Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Parse Operations using High-Level Descriptions #33

Merged
merged 6 commits into from
May 22, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
390 changes: 390 additions & 0 deletions parser/match_operations.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,390 @@
// Copyright 2020 Coinbase, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package parser

import (
"errors"
"fmt"
"reflect"

"github.com/coinbase/rosetta-sdk-go/types"
)

// AmountSign is used to represent possible signedness
// of an amount.
type AmountSign int

const (
// AnyAmountSign is a positive or negative amount.
AnyAmountSign = iota

// NegativeAmountSign is a negative amount.
NegativeAmountSign

// PositiveAmountSign is a positive amount.
PositiveAmountSign

// oppositesLength is the only allowed number of
// operations to compare as opposites.
oppositesLength = 2
)

// Match returns a boolean indicating if a *types.Amount
// has an AmountSign.
func (s AmountSign) Match(amount *types.Amount) bool {
if s == AnyAmountSign {
return true
}

numeric, err := types.AmountValue(amount)
if err != nil {
return false
}

if s == NegativeAmountSign && numeric.Sign() == -1 {
return true
}

if s == PositiveAmountSign && numeric.Sign() == 1 {
return true
}

return false
}

// String returns a description of an AmountSign.
func (s AmountSign) String() string {
switch s {
case AnyAmountSign:
return "any"
case NegativeAmountSign:
return "negative"
case PositiveAmountSign:
return "positive"
default:
return "invalid"
}
}

// MetadataDescription is used to check if a map[string]interface{}
// has certain keys and values of a certain kind.
type MetadataDescription struct {
Key string
ValueKind reflect.Kind // ex: reflect.String
}

// AccountDescription is used to describe a *types.AccountIdentifier.
type AccountDescription struct {
Exists bool
SubAccountExists bool
SubAccountAddress string
SubAccountMetadataKeys []*MetadataDescription
}

// AmountDescription is used to describe a *types.Amount.
type AmountDescription struct {
Exists bool
Sign AmountSign
Currency *types.Currency
}

// OperationDescription is used to describe a *types.Operation.
type OperationDescription struct {
Account *AccountDescription
Amount *AmountDescription
Metadata []*MetadataDescription
}

// Descriptions contains a slice of OperationDescriptions and
// high-level requirements enforced across multiple *types.Operations.
type Descriptions struct {
// EqualAmounts are specified using the operation indicies of
// OperationDescriptions to handle out of order matches.
EqualAmounts [][]int

// OppositeAmounts are specified using the operation indicies of
// OperationDescriptions to handle out of order matches.
OppositeAmounts [][]int

RejectExtraOperations bool
OperationDescriptions []*OperationDescription
}

// metadataMatch returns an error if a map[string]interface does not meet
// a slice of *MetadataDescription.
func metadataMatch(reqs []*MetadataDescription, metadata map[string]interface{}) error {
if len(reqs) == 0 {
return nil
}

for _, req := range reqs {
val, ok := metadata[req.Key]
if !ok {
return fmt.Errorf("%s is not present in metadata", req.Key)
}

if reflect.TypeOf(val).Kind() != req.ValueKind {
return fmt.Errorf("%s value is not %s", req.Key, req.ValueKind)
}
}

return nil
}

// accountMatch returns an error if a *types.AccountIdentifier does not meet
// an *AccountDescription.
func accountMatch(req *AccountDescription, account *types.AccountIdentifier) error {
if req == nil { //anything is ok
return nil
}

if account == nil {
if req.Exists {
return errors.New("account is missing")
}

return nil
}

if account.SubAccount == nil {
if req.SubAccountExists {
return errors.New("SubAccountIdentifier.Address is missing")
}

return nil
}

if !req.SubAccountExists {
return errors.New("SubAccount is populated")
}

// Optionally can require a certain subaccount address
if len(req.SubAccountAddress) > 0 && account.SubAccount.Address != req.SubAccountAddress {
return fmt.Errorf(
"SubAccountIdentifier.Address is %s not %s",
account.SubAccount.Address,
req.SubAccountAddress,
)
}

if err := metadataMatch(req.SubAccountMetadataKeys, account.SubAccount.Metadata); err != nil {
return fmt.Errorf("%w: account metadata keys mismatch", err)
}

return nil
}

// amountMatch returns an error if a *types.Amount does not meet an
// *AmountDescription.
func amountMatch(req *AmountDescription, amount *types.Amount) error {
if req == nil { // anything is ok
return nil
}

if amount == nil {
if req.Exists {
return errors.New("amount is missing")
}

return nil
}

if !req.Exists {
return errors.New("amount is populated")
}

if !req.Sign.Match(amount) {
return fmt.Errorf("amount sign was not %s", req.Sign.String())
}

// If no currency is provided, anything is ok.
if req.Currency == nil {
return nil
}

if amount.Currency == nil || types.Hash(amount.Currency) != types.Hash(req.Currency) {
return fmt.Errorf("currency %+v is not %+v", amount.Currency, req.Currency)
}

return nil
}

// operationMatch returns an error if a *types.Operation does not match a
// *OperationDescription.
func operationMatch(
groupIndex int,
operation *types.Operation,
descriptions []*OperationDescription,
matches []int,
) {
for i, req := range descriptions {
if matches[i] != -1 { // already matched
continue
}

if err := accountMatch(req.Account, operation.Account); err != nil {
continue
}

if err := amountMatch(req.Amount, operation.Amount); err != nil {
continue
}

if err := metadataMatch(req.Metadata, operation.Metadata); err != nil {
continue
}

matches[i] = groupIndex
return
}
}

// equalAmounts returns an error if a slice of operations do not have
// equal amounts.
func equalAmounts(ops []*types.Operation) error {
if len(ops) <= 1 {
return fmt.Errorf("cannot check equality of %d operations", len(ops))
}

val, err := types.AmountValue(ops[0].Amount)
if err != nil {
return err
}

for _, op := range ops {
otherVal, err := types.AmountValue(op.Amount)
if err != nil {
return err
}

if val.Cmp(otherVal) != 0 {
return fmt.Errorf("%s is not equal to %s", val.String(), otherVal.String())
}
}

return nil
}

// oppositeAmounts returns an error if two operations do not have opposite
// amounts.
func oppositeAmounts(a *types.Operation, b *types.Operation) error {
aVal, err := types.AmountValue(a.Amount)
if err != nil {
return err
}

bVal, err := types.AmountValue(b.Amount)
if err != nil {
return err
}

if aVal.Sign() == bVal.Sign() {
return fmt.Errorf("%s and %s have the same sign", aVal.String(), bVal.String())
}

if aVal.CmpAbs(bVal) != 0 {
return fmt.Errorf("|%s| and |%s| are not equal", aVal.String(), bVal.String())
}

return nil
}

// comparisonMatch ensures collections of *types.Operations
// have either equal or opposite amounts.
func comparisonMatch(
matches []int,
descriptions *Descriptions,
operations []*types.Operation,
) error {
for _, amountMatch := range descriptions.EqualAmounts {
ops := make([]*types.Operation, len(amountMatch))
for j, reqIndex := range amountMatch {
ops[j] = operations[matches[reqIndex]]
}

if err := equalAmounts(ops); err != nil {
return fmt.Errorf("%w: operations not equal", err)
}
}

for _, amountMatch := range descriptions.OppositeAmounts {
if len(amountMatch) != oppositesLength { // cannot have opposites without exactly 2
return fmt.Errorf("cannot check opposites of %d operations", len(amountMatch))
}

if err := oppositeAmounts(
operations[matches[amountMatch[0]]],
operations[matches[amountMatch[1]]],
); err != nil {
return fmt.Errorf("%w: amounts not opposites", err)
}
}

return nil
}

// MatchOperations attempts to match a slice of operations with a slice of
// OperationDescriptions (high-level descriptions of what operations are
// desired). If matching succeeds, a slice of indicies are returned mapping
// OperationDescriptions to operations.
func MatchOperations(
descriptions *Descriptions,
operations []*types.Operation,
) ([]int, error) {
if len(operations) == 0 {
return nil, errors.New("unable to match anything to 0 operations")
}

if len(descriptions.OperationDescriptions) == 0 {
return nil, errors.New("unable to match 0 descriptions")
}

if descriptions.RejectExtraOperations &&
len(descriptions.OperationDescriptions) != len(operations) {
return nil, fmt.Errorf(
"expected %d operations, got %d",
len(descriptions.OperationDescriptions),
len(operations),
)
}

operationDescriptions := descriptions.OperationDescriptions
matches := make([]int, len(operationDescriptions))

// Set all matches to -1 so we know if any are unmatched
for i := 0; i < len(matches); i++ {
matches[i] = -1
}

// Match a *types.Operation to each *OperationDescription
for i, op := range operations {
operationMatch(i, op, operationDescriptions, matches)
}

// Error if any *OperationDescription is not matched
for i := 0; i < len(matches); i++ {
if matches[i] == -1 {
return nil, fmt.Errorf("could not find match for Description %d", i)
}
}

// Once matches are found, assert high-level descriptions between
// *types.Operations
if err := comparisonMatch(matches, descriptions, operations); err != nil {
return nil, fmt.Errorf("%w: group descriptions not met", err)
}

return matches, nil
}
Loading