Repository: coinbase/rosetta-sdk-go
Pull request #326 (2021-08-18 22:46:55)

Introduce a validation config for validating operation using rosetta-cli
Fixes # .

### Motivation
There are lots of validation `rosetta-cli` doesn't perform on operation. 
Few of them include
- validating that deposit and withdraw balances
- issuers might want to validate the number of operation for payment or fee

### Solution
We introduce a validation config file which an asset issuer can specify and provide as an argument while doing `check:data` (more to come on how to use)

TODO:
- Add more tests
- Figure out how to release the package so that it doesn't break existing usage

### Open questions
<!--
(optional) Any open questions or feedback on design desired?
-->


coveralls: ## Pull Request Test Coverage Report for [Build 14844](https://coveralls.io/builds/42280407)

* **58** of **62**   **(93.55%)**  changed or added relevant lines in **3** files are covered.
* **5** unchanged lines in **1** file lost coverage.
* Overall coverage increased (+**0.09%**) to **77.759%**

---

|  Changes Missing Coverage | Covered Lines | Changed/Added Lines | % |
| :-----|--------------|--------|---: |
| [asserter/asserter.go](https://coveralls.io/builds/42280407/source?filename=asserter%2Fasserter.go#L89) | 25 | 27 | 92.59%
| [asserter/block.go](https://coveralls.io/builds/42280407/source?filename=asserter%2Fblock.go#L374) | 32 | 34 | 94.12%
<!-- | **Total:** | **58** | **62** | **93.55%** | -->

|  Files with Coverage Reduction | New Missed Lines | % |
| :-----|--------------|--: |
| [asserter/block.go](https://coveralls.io/builds/42280407/source?filename=asserter%2Fblock.go#L387) | 5 | 88.89% |
<!-- | **Total:** | **5** |  | -->

|  Totals | [![Coverage Status](https://coveralls.io/builds/42280407/badge)](https://coveralls.io/builds/42280407) |
| :-- | --: |
| Change from base [Build 14773](https://coveralls.io/builds/38900943): |  0.09% |
| Covered Lines: | 8489 |
| Relevant Lines: | 10917 |

---
##### 💛  - [Coveralls](https://coveralls.io)


Patch:
diff --git a/asserter/asserter.go b/asserter/asserter.go
index 22b4d37..84ecd9a 100644
--- a/asserter/asserter.go
+++ b/asserter/asserter.go
@@ -39,8 +39,33 @@ type Asserter struct {
 	supportedNetworks       []*types.NetworkIdentifier
 	callMethods             map[string]struct{}
 	mempoolCoins            bool
+	validations             *Validations
 }
 
+type Validations struct {
+	Enabled   bool                 `json:"enabled"`
+	ChainType ChainType            `json:"chain_type"`
+	Payment   *ValidationOperation `json:"payment"`
+	Fee       *ValidationOperation `json:"fee"`
+}
+
+type ValidationOperation struct {
+	Name      string     `json:"name"`
+	Operation *Operation `json:"operation"`
+}
+
+type Operation struct {
+	Count         int  `json:"count"`
+	ShouldBalance bool `json:"should_balance"`
+}
+
+type ChainType string
+
+const (
+	Account ChainType = "account"
+	UTXO    ChainType = "utxo"
+)
+
 // NewServer constructs a new Asserter for use in the
 // server package.
 func NewServer(
@@ -49,6 +74,7 @@ func NewServer(
 	supportedNetworks []*types.NetworkIdentifier,
 	callMethods []string,
 	mempoolCoins bool,
+	validationFilePath string,
 ) (*Asserter, error) {
 	if err := OperationTypes(supportedOperationTypes); err != nil {
 		return nil, err
@@ -58,6 +84,11 @@ func NewServer(
 		return nil, err
 	}
 
+	validationConfig, err := getValidationConfig(validationFilePath)
+	if err != nil {
+		return nil, err
+	}
+
 	callMap := map[string]struct{}{}
 	for _, method := range callMethods {
 		if len(method) == 0 {
@@ -77,6 +108,7 @@ func NewServer(
 		supportedNetworks:       supportedNetworks,
 		callMethods:             callMap,
 		mempoolCoins:            mempoolCoins,
+		validations:             validationConfig,
 	}, nil
 }
 
@@ -87,6 +119,7 @@ func NewClientWithResponses(
 	network *types.NetworkIdentifier,
 	networkStatus *types.NetworkStatusResponse,
 	networkOptions *types.NetworkOptionsResponse,
+	validationFilePath string,
 ) (*Asserter, error) {
 	if err := NetworkIdentifier(network); err != nil {
 		return nil, err
@@ -100,6 +133,11 @@ func NewClientWithResponses(
 		return nil, err
 	}
 
+	validationConfig, err := getValidationConfig(validationFilePath)
+	if err != nil {
+		return nil, err
+	}
+
 	return NewClientWithOptions(
 		network,
 		networkStatus.GenesisBlockIdentifier,
@@ -107,6 +145,7 @@ func NewClientWithResponses(
 		networkOptions.Allow.OperationStatuses,
 		networkOptions.Allow.Errors,
 		networkOptions.Allow.TimestampStartIndex,
+		validationConfig,
 	)
 }
 
@@ -147,6 +186,9 @@ func NewClientWithFile(
 		config.AllowedOperationStatuses,
 		config.AllowedErrors,
 		&config.AllowedTimestampStartIndex,
+		&Validations{
+			Enabled: false,
+		},
 	)
 }
 
@@ -160,6 +202,7 @@ func NewClientWithOptions(
 	operationStatuses []*types.OperationStatus,
 	errors []*types.Error,
 	timestampStartIndex *int64,
+	validationConfig *Validations,
 ) (*Asserter, error) {
 	if err := NetworkIdentifier(network); err != nil {
 		return nil, err
@@ -197,6 +240,7 @@ func NewClientWithOptions(
 		operationTypes:      operationTypes,
 		genesisBlock:        genesisBlockIdentifier,
 		timestampStartIndex: parsedTimestampStartIndex,
+		validations:         validationConfig,
 	}
 
 	asserter.operationStatusMap = map[string]bool{}
@@ -261,3 +305,20 @@ func (a *Asserter) OperationSuccessful(operation *types.Operation) (bool, error)
 
 	return val, nil
 }
+
+func getValidationConfig(validationFilePath string) (*Validations, error) {
+	validationConfig := &Validations{
+		Enabled: false,
+	}
+	if validationFilePath != "" {
+		content, err := ioutil.ReadFile(path.Clean(validationFilePath))
+		if err != nil {
+			return nil, err
+		}
+
+		if err := json.Unmarshal(content, validationConfig); err != nil {
+			return nil, err
+		}
+	}
+	return validationConfig, nil
+}
diff --git a/asserter/block.go b/asserter/block.go
index 2b88955..a9fadfa 100644
--- a/asserter/block.go
+++ b/asserter/block.go
@@ -293,12 +293,30 @@ func (a *Asserter) Operations(
 		return ErrNoOperationsForConstruction
 	}
 
+	paymentTotal := big.NewInt(0)
+	feeTotal := big.NewInt(0)
+	paymentCount := 0
+	feeCount := 0
 	for i, op := range operations {
 		// Ensure operations are sorted
 		if err := a.Operation(op, int64(i), construction); err != nil {
 			return err
 		}
 
+		if a.validations.Enabled {
+			if op.Type == a.validations.Payment.Name {
+				val, _ := new(big.Int).SetString(op.Amount.Value, 10)
+				paymentTotal.Add(paymentTotal, val)
+				paymentCount++
+			}
+
+			if op.Type == a.validations.Fee.Name {
+				val, _ := new(big.Int).SetString(op.Amount.Value, 10)
+				feeTotal.Add(feeTotal, val)
+				feeCount++
+			}
+		}
+
 		// Ensure an operation's related_operations are only
 		// operations with an index less than the operation
 		// and that there are no duplicates.
@@ -325,6 +343,38 @@ func (a *Asserter) Operations(
 		}
 	}
 
+	// only account based validation
+	if a.validations.Enabled && a.validations.ChainType == Account {
+		return a.ValidatePaymentAndFee(paymentTotal, paymentCount, feeTotal, feeCount)
+	}
+
+	return nil
+}
+
+func (a *Asserter) ValidatePaymentAndFee(
+	paymentTotal *big.Int,
+	paymentCount int,
+	feeTotal *big.Int,
+	feeCount int,
+) error {
+	zero := big.NewInt(0)
+	if a.validations.Payment.Operation.Count != -1 &&
+		a.validations.Payment.Operation.Count != paymentCount {
+		return ErrPaymentCountMismatch
+	}
+
+	if a.validations.Payment.Operation.ShouldBalance && paymentTotal.Cmp(zero) != 0 {
+		return ErrPaymentAmountNotBalancing
+	}
+
+	if a.validations.Fee.Operation.Count != -1 && a.validations.Fee.Operation.Count != feeCount {
+		return ErrFeeCountMismatch
+	}
+
+	if a.validations.Fee.Operation.ShouldBalance && feeTotal.Cmp(zero) != 0 {
+		return ErrPaymentAmountNotBalancing
+	}
+
 	return nil
 }
 
diff --git a/asserter/data/validation_fee_and_payment_balanced.json b/asserter/data/validation_fee_and_payment_balanced.json
new file mode 100644
index 0000000..6693ce0
--- /dev/null
+++ b/asserter/data/validation_fee_and_payment_balanced.json
@@ -0,0 +1,18 @@
+{
+  "enabled": true,
+  "chain_type": "account",
+  "payment": {
+    "name": "PAYMENT",
+    "operation": {
+      "count": 2,
+      "should_balance": true
+    }
+  },
+  "fee": {
+    "name": "FEE",
+    "operation": {
+      "count": 1,
+      "should_balance": false
+    }
+  }
+}
\ No newline at end of file
diff --git a/asserter/data/validation_fee_and_payment_unbalanced.json b/asserter/data/validation_fee_and_payment_unbalanced.json
new file mode 100644
index 0000000..1272cb4
--- /dev/null
+++ b/asserter/data/validation_fee_and_payment_unbalanced.json
@@ -0,0 +1,18 @@
+{
+  "enabled": true,
+  "chain_type": "account",
+  "payment": {
+    "name": "PAYMENT",
+    "operation": {
+      "count": 2,
+      "should_balance": false
+    }
+  },
+  "fee": {
+    "name": "FEE",
+    "operation": {
+      "count": 1,
+      "should_balance": false
+    }
+  }
+}
\ No newline at end of file
diff --git a/asserter/errors.go b/asserter/errors.go
index 8f0b588..11469a6 100644
--- a/asserter/errors.go
+++ b/asserter/errors.go
@@ -95,6 +95,10 @@ var (
 		"invalid direction (must be 'forward' or 'backward')",
 	)
 	ErrDuplicateRelatedTransaction = errors.New("duplicate related transaction")
+	ErrPaymentAmountNotBalancing   = errors.New("payment amount doesn't balance")
+	ErrFeeAmountNotBalancing       = errors.New("fee amount doesn't balance")
+	ErrPaymentCountMismatch        = errors.New("payment count doesn't match")
+	ErrFeeCountMismatch            = errors.New("fee count doesn't match")
 
 	BlockErrs = []error{
 		ErrAmountValueMissing,
@@ -131,6 +135,8 @@ var (
 		ErrBlockIndexPrecedesParentBlockIndex,
 		ErrInvalidDirection,
 		ErrDuplicateRelatedTransaction,
+		ErrPaymentAmountNotBalancing,
+		ErrFeeAmountNotBalancing,
 	}
 )
 
diff --git a/examples/client/main.go b/examples/client/main.go
index ea9d221..22cc071 100644
--- a/examples/client/main.go
+++ b/examples/client/main.go
@@ -128,6 +128,7 @@ func main() {
 		primaryNetwork,
 		networkStatus,
 		networkOptions,
+		"",
 	)
 	if err != nil {
 		log.Fatal(err)
@@ -159,8 +160,8 @@ func main() {
 	// required fields are populated and that operations
 	// in the block only use types and statuses that were
 	// provided in the networkStatusResponse. To run more
-	// intensive validation, use the Rosetta Validator. It
-	// can be found at: https://github.com/coinbase/rosetta-validator
+	// intensive validation, use the Rosetta CLI. It
+	// can be found at: https://github.com/coinbase/rosetta-cli
 	err = asserter.Block(block.Block)
 	if err != nil {
 		log.Fatalf("Assertion Error: %s\n", err.Error())
diff --git a/examples/fetcher/main.go b/examples/fetcher/main.go
index c20dab8..e0e0da1 100644
--- a/examples/fetcher/main.go
+++ b/examples/fetcher/main.go
@@ -40,7 +40,7 @@ func main() {
 	// Behind the scenes this makes a call to get the
 	// network status and uses the response to inform
 	// the asserter what are valid responses.
-	primaryNetwork, networkStatus, err := newFetcher.InitializeAsserter(ctx, nil)
+	primaryNetwork, networkStatus, err := newFetcher.InitializeAsserter(ctx, nil, "")
 	if err != nil {
 		log.Fatal(err)
 	}
diff --git a/examples/server/main.go b/examples/server/main.go
index ae7699a..cef1103 100644
--- a/examples/server/main.go
+++ b/examples/server/main.go
@@ -64,6 +64,7 @@ func main() {
 		[]*types.NetworkIdentifier{network},
 		nil,
 		false,
+		"",
 	)
 	if err != nil {
 		log.Fatal(err)
diff --git a/fetcher/fetcher.go b/fetcher/fetcher.go
index b13bf41..0f313be 100644
--- a/fetcher/fetcher.go
+++ b/fetcher/fetcher.go
@@ -148,6 +148,7 @@ func New(
 func (f *Fetcher) InitializeAsserter(
 	ctx context.Context,
 	networkIdentifier *types.NetworkIdentifier,
+	validationFilePath string,
 ) (
 	*types.NetworkIdentifier,
 	*types.NetworkStatusResponse,
@@ -208,6 +209,7 @@ func (f *Fetcher) InitializeAsserter(
 		primaryNetwork,
 		networkStatus,
 		networkOptions,
+		validationFilePath,
 	)
 	if assertErr != nil {
 		return nil, nil, &Error{Err: assertErr}


Test patch:
diff --git a/asserter/asserter_test.go b/asserter/asserter_test.go
index e7e476a..187d987 100644
--- a/asserter/asserter_test.go
+++ b/asserter/asserter_test.go
@@ -262,74 +262,84 @@ func TestNew(t *testing.T) {
 	)
 
 	var tests = map[string]struct {
-		network        *types.NetworkIdentifier
-		networkStatus  *types.NetworkStatusResponse
-		networkOptions *types.NetworkOptionsResponse
+		network            *types.NetworkIdentifier
+		networkStatus      *types.NetworkStatusResponse
+		networkOptions     *types.NetworkOptionsResponse
+		validationFilePath string
 
 		err          error
 		skipLoadTest bool
 	}{
 		"valid responses": {
-			network:        validNetwork,
-			networkStatus:  validNetworkStatus,
-			networkOptions: validNetworkOptions,
+			network:            validNetwork,
+			networkStatus:      validNetworkStatus,
+			networkOptions:     validNetworkOptions,
+			validationFilePath: "",
 
 			err: nil,
 		},
 		"valid responses (with sync status)": {
-			network:        validNetwork,
-			networkStatus:  validNetworkStatusSyncStatus,
-			networkOptions: validNetworkOptions,
+			network:            validNetwork,
+			networkStatus:      validNetworkStatusSyncStatus,
+			networkOptions:     validNetworkOptions,
+			validationFilePath: "",
 
 			err: nil,
 		},
 		"valid responses (with start index)": {
-			network:        validNetwork,
-			networkStatus:  validNetworkStatus,
-			networkOptions: validNetworkOptionsWithStartIndex,
+			network:            validNetwork,
+			networkStatus:      validNetworkStatus,
+			networkOptions:     validNetworkOptionsWithStartIndex,
+			validationFilePath: "",
 
 			err: nil,
 		},
 		"invalid network status": {
-			network:        validNetwork,
-			networkStatus:  invalidNetworkStatus,
-			networkOptions: validNetworkOptions,
+			network:            validNetwork,
+			networkStatus:      invalidNetworkStatus,
+			networkOptions:     validNetworkOptions,
+			validationFilePath: "",
 
 			err: errors.New("BlockIdentifier is nil"),
 		},
 		"invalid network status (with SyncStatus)": {
-			network:        validNetwork,
-			networkStatus:  invalidNetworkStatusSyncStatus,
-			networkOptions: validNetworkOptions,
+			network:            validNetwork,
+			networkStatus:      invalidNetworkStatusSyncStatus,
+			networkOptions:     validNetworkOptions,
+			validationFilePath: "",
 
 			err:          errors.New("SyncStatus.CurrentIndex is negative"),
 			skipLoadTest: true,
 		},
 		"invalid network options": {
-			network:        validNetwork,
-			networkStatus:  validNetworkStatus,
-			networkOptions: invalidNetworkOptions,
+			network:            validNetwork,
+			networkStatus:      validNetworkStatus,
+			networkOptions:     invalidNetworkOptions,
+			validationFilePath: "",
 
 			err: errors.New("no Allow.OperationStatuses found"),
 		},
 		"duplicate operation statuses": {
-			network:        validNetwork,
-			networkStatus:  validNetworkStatus,
-			networkOptions: duplicateStatuses,
+			network:            validNetwork,
+			networkStatus:      validNetworkStatus,
+			networkOptions:     duplicateStatuses,
+			validationFilePath: "",
 
 			err: errors.New("Allow.OperationStatuses contains a duplicate Success"),
 		},
 		"duplicate operation types": {
-			network:        validNetwork,
-			networkStatus:  validNetworkStatus,
-			networkOptions: duplicateTypes,
+			network:            validNetwork,
+			networkStatus:      validNetworkStatus,
+			networkOptions:     duplicateTypes,
+			validationFilePath: "",
 
 			err: errors.New("Allow.OperationTypes contains a duplicate Transfer"),
 		},
 		"invalid start index": {
-			network:        validNetwork,
-			networkStatus:  validNetworkStatus,
-			networkOptions: negativeStartIndex,
+			network:            validNetwork,
+			networkStatus:      validNetworkStatus,
+			networkOptions:     negativeStartIndex,
+			validationFilePath: "",
 
 			err: errors.New("TimestampStartIndex is invalid: -1"),
 		},
@@ -341,6 +351,7 @@ func TestNew(t *testing.T) {
 				test.network,
 				test.networkStatus,
 				test.networkOptions,
+				test.validationFilePath,
 			)
 
 			if test.err != nil {
@@ -477,4 +488,49 @@ func TestNew(t *testing.T) {
 		assert.Nil(t, asserter)
 		assert.Error(t, err)
 	})
+
+	t.Run("default no validation file", func(t *testing.T) {
+		asserter, err := NewClientWithResponses(
+			validNetwork,
+			validNetworkStatus,
+			validNetworkOptions,
+			"",
+		)
+
+		assert.NoError(t, err)
+		assert.NotNil(t, asserter)
+		assert.False(t, asserter.validations.Enabled)
+	})
+
+	t.Run("non existent validation file", func(t *testing.T) {
+		asserter, err := NewClientWithResponses(
+			validNetwork,
+			validNetworkStatus,
+			validNetworkOptions,
+			"blah",
+		)
+
+		assert.Error(t, err)
+		assert.Nil(t, asserter)
+	})
+
+	t.Run("wrong format of validation file", func(t *testing.T) {
+		tmpfile, err := ioutil.TempFile("", "test.json")
+		assert.NoError(t, err)
+		defer os.Remove(tmpfile.Name())
+
+		_, err = tmpfile.Write([]byte("blah"))
+		assert.NoError(t, err)
+		assert.NoError(t, tmpfile.Close())
+
+		asserter, err := NewClientWithResponses(
+			validNetwork,
+			validNetworkStatus,
+			validNetworkOptions,
+			tmpfile.Name(),
+		)
+
+		assert.Error(t, err)
+		assert.Nil(t, asserter)
+	})
 }
diff --git a/asserter/block_test.go b/asserter/block_test.go
index b45cb5f..d86cfd7 100644
--- a/asserter/block_test.go
+++ b/asserter/block_test.go
@@ -268,6 +268,268 @@ func TestAccountIdentifier(t *testing.T) {
 	}
 }
 
+func TestOperationsValidations(t *testing.T) {
+	var (
+		validDepositAmount = &types.Amount{
+			Value: "1000",
+			Currency: &types.Currency{
+				Symbol:   "BTC",
+				Decimals: 8,
+			},
+		}
+		validWithdrawAmount = &types.Amount{
+			Value: "-1000",
+			Currency: &types.Currency{
+				Symbol:   "BTC",
+				Decimals: 8,
+			},
+		}
+
+		validFeeAmount = &types.Amount{
+			Value: "-100",
+			Currency: &types.Currency{
+				Symbol:   "BTC",
+				Decimals: 8,
+			},
+		}
+
+		validAccount = &types.AccountIdentifier{
+			Address: "test",
+		}
+	)
+	var tests = map[string]struct {
+		operations         []*types.Operation
+		validationFilePath string
+		asserter           *Asserter
+		construction       bool
+		err                error
+	}{
+		"valid operations based on validation file": {
+			operations: []*types.Operation{
+				{
+					OperationIdentifier: &types.OperationIdentifier{
+						Index: int64(0),
+					},
+					Type:    "PAYMENT",
+					Status:  types.String("SUCCESS"),
+					Account: validAccount,
+					Amount:  validDepositAmount,
+				},
+				{
+					OperationIdentifier: &types.OperationIdentifier{
+						Index: int64(1),
+					},
+					Type:    "PAYMENT",
+					Status:  types.String("SUCCESS"),
+					Account: validAccount,
+					Amount:  validWithdrawAmount,
+				},
+				{
+					OperationIdentifier: &types.OperationIdentifier{
+						Index: int64(2),
+					},
+					Type:    "FEE",
+					Status:  types.String("SUCCESS"),
+					Account: validAccount,
+					Amount:  validFeeAmount,
+				},
+			},
+			validationFilePath: "data/validation_fee_and_payment_balanced.json",
+			construction:       false,
+			err:                nil,
+		},
+		"throw error on missing fee operation": {
+			operations: []*types.Operation{
+				{
+					OperationIdentifier: &types.OperationIdentifier{
+						Index: int64(0),
+					},
+					Type:    "PAYMENT",
+					Status:  types.String("SUCCESS"),
+					Account: validAccount,
+					Amount:  validDepositAmount,
+				},
+				{
+					OperationIdentifier: &types.OperationIdentifier{
+						Index: int64(1),
+					},
+					Type:    "PAYMENT",
+					Status:  types.String("SUCCESS"),
+					Account: validAccount,
+					Amount:  validWithdrawAmount,
+				},
+			},
+			validationFilePath: "data/validation_fee_and_payment_balanced.json",
+			construction:       false,
+			err:                ErrFeeCountMismatch,
+		},
+		"throw error on missing payment operation": {
+			operations: []*types.Operation{
+				{
+					OperationIdentifier: &types.OperationIdentifier{
+						Index: int64(0),
+					},
+					Type:    "PAYMENT",
+					Status:  types.String("SUCCESS"),
+					Account: validAccount,
+					Amount:  validDepositAmount,
+				},
+				{
+					OperationIdentifier: &types.OperationIdentifier{
+						Index: int64(1),
+					},
+					Type:    "FEE",
+					Status:  types.String("SUCCESS"),
+					Account: validAccount,
+					Amount:  validFeeAmount,
+				},
+			},
+			validationFilePath: "data/validation_fee_and_payment_balanced.json",
+			construction:       false,
+			err:                ErrPaymentCountMismatch,
+		},
+		"throw error on payment amount not balancing": {
+			operations: []*types.Operation{
+				{
+					OperationIdentifier: &types.OperationIdentifier{
+						Index: int64(0),
+					},
+					Type:    "PAYMENT",
+					Status:  types.String("SUCCESS"),
+					Account: validAccount,
+					Amount:  validDepositAmount,
+				},
+				{
+					OperationIdentifier: &types.OperationIdentifier{
+						Index: int64(1),
+					},
+					Type:    "PAYMENT",
+					Status:  types.String("SUCCESS"),
+					Account: validAccount,
+					Amount: &types.Amount{
+						Value: "-2000",
+						Currency: &types.Currency{
+							Symbol:   "BTC",
+							Decimals: 8,
+						},
+					},
+				},
+				{
+					OperationIdentifier: &types.OperationIdentifier{
+						Index: int64(2),
+					},
+					Type:    "FEE",
+					Status:  types.String("SUCCESS"),
+					Account: validAccount,
+					Amount:  validFeeAmount,
+				},
+			},
+			validationFilePath: "data/validation_fee_and_payment_balanced.json",
+			construction:       false,
+			err:                ErrPaymentAmountNotBalancing,
+		},
+		"valid operations based on validation file - unbalanced": {
+			operations: []*types.Operation{
+				{
+					OperationIdentifier: &types.OperationIdentifier{
+						Index: int64(0),
+					},
+					Type:    "PAYMENT",
+					Status:  types.String("SUCCESS"),
+					Account: validAccount,
+					Amount:  validDepositAmount,
+				},
+				{
+					OperationIdentifier: &types.OperationIdentifier{
+						Index: int64(1),
+					},
+					Type:    "PAYMENT",
+					Status:  types.String("SUCCESS"),
+					Account: validAccount,
+					Amount: &types.Amount{
+						Value: "-2000",
+						Currency: &types.Currency{
+							Symbol:   "BTC",
+							Decimals: 8,
+						},
+					},
+				},
+				{
+					OperationIdentifier: &types.OperationIdentifier{
+						Index: int64(2),
+					},
+					Type:    "FEE",
+					Status:  types.String("SUCCESS"),
+					Account: validAccount,
+					Amount:  validFeeAmount,
+				},
+			},
+			validationFilePath: "data/validation_fee_and_payment_unbalanced.json",
+			construction:       false,
+			err:                nil,
+		},
+	}
+
+	for name, test := range tests {
+		asserter, err := NewClientWithResponses(
+			&types.NetworkIdentifier{
+				Blockchain: "hello",
+				Network:    "world",
+			},
+			&types.NetworkStatusResponse{
+				GenesisBlockIdentifier: &types.BlockIdentifier{
+					Index: 0,
+					Hash:  "block 0",
+				},
+				CurrentBlockIdentifier: &types.BlockIdentifier{
+					Index: 100,
+					Hash:  "block 100",
+				},
+				CurrentBlockTimestamp: MinUnixEpoch + 1,
+				Peers: []*types.Peer{
+					{
+						PeerID: "peer 1",
+					},
+				},
+			},
+			&types.NetworkOptionsResponse{
+				Version: &types.Version{
+					RosettaVersion: "1.4.0",
+					NodeVersion:    "1.0",
+				},
+				Allow: &types.Allow{
+					OperationStatuses: []*types.OperationStatus{
+						{
+							Status:     "SUCCESS",
+							Successful: true,
+						},
+						{
+							Status:     "FAILURE",
+							Successful: false,
+						},
+					},
+					OperationTypes: []string{
+						"PAYMENT",
+						"FEE",
+					},
+				},
+			},
+			test.validationFilePath,
+		)
+		assert.NotNil(t, asserter)
+		assert.NoError(t, err)
+
+		t.Run(name, func(t *testing.T) {
+			actualErr := asserter.Operations(test.operations, test.construction)
+			if test.err != nil {
+				assert.Contains(t, actualErr.Error(), test.err.Error())
+			} else {
+				assert.NoError(t, actualErr)
+			}
+		})
+	}
+}
+
 func TestOperation(t *testing.T) {
 	var (
 		validAmount = &types.Amount{
@@ -480,6 +742,7 @@ func TestOperation(t *testing.T) {
 					},
 				},
 			},
+			"",
 		)
 		assert.NotNil(t, asserter)
 		assert.NoError(t, err)
@@ -1017,6 +1280,7 @@ func TestBlock(t *testing.T) {
 						TimestampStartIndex: test.startIndex,
 					},
 				},
+				"",
 			)
 			assert.NotNil(t, asserter)
 			assert.NoError(t, err)
diff --git a/asserter/construction_test.go b/asserter/construction_test.go
index 11e1611..2196eb8 100644
--- a/asserter/construction_test.go
+++ b/asserter/construction_test.go
@@ -496,6 +496,7 @@ func TestConstructionParseResponse(t *testing.T) {
 				},
 			},
 		},
+		"",
 	)
 	assert.NotNil(t, asserter)
 	assert.NoError(t, err)
diff --git a/asserter/error_test.go b/asserter/error_test.go
index 9379c89..3ea8be9 100644
--- a/asserter/error_test.go
+++ b/asserter/error_test.go
@@ -150,6 +150,7 @@ func TestErrorMap(t *testing.T) {
 						},
 					},
 				},
+				"",
 			)
 			assert.NotNil(t, asserter)
 			assert.NoError(t, err)
diff --git a/asserter/search_test.go b/asserter/search_test.go
index a7bf3d8..f03b6f7 100644
--- a/asserter/search_test.go
+++ b/asserter/search_test.go
@@ -163,6 +163,7 @@ func TestSearchTransactionsResponse(t *testing.T) {
 						},
 					},
 				},
+				"",
 			)
 			assert.NotNil(t, asserter)
 			assert.NoError(t, err)
diff --git a/asserter/server_test.go b/asserter/server_test.go
index 978aa3a..bd13662 100644
--- a/asserter/server_test.go
+++ b/asserter/server_test.go
@@ -202,6 +202,7 @@ var (
 		[]*types.NetworkIdentifier{validNetworkIdentifier},
 		[]string{"eth_call"},
 		false,
+		"",
 	)
 )
 
@@ -275,6 +276,7 @@ func TestNewWithOptions(t *testing.T) {
 				test.supportedNetworks,
 				test.callMethods,
 				false,
+				"",
 			)
 			if test.err == nil {
 				assert.NotNil(t, thisA)
@@ -435,6 +437,7 @@ func TestAccountBalanceRequest(t *testing.T) {
 				[]*types.NetworkIdentifier{validNetworkIdentifier},
 				nil,
 				false,
+				"",
 			)
 			assert.NotNil(t, asserter)
 			assert.NoError(t, err)
@@ -1452,6 +1455,7 @@ func TestAccountCoinsRequest(t *testing.T) {
 				[]*types.NetworkIdentifier{validNetworkIdentifier},
 				nil,
 				test.allowMempool,
+				"",
 			)
 			assert.NotNil(t, asserter)
 			assert.NoError(t, err)
diff --git a/constructor/coordinator/coordinator_test.go b/constructor/coordinator/coordinator_test.go
index da767eb..c29e312 100644
--- a/constructor/coordinator/coordinator_test.go
+++ b/constructor/coordinator/coordinator_test.go
@@ -62,6 +62,9 @@ func simpleAsserterConfiguration() (*asserter.Asserter, error) {
 		},
 		[]*types.Error{},
 		nil,
+		&asserter.Validations{
+			Enabled: false,
+		},
 	)
 }
 
diff --git a/fetcher/block_test.go b/fetcher/block_test.go
index 2adb8cd..b4800b3 100644
--- a/fetcher/block_test.go
+++ b/fetcher/block_test.go
@@ -239,6 +239,9 @@ func TestBlockRetry(t *testing.T) {
 				basicNetworkOptions.Allow.OperationStatuses,
 				nil,
 				nil,
+				&asserter.Validations{
+					Enabled: false,
+				},
 			)
 			assert.NoError(err)
 
diff --git a/fetcher/fetcher_test.go b/fetcher/fetcher_test.go
index fbf1fec..f831914 100644
--- a/fetcher/fetcher_test.go
+++ b/fetcher/fetcher_test.go
@@ -189,7 +189,7 @@ func TestInitializeAsserter(t *testing.T) {
 				WithRetryElapsedTime(5*time.Second),
 			)
 
-			networkIdentifier, networkStatus, err := f.InitializeAsserter(ctx, test.network)
+			networkIdentifier, networkStatus, err := f.InitializeAsserter(ctx, test.network, "")
 			assert.Equal(test.expectedNetwork, networkIdentifier)
 			assert.Equal(test.expectedStatus, networkStatus)
 			assert.True(checkError(err, test.expectedError))
diff --git a/fetcher/search_test.go b/fetcher/search_test.go
index 7d737ba..1d06a22 100644
--- a/fetcher/search_test.go
+++ b/fetcher/search_test.go
@@ -157,6 +157,9 @@ func TestSearchTransactionsRetry(t *testing.T) {
 				basicNetworkOptions.Allow.OperationStatuses,
 				nil,
 				nil,
+				&asserter.Validations{
+					Enabled: false,
+				},
 			)
 			assert.NoError(aerr)
 
diff --git a/parser/balance_changes_test.go b/parser/balance_changes_test.go
index 8f50c00..3087aa8 100644
--- a/parser/balance_changes_test.go
+++ b/parser/balance_changes_test.go
@@ -324,5 +324,8 @@ func simpleAsserterConfiguration(
 		allowedStatus,
 		[]*types.Error{},
 		nil,
+		&asserter.Validations{
+			Enabled: false,
+		},
 	)
 }
diff --git a/storage/modules/balance_storage_test.go b/storage/modules/balance_storage_test.go
index c121752..8b3a16a 100644
--- a/storage/modules/balance_storage_test.go
+++ b/storage/modules/balance_storage_test.go
@@ -54,6 +54,9 @@ func baseAsserter() *asserter.Asserter {
 		},
 		[]*types.Error{},
 		nil,
+		&asserter.Validations{
+			Enabled: false,
+		},
 	)
 	return a
 }
diff --git a/storage/modules/coin_storage_test.go b/storage/modules/coin_storage_test.go
index 86b25f2..3773be7 100644
--- a/storage/modules/coin_storage_test.go
+++ b/storage/modules/coin_storage_test.go
@@ -421,6 +421,9 @@ func TestCoinStorage(t *testing.T) {
 		},
 		[]*types.Error{},
 		nil,
+		&asserter.Validations{
+			Enabled: false,
+		},
 	)
 	assert.NoError(t, err)
 	assert.NotNil(t, a)