Repository: coinbase/rosetta-sdk-go
Pull request #78 (2020-07-22 23:10:24)

Compare Intent vs Observed Operations and Signers
Related Issue: https://github.com/coinbase/rosetta-cli/issues/68

### Changes
Compare intended vs observed operations and singers. This is useful when comparing the input to the `Construction API` with the output of `/construction/parse`.


Patch:
diff --git a/parser/intent.go b/parser/intent.go
new file mode 100644
index 0000000..51d5e34
--- /dev/null
+++ b/parser/intent.go
@@ -0,0 +1,154 @@
+// 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 (
+	"fmt"
+	"log"
+
+	"github.com/coinbase/rosetta-sdk-go/asserter"
+	"github.com/coinbase/rosetta-sdk-go/types"
+)
+
+// ExpectedOperation returns an error if an observed operation
+// differs from the intended operation. An operation is considered
+// to be different from the intent if the AccountIdentifier,
+// Amount, or Type has changed.
+func ExpectedOperation(intent *types.Operation, observed *types.Operation) error {
+	if types.Hash(intent.Account) != types.Hash(observed.Account) {
+		return fmt.Errorf(
+			"intended account %s did not match observed account %s",
+			types.PrettyPrintStruct(intent.Account),
+			types.PrettyPrintStruct(observed.Account),
+		)
+	}
+
+	if types.Hash(intent.Amount) != types.Hash(observed.Amount) {
+		return fmt.Errorf(
+			"intended amount %s did not match observed amount %s",
+			types.PrettyPrintStruct(intent.Amount),
+			types.PrettyPrintStruct(observed.Amount),
+		)
+	}
+
+	if intent.Type != observed.Type {
+		return fmt.Errorf(
+			"intended type %s did not match observed type %s",
+			intent.Type,
+			observed.Type,
+		)
+	}
+
+	return nil
+}
+
+// ExpectedOperations returns an error if a slice of intended
+// operations differ from observed operations. Optionally,
+// it is possible to error if any extra observed opertions
+// are found.
+func ExpectedOperations(
+	intent []*types.Operation,
+	observed []*types.Operation,
+	errExtra bool,
+) error {
+	matches := make(map[int]struct{})
+	unmatched := []*types.Operation{}
+
+	for _, obs := range observed {
+		foundMatch := false
+		for i, in := range intent {
+			if _, exists := matches[i]; exists {
+				continue
+			}
+
+			if ExpectedOperation(in, obs) == nil {
+				matches[i] = struct{}{}
+				foundMatch = true
+				break
+			}
+		}
+
+		if !foundMatch {
+			if errExtra {
+				return fmt.Errorf(
+					"found extra operation %s",
+					types.PrettyPrintStruct(obs),
+				)
+			}
+			unmatched = append(unmatched, obs)
+		}
+	}
+
+	for i := 0; i < len(intent); i++ {
+		if _, exists := matches[i]; !exists {
+			return fmt.Errorf(
+				"could not find operation with intent %s in observed",
+				types.PrettyPrintStruct(intent[i]),
+			)
+		}
+	}
+
+	if len(unmatched) > 0 {
+		log.Printf("found extra operations: %s\n", types.PrettyPrintStruct(unmatched))
+	}
+
+	return nil
+}
+
+// ExpectedSigners returns an error if a slice of SigningPayload
+// has different signers than what was observed (typically populated
+// using the signers returned from parsing a transaction).
+func ExpectedSigners(intent []*types.SigningPayload, observed []string) error {
+	// De-duplicate required signers (ex: multiple UTXOs from same address)
+	intendedSigners := make(map[string]struct{})
+	for _, payload := range intent {
+		intendedSigners[payload.Address] = struct{}{}
+	}
+
+	if err := asserter.StringArray("observed signers", observed); err != nil {
+		return fmt.Errorf("%w: found duplicate signer", err)
+	}
+
+	// Could exist here if len(intent) != len(observed) but
+	// more useful to print out a detailed error message.
+	seenSigners := make(map[string]struct{})
+	unmatched := []string{} // observed
+	for _, signer := range observed {
+		_, exists := intendedSigners[signer]
+		if !exists {
+			unmatched = append(unmatched, signer)
+		} else {
+			seenSigners[signer] = struct{}{}
+		}
+	}
+
+	for k := range intendedSigners {
+		if _, exists := seenSigners[k]; !exists {
+			return fmt.Errorf(
+				"could not find match for intended signer %s",
+				k,
+			)
+		}
+	}
+
+	if len(unmatched) != 0 {
+		return fmt.Errorf(
+			"found unexpected signers: %s",
+			types.PrettyPrintStruct(unmatched),
+		)
+	}
+
+	return nil
+}


Test patch:
diff --git a/parser/intent_test.go b/parser/intent_test.go
new file mode 100644
index 0000000..ab678ce
--- /dev/null
+++ b/parser/intent_test.go
@@ -0,0 +1,443 @@
+// 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 (
+	"testing"
+
+	"github.com/coinbase/rosetta-sdk-go/types"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestExpectedOperation(t *testing.T) {
+	var tests = map[string]struct {
+		intent   *types.Operation
+		observed *types.Operation
+
+		err bool
+	}{
+		"simple match": {
+			intent: &types.Operation{
+				OperationIdentifier: &types.OperationIdentifier{
+					Index: 1,
+				},
+				Type: "transfer",
+				Account: &types.AccountIdentifier{
+					Address: "addr1",
+				},
+				Amount: &types.Amount{
+					Value: "100",
+				},
+			},
+			observed: &types.Operation{
+				OperationIdentifier: &types.OperationIdentifier{
+					Index: 3,
+				},
+				RelatedOperations: []*types.OperationIdentifier{
+					{
+						Index: 2,
+					},
+				},
+				Status: "success",
+				Type:   "transfer",
+				Account: &types.AccountIdentifier{
+					Address: "addr1",
+				},
+				Amount: &types.Amount{
+					Value: "100",
+				},
+			},
+		},
+		"account mismatch": {
+			intent: &types.Operation{
+				OperationIdentifier: &types.OperationIdentifier{
+					Index: 1,
+				},
+				Type: "transfer",
+				Account: &types.AccountIdentifier{
+					Address: "addr1",
+				},
+				Amount: &types.Amount{
+					Value: "100",
+				},
+			},
+			observed: &types.Operation{
+				OperationIdentifier: &types.OperationIdentifier{
+					Index: 3,
+				},
+				RelatedOperations: []*types.OperationIdentifier{
+					{
+						Index: 2,
+					},
+				},
+				Status: "success",
+				Type:   "transfer",
+				Account: &types.AccountIdentifier{
+					Address: "addr2",
+				},
+				Amount: &types.Amount{
+					Value: "100",
+				},
+			},
+			err: true,
+		},
+		"amount mismatch": {
+			intent: &types.Operation{
+				OperationIdentifier: &types.OperationIdentifier{
+					Index: 1,
+				},
+				Type: "transfer",
+				Account: &types.AccountIdentifier{
+					Address: "addr1",
+				},
+				Amount: &types.Amount{
+					Value: "100",
+				},
+			},
+			observed: &types.Operation{
+				OperationIdentifier: &types.OperationIdentifier{
+					Index: 3,
+				},
+				RelatedOperations: []*types.OperationIdentifier{
+					{
+						Index: 2,
+					},
+				},
+				Status: "success",
+				Type:   "transfer",
+				Account: &types.AccountIdentifier{
+					Address: "addr1",
+				},
+				Amount: &types.Amount{
+					Value: "150",
+				},
+			},
+			err: true,
+		},
+		"type mismatch": {
+			intent: &types.Operation{
+				OperationIdentifier: &types.OperationIdentifier{
+					Index: 1,
+				},
+				Type: "transfer",
+				Account: &types.AccountIdentifier{
+					Address: "addr1",
+				},
+				Amount: &types.Amount{
+					Value: "100",
+				},
+			},
+			observed: &types.Operation{
+				OperationIdentifier: &types.OperationIdentifier{
+					Index: 3,
+				},
+				RelatedOperations: []*types.OperationIdentifier{
+					{
+						Index: 2,
+					},
+				},
+				Status: "success",
+				Type:   "reward",
+				Account: &types.AccountIdentifier{
+					Address: "addr1",
+				},
+				Amount: &types.Amount{
+					Value: "100",
+				},
+			},
+			err: true,
+		},
+	}
+
+	for name, test := range tests {
+		t.Run(name, func(t *testing.T) {
+			err := ExpectedOperation(test.intent, test.observed)
+			if test.err {
+				assert.Error(t, err)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestExpectedOperations(t *testing.T) {
+	var tests = map[string]struct {
+		intent   []*types.Operation
+		observed []*types.Operation
+		errExtra bool
+
+		err bool
+	}{
+		"simple match": {
+			intent: []*types.Operation{
+				{
+					OperationIdentifier: &types.OperationIdentifier{
+						Index: 1,
+					},
+					Type: "transfer",
+					Account: &types.AccountIdentifier{
+						Address: "addr1",
+					},
+					Amount: &types.Amount{
+						Value: "100",
+					},
+				},
+				{
+					OperationIdentifier: &types.OperationIdentifier{
+						Index: 5,
+					},
+					Type: "fee",
+					Account: &types.AccountIdentifier{
+						Address: "addr2",
+					},
+					Amount: &types.Amount{
+						Value: "50",
+					},
+				},
+			},
+			observed: []*types.Operation{
+				{
+					OperationIdentifier: &types.OperationIdentifier{
+						Index: 2,
+					},
+					Status: "success",
+					Type:   "fee",
+					Account: &types.AccountIdentifier{
+						Address: "addr2",
+					},
+					Amount: &types.Amount{
+						Value: "50",
+					},
+				},
+				{
+					OperationIdentifier: &types.OperationIdentifier{
+						Index: 3,
+					},
+					RelatedOperations: []*types.OperationIdentifier{
+						{
+							Index: 2,
+						},
+					},
+					Status: "success",
+					Type:   "transfer",
+					Account: &types.AccountIdentifier{
+						Address: "addr1",
+					},
+					Amount: &types.Amount{
+						Value: "100",
+					},
+				},
+			},
+		},
+		"err extra": {
+			intent: []*types.Operation{
+				{
+					OperationIdentifier: &types.OperationIdentifier{
+						Index: 1,
+					},
+					Type: "transfer",
+					Account: &types.AccountIdentifier{
+						Address: "addr1",
+					},
+					Amount: &types.Amount{
+						Value: "100",
+					},
+				},
+			},
+			observed: []*types.Operation{
+				{
+					OperationIdentifier: &types.OperationIdentifier{
+						Index: 2,
+					},
+					Status: "success",
+					Type:   "fee",
+					Account: &types.AccountIdentifier{
+						Address: "addr2",
+					},
+					Amount: &types.Amount{
+						Value: "100",
+					},
+				},
+				{
+					OperationIdentifier: &types.OperationIdentifier{
+						Index: 3,
+					},
+					RelatedOperations: []*types.OperationIdentifier{
+						{
+							Index: 2,
+						},
+					},
+					Status: "success",
+					Type:   "transfer",
+					Account: &types.AccountIdentifier{
+						Address: "addr1",
+					},
+					Amount: &types.Amount{
+						Value: "100",
+					},
+				},
+			},
+			errExtra: true,
+			err:      true,
+		},
+		"missing match": {
+			intent: []*types.Operation{
+				{
+					OperationIdentifier: &types.OperationIdentifier{
+						Index: 1,
+					},
+					Type: "transfer",
+					Account: &types.AccountIdentifier{
+						Address: "addr1",
+					},
+					Amount: &types.Amount{
+						Value: "100",
+					},
+				},
+				{
+					OperationIdentifier: &types.OperationIdentifier{
+						Index: 5,
+					},
+					Type: "fee",
+					Account: &types.AccountIdentifier{
+						Address: "addr2",
+					},
+					Amount: &types.Amount{
+						Value: "50",
+					},
+				},
+			},
+			observed: []*types.Operation{
+				{
+					OperationIdentifier: &types.OperationIdentifier{
+						Index: 3,
+					},
+					RelatedOperations: []*types.OperationIdentifier{
+						{
+							Index: 2,
+						},
+					},
+					Status: "success",
+					Type:   "transfer",
+					Account: &types.AccountIdentifier{
+						Address: "addr1",
+					},
+					Amount: &types.Amount{
+						Value: "100",
+					},
+				},
+			},
+			err: true,
+		},
+	}
+
+	for name, test := range tests {
+		t.Run(name, func(t *testing.T) {
+			err := ExpectedOperations(test.intent, test.observed, test.errExtra)
+			if test.err {
+				assert.Error(t, err)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestExpectedSigners(t *testing.T) {
+	var tests = map[string]struct {
+		intent   []*types.SigningPayload
+		observed []string
+
+		err bool
+	}{
+		"simple match": {
+			intent: []*types.SigningPayload{
+				{
+					Address: "addr1",
+				},
+				{
+					Address: "addr2",
+				},
+				{
+					Address: "addr2",
+				},
+			},
+			observed: []string{
+				"addr1",
+				"addr2",
+			},
+		},
+		"duplicate observed signers": {
+			intent: []*types.SigningPayload{
+				{
+					Address: "addr1",
+				},
+				{
+					Address: "addr2",
+				},
+				{
+					Address: "addr2",
+				},
+			},
+			observed: []string{
+				"addr1",
+				"addr2",
+				"addr2",
+			},
+			err: true,
+		},
+		"missing observed signer": {
+			intent: []*types.SigningPayload{
+				{
+					Address: "addr1",
+				},
+				{
+					Address: "addr2",
+				},
+				{
+					Address: "addr2",
+				},
+			},
+			observed: []string{
+				"addr1",
+			},
+			err: true,
+		},
+		"extra observed signer": {
+			intent: []*types.SigningPayload{
+				{
+					Address: "addr1",
+				},
+			},
+			observed: []string{
+				"addr1",
+				"addr2",
+			},
+			err: true,
+		},
+	}
+
+	for name, test := range tests {
+		t.Run(name, func(t *testing.T) {
+			err := ExpectedSigners(test.intent, test.observed)
+			if test.err {
+				assert.Error(t, err)
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}