Repository: coinbase/rosetta-sdk-go
Pull request #198 (2020-10-20 01:02:40)

[storage] Automated Computed Balance Updates
This PR extends `BalanceStorage` to update computed balances with an updated live balance if an account is subject to a `*types.BalanceExemption`.

### Changes
- [x] Add functionality to `parser` to match `*types.AccountIdentifier` and `*types.Currency` with `[]*types.BalanceExemption`
- [x] Modify `reconciler` to use this parser implementation
- [x] Automatically update computed balance of any account based on applicable balance exemptions (this could slow down syncing considerably)
  - [x] Check that modification to computed balance is permitted by the matching `*types.BalanceExemption`


Patch:
diff --git a/parser/exemptions.go b/parser/exemptions.go
new file mode 100644
index 0000000..645da2d
--- /dev/null
+++ b/parser/exemptions.go
@@ -0,0 +1,68 @@
+// 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 (
+	"math/big"
+
+	"github.com/coinbase/rosetta-sdk-go/types"
+)
+
+// FindExemptions returns all matching *types.BalanceExemption
+// for a particular *types.AccountIdentifier and *types.Currency.
+func (p *Parser) FindExemptions(
+	account *types.AccountIdentifier,
+	currency *types.Currency,
+) []*types.BalanceExemption {
+	matches := []*types.BalanceExemption{}
+	for _, exemption := range p.BalanceExemptions {
+		if exemption.Currency != nil && types.Hash(currency) != types.Hash(exemption.Currency) {
+			continue
+		}
+
+		if exemption.SubAccountAddress != nil &&
+			(account.SubAccount == nil || *exemption.SubAccountAddress != account.SubAccount.Address) {
+			continue
+		}
+
+		matches = append(matches, exemption)
+	}
+
+	return matches
+}
+
+// MatchBalanceExemption returns a *types.BalanceExemption
+// associated with the *types.AccountIdentifier, *types.Currency,
+// and difference, if it exists. The provided exemptions
+// should be produced using FindExemptions.
+func MatchBalanceExemption(
+	matchedExemptions []*types.BalanceExemption,
+	difference string, // live - computed
+) *types.BalanceExemption {
+	bigDifference, ok := new(big.Int).SetString(difference, 10)
+	if !ok {
+		return nil
+	}
+
+	for _, exemption := range matchedExemptions {
+		if exemption.ExemptionType == types.BalanceDynamic ||
+			(exemption.ExemptionType == types.BalanceGreaterOrEqual && bigDifference.Sign() >= 0) ||
+			(exemption.ExemptionType == types.BalanceLessOrEqual && bigDifference.Sign() <= 0) {
+			return exemption
+		}
+	}
+
+	return nil
+}
diff --git a/parser/parser.go b/parser/parser.go
index be2df6a..ce1b94f 100644
--- a/parser/parser.go
+++ b/parser/parser.go
@@ -16,18 +16,25 @@ package parser
 
 import (
 	"github.com/coinbase/rosetta-sdk-go/asserter"
+	"github.com/coinbase/rosetta-sdk-go/types"
 )
 
 // Parser provides support for parsing Rosetta blocks.
 type Parser struct {
-	Asserter   *asserter.Asserter
-	ExemptFunc ExemptOperation
+	Asserter          *asserter.Asserter
+	ExemptFunc        ExemptOperation
+	BalanceExemptions []*types.BalanceExemption
 }
 
 // New creates a new Parser.
-func New(asserter *asserter.Asserter, exemptFunc ExemptOperation) *Parser {
+func New(
+	asserter *asserter.Asserter,
+	exemptFunc ExemptOperation,
+	balanceExemptions []*types.BalanceExemption,
+) *Parser {
 	return &Parser{
-		Asserter:   asserter,
-		ExemptFunc: exemptFunc,
+		Asserter:          asserter,
+		ExemptFunc:        exemptFunc,
+		BalanceExemptions: balanceExemptions,
 	}
 }
diff --git a/reconciler/configuration.go b/reconciler/configuration.go
index 31d66f4..a28f163 100644
--- a/reconciler/configuration.go
+++ b/reconciler/configuration.go
@@ -93,11 +93,3 @@ func WithDebugLogging(debug bool) Option {
 		r.debugLogging = debug
 	}
 }
-
-// WithBalanceExemptions is which reconciliation errors to
-// skip.
-func WithBalanceExemptions(exemptions []*types.BalanceExemption) Option {
-	return func(r *Reconciler) {
-		r.exemptions = exemptions
-	}
-}
diff --git a/reconciler/reconciler.go b/reconciler/reconciler.go
index 3c918aa..7b7c304 100644
--- a/reconciler/reconciler.go
+++ b/reconciler/reconciler.go
@@ -19,7 +19,6 @@ import (
 	"errors"
 	"fmt"
 	"log"
-	"math/big"
 	"sync"
 	"time"
 
@@ -166,8 +165,10 @@ type AccountCurrency struct {
 // types.AccountIdentifiers returned in types.Operations
 // by a Rosetta Server.
 type Reconciler struct {
-	helper               Helper
-	handler              Handler
+	helper  Helper
+	handler Handler
+	parser  *parser.Parser
+
 	lookupBalanceByBlock bool
 	interestingAccounts  []*AccountCurrency
 	changeQueue          chan *parser.BalanceChange
@@ -203,10 +204,6 @@ type Reconciler struct {
 	// of a channel to determine when it is ready to look at.
 	inactiveQueueMutex sync.Mutex
 
-	// exemptions allows for certain reconciliation failures
-	// to be skipped.
-	exemptions []*types.BalanceExemption
-
 	// LastIndexChecked is the last block index reconciled actively.
 	lastIndexMutex   sync.Mutex
 	lastIndexChecked int64
@@ -216,11 +213,13 @@ type Reconciler struct {
 func New(
 	helper Helper,
 	handler Handler,
+	p *parser.Parser,
 	options ...Option,
 ) *Reconciler {
 	r := &Reconciler{
 		helper:               helper,
 		handler:              handler,
+		parser:               p,
 		inactiveFrequency:    defaultInactiveFrequency,
 		activeConcurrency:    defaultReconcilerConcurrency,
 		inactiveConcurrency:  defaultReconcilerConcurrency,
@@ -489,39 +488,23 @@ func (r *Reconciler) handleBalanceMismatch(
 	block *types.BlockIdentifier,
 ) error {
 	// Check if the reconciliation was exempt (supports compound exemptions)
-	for _, exemption := range r.exemptions {
-		if exemption.Currency != nil && types.Hash(currency) != types.Hash(exemption.Currency) {
-			continue
-		}
-
-		if exemption.SubAccountAddress != nil &&
-			(account.SubAccount == nil || *exemption.SubAccountAddress != account.SubAccount.Address) {
-			continue
-		}
-
-		// This should never error because we check for validity
-		// before calling this method.
-		bigDifference, ok := new(big.Int).SetString(difference, 10)
-		if !ok {
-			return fmt.Errorf("could not convert difference %s to integer", difference)
-		}
-
-		if exemption.ExemptionType == types.BalanceDynamic ||
-			(exemption.ExemptionType == types.BalanceGreaterOrEqual && bigDifference.Sign() >= 0) ||
-			(exemption.ExemptionType == types.BalanceLessOrEqual && bigDifference.Sign() <= 0) {
-			// Return handler result (regardless if error) so that we don't invoke the handler for
-			// a failed reconciliation as well.
-			return r.handler.ReconciliationExempt(
-				ctx,
-				reconciliationType,
-				account,
-				currency,
-				computedBalance,
-				liveBalance,
-				block,
-				exemption,
-			)
-		}
+	exemption := parser.MatchBalanceExemption(
+		r.parser.FindExemptions(account, currency),
+		difference,
+	)
+	if exemption != nil {
+		// Return handler result (regardless if error) so that we don't invoke the handler for
+		// a failed reconciliation as well.
+		return r.handler.ReconciliationExempt(
+			ctx,
+			reconciliationType,
+			account,
+			currency,
+			computedBalance,
+			liveBalance,
+			block,
+			exemption,
+		)
 	}
 
 	// If we didn't find a matching exemption,
diff --git a/storage/balance_storage.go b/storage/balance_storage.go
index 3518c89..dfdfde1 100644
--- a/storage/balance_storage.go
+++ b/storage/balance_storage.go
@@ -64,6 +64,7 @@ type BalanceStorageHelper interface {
 	) (*types.Amount, error)
 
 	ExemptFunc() parser.ExemptOperation
+	BalanceExemptions() []*types.BalanceExemption
 	Asserter() *asserter.Asserter
 }
 
@@ -88,10 +89,17 @@ func NewBalanceStorage(
 
 // Initialize adds a BalanceStorageHelper and BalanceStorageHandler to BalanceStorage.
 // This must be called prior to syncing!
-func (b *BalanceStorage) Initialize(helper BalanceStorageHelper, handler BalanceStorageHandler) {
+func (b *BalanceStorage) Initialize(
+	helper BalanceStorageHelper,
+	handler BalanceStorageHandler,
+) {
 	b.helper = helper
 	b.handler = handler
-	b.parser = parser.New(helper.Asserter(), helper.ExemptFunc())
+	b.parser = parser.New(
+		helper.Asserter(),
+		helper.ExemptFunc(),
+		helper.BalanceExemptions(),
+	)
 }
 
 // AddingBlock is called by BlockStorage when adding a block to storage.
@@ -262,6 +270,81 @@ func (b *BalanceStorage) ReconciliationCoverage(
 	return float64(validCoverage) / float64(seen), nil
 }
 
+// existingValue finds the existing value for
+// a given *types.AccountIdentifier and *types.Currency.
+func (b *BalanceStorage) existingValue(
+	ctx context.Context,
+	change *parser.BalanceChange,
+	parentBlock *types.BlockIdentifier,
+	namespace string,
+	balance []byte,
+	exists bool,
+	exemptions []*types.BalanceExemption,
+) (string, error) {
+	if parentBlock != nil && change.Block.Hash == parentBlock.Hash {
+		// Don't attempt to use the helper if we are going to query the same
+		// block we are processing (causes the duplicate issue).
+		return "0", nil
+	}
+
+	var existingValue string
+	if exists {
+		var bal balanceEntry
+		err := b.db.Encoder().Decode(namespace, balance, &bal, true)
+		if err != nil {
+			return "", err
+		}
+
+		existingValue = bal.Amount.Value
+		if len(exemptions) == 0 {
+			return existingValue, nil
+		}
+	}
+
+	// Use helper to fetch existing balance.
+	amount, err := b.helper.AccountBalance(ctx, change.Account, change.Currency, parentBlock)
+	if err != nil {
+		return "", fmt.Errorf(
+			"%w: unable to get previous account balance for %s %s at %s",
+			err,
+			types.PrintStruct(change.Account),
+			types.PrintStruct(change.Currency),
+			types.PrintStruct(parentBlock),
+		)
+	}
+
+	// Nothing to compare, so should return.
+	if len(existingValue) == 0 {
+		return amount.Value, nil
+	}
+
+	// Determine if new live balance complies
+	// with any balance exemption.
+	difference, err := types.SubtractValues(amount.Value, existingValue)
+	if err != nil {
+		return "", fmt.Errorf(
+			"%w: unable to calculate difference between live and computed balances",
+			err,
+		)
+	}
+
+	exemption := parser.MatchBalanceExemption(
+		exemptions,
+		difference,
+	)
+	if exemption == nil {
+		return "", fmt.Errorf(
+			"%w: account %s balance difference (live - computed) %s at %s is not allowed by any balance exemption",
+			ErrInvalidLiveBalance,
+			types.PrintStruct(change.Account),
+			difference,
+			types.PrintStruct(parentBlock),
+		)
+	}
+
+	return amount.Value, nil
+}
+
 // UpdateBalance updates a types.AccountIdentifer
 // by a types.Amount and sets the account's most
 // recent accessed block.
@@ -275,43 +358,30 @@ func (b *BalanceStorage) UpdateBalance(
 		return errors.New("invalid currency")
 	}
 
-	namespace, key := GetBalanceKey(change.Account, change.Currency)
 	// Get existing balance on key
+	namespace, key := GetBalanceKey(change.Account, change.Currency)
 	exists, balance, err := dbTransaction.Get(ctx, key)
 	if err != nil {
 		return err
 	}
 
-	var existingValue string
-	switch {
-	case exists:
-		// This could happen if balances are bootstrapped and should not be
-		// overridden.
-		var bal balanceEntry
-		err := b.db.Encoder().Decode(namespace, balance, &bal, true)
-		if err != nil {
-			return err
-		}
+	// Find exemptions that are applicable to the *parser.BalanceChange
+	exemptions := b.parser.FindExemptions(change.Account, change.Currency)
 
-		existingValue = bal.Amount.Value
-	case parentBlock != nil && change.Block.Hash == parentBlock.Hash:
-		// Don't attempt to use the helper if we are going to query the same
-		// block we are processing (causes the duplicate issue).
-		existingValue = "0"
-	default:
-		// Use helper to fetch existing balance.
-		amount, err := b.helper.AccountBalance(ctx, change.Account, change.Currency, parentBlock)
-		if err != nil {
-			return fmt.Errorf(
-				"%w: unable to get previous account balance for %s %s at %s",
-				err,
-				types.PrintStruct(change.Account),
-				types.PrintStruct(change.Currency),
-				types.PrintStruct(parentBlock),
-			)
-		}
-
-		existingValue = amount.Value
+	// Find account existing value whether the account is new, has an
+	// existing balance, or is subject to additional accounting from
+	// a balance exemption.
+	existingValue, err := b.existingValue(
+		ctx,
+		change,
+		parentBlock,
+		namespace,
+		balance,
+		exists,
+		exemptions,
+	)
+	if err != nil {
+		return err
 	}
 
 	newVal, err := types.AddValues(change.Difference, existingValue)
diff --git a/storage/errors.go b/storage/errors.go
index 03fa139..6c82309 100644
--- a/storage/errors.go
+++ b/storage/errors.go
@@ -315,8 +315,14 @@ var (
 	// balance goes negative as the result of an operation.
 	ErrNegativeBalance = errors.New("negative balance")
 
+	// ErrInvalidLiveBalance is returned when an account's
+	// live balance varies in a way that is inconsistent
+	// with any balance exemption.
+	ErrInvalidLiveBalance = errors.New("invalid live balance")
+
 	BalanceStorageErrs = []error{
 		ErrNegativeBalance,
+		ErrInvalidLiveBalance,
 	}
 
 	///////////////////


Test patch:
diff --git a/constructor/coordinator/coordinator_test.go b/constructor/coordinator/coordinator_test.go
index 34aa329..369afc8 100644
--- a/constructor/coordinator/coordinator_test.go
+++ b/constructor/coordinator/coordinator_test.go
@@ -69,7 +69,7 @@ func defaultParser(t *testing.T) *parser.Parser {
 	asserter, err := simpleAsserterConfiguration()
 	assert.NoError(t, err)
 
-	return parser.New(asserter, nil)
+	return parser.New(asserter, nil, nil)
 }
 
 func TestProcess(t *testing.T) {
diff --git a/parser/balance_changes_test.go b/parser/balance_changes_test.go
index 53c2897..a4e2f3e 100644
--- a/parser/balance_changes_test.go
+++ b/parser/balance_changes_test.go
@@ -264,6 +264,7 @@ func TestBalanceChanges(t *testing.T) {
 			parser := New(
 				asserter,
 				test.exemptFunc,
+				nil,
 			)
 
 			changes, err := parser.BalanceChanges(
diff --git a/parser/exemptions_test.go b/parser/exemptions_test.go
new file mode 100644
index 0000000..11d09d6
--- /dev/null
+++ b/parser/exemptions_test.go
@@ -0,0 +1,464 @@
+// 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 stringPointer(s string) *string {
+	return &s
+}
+
+func TestFindExemptions(t *testing.T) {
+	tests := map[string]struct {
+		balanceExemptions []*types.BalanceExemption
+		account           *types.AccountIdentifier
+		currency          *types.Currency
+		expected          []*types.BalanceExemption
+	}{
+		"no exemptions": {
+			account: &types.AccountIdentifier{
+				Address: "test",
+			},
+			currency: &types.Currency{
+				Symbol:   "BTC",
+				Decimals: 8,
+			},
+			expected: []*types.BalanceExemption{},
+		},
+		"no matching exemption": {
+			balanceExemptions: []*types.BalanceExemption{
+				{
+					ExemptionType: types.BalanceDynamic,
+					Currency: &types.Currency{
+						Symbol:   "BTC",
+						Decimals: 7,
+					},
+				},
+			},
+			account: &types.AccountIdentifier{
+				Address: "test",
+			},
+			currency: &types.Currency{
+				Symbol:   "BTC",
+				Decimals: 8,
+			},
+			expected: []*types.BalanceExemption{},
+		},
+		"no matching exemptions": {
+			balanceExemptions: []*types.BalanceExemption{
+				{
+					ExemptionType: types.BalanceDynamic,
+					Currency: &types.Currency{
+						Symbol:   "BTC",
+						Decimals: 7,
+					},
+				},
+				{
+					ExemptionType:     types.BalanceDynamic,
+					SubAccountAddress: stringPointer("hello"),
+				},
+			},
+			account: &types.AccountIdentifier{
+				Address: "test",
+				SubAccount: &types.SubAccountIdentifier{
+					Address: "blah",
+				},
+			},
+			currency: &types.Currency{
+				Symbol:   "BTC",
+				Decimals: 8,
+			},
+			expected: []*types.BalanceExemption{},
+		},
+		"currency match": {
+			balanceExemptions: []*types.BalanceExemption{
+				{
+					ExemptionType: types.BalanceDynamic,
+					Currency: &types.Currency{
+						Symbol:   "BTC",
+						Decimals: 8,
+					},
+				},
+				{
+					ExemptionType:     types.BalanceDynamic,
+					SubAccountAddress: stringPointer("hello"),
+				},
+			},
+			account: &types.AccountIdentifier{
+				Address: "test",
+				SubAccount: &types.SubAccountIdentifier{
+					Address: "blah",
+				},
+			},
+			currency: &types.Currency{
+				Symbol:   "BTC",
+				Decimals: 8,
+			},
+			expected: []*types.BalanceExemption{
+				{
+					ExemptionType: types.BalanceDynamic,
+					Currency: &types.Currency{
+						Symbol:   "BTC",
+						Decimals: 8,
+					},
+				},
+			},
+		},
+		"subaccount match": {
+			balanceExemptions: []*types.BalanceExemption{
+				{
+					ExemptionType: types.BalanceDynamic,
+					Currency: &types.Currency{
+						Symbol:   "BTC",
+						Decimals: 7,
+					},
+				},
+				{
+					ExemptionType:     types.BalanceDynamic,
+					SubAccountAddress: stringPointer("hello"),
+				},
+			},
+			account: &types.AccountIdentifier{
+				Address: "test",
+				SubAccount: &types.SubAccountIdentifier{
+					Address: "hello",
+				},
+			},
+			currency: &types.Currency{
+				Symbol:   "BTC",
+				Decimals: 8,
+			},
+			expected: []*types.BalanceExemption{
+				{
+					ExemptionType:     types.BalanceDynamic,
+					SubAccountAddress: stringPointer("hello"),
+				},
+			},
+		},
+		"multiple match": {
+			balanceExemptions: []*types.BalanceExemption{
+				{
+					ExemptionType: types.BalanceDynamic,
+					Currency: &types.Currency{
+						Symbol:   "BTC",
+						Decimals: 8,
+					},
+				},
+				{
+					ExemptionType:     types.BalanceDynamic,
+					SubAccountAddress: stringPointer("hello"),
+				},
+			},
+			account: &types.AccountIdentifier{
+				Address: "test",
+				SubAccount: &types.SubAccountIdentifier{
+					Address: "hello",
+				},
+			},
+			currency: &types.Currency{
+				Symbol:   "BTC",
+				Decimals: 8,
+			},
+			expected: []*types.BalanceExemption{
+				{
+					ExemptionType: types.BalanceDynamic,
+					Currency: &types.Currency{
+						Symbol:   "BTC",
+						Decimals: 8,
+					},
+				},
+				{
+					ExemptionType:     types.BalanceDynamic,
+					SubAccountAddress: stringPointer("hello"),
+				},
+			},
+		},
+	}
+
+	for name, test := range tests {
+		t.Run(name, func(t *testing.T) {
+			parser := New(nil, nil, test.balanceExemptions)
+			assert.Equal(t, test.expected, parser.FindExemptions(test.account, test.currency))
+		})
+	}
+}
+
+func TestMatchBalanceExemption(t *testing.T) {
+	tests := map[string]struct {
+		balanceExemptions []*types.BalanceExemption
+		account           *types.AccountIdentifier
+		currency          *types.Currency
+		difference        string
+		expected          *types.BalanceExemption
+	}{
+		"no exemptions": {
+			account: &types.AccountIdentifier{
+				Address: "test",
+			},
+			currency: &types.Currency{
+				Symbol:   "BTC",
+				Decimals: 8,
+			},
+			difference: "100",
+		},
+		"no matching exemption": {
+			balanceExemptions: []*types.BalanceExemption{
+				{
+					ExemptionType: types.BalanceDynamic,
+					Currency: &types.Currency{
+						Symbol:   "BTC",
+						Decimals: 7,
+					},
+				},
+			},
+			account: &types.AccountIdentifier{
+				Address: "test",
+			},
+			currency: &types.Currency{
+				Symbol:   "BTC",
+				Decimals: 8,
+			},
+			difference: "100",
+		},
+		"no matching exemptions": {
+			balanceExemptions: []*types.BalanceExemption{
+				{
+					ExemptionType: types.BalanceDynamic,
+					Currency: &types.Currency{
+						Symbol:   "BTC",
+						Decimals: 7,
+					},
+				},
+				{
+					ExemptionType:     types.BalanceDynamic,
+					SubAccountAddress: stringPointer("hello"),
+				},
+			},
+			account: &types.AccountIdentifier{
+				Address: "test",
+				SubAccount: &types.SubAccountIdentifier{
+					Address: "blah",
+				},
+			},
+			currency: &types.Currency{
+				Symbol:   "BTC",
+				Decimals: 8,
+			},
+			difference: "100",
+		},
+		"currency match": {
+			balanceExemptions: []*types.BalanceExemption{
+				{
+					ExemptionType: types.BalanceDynamic,
+					Currency: &types.Currency{
+						Symbol:   "BTC",
+						Decimals: 8,
+					},
+				},
+				{
+					ExemptionType:     types.BalanceDynamic,
+					SubAccountAddress: stringPointer("hello"),
+				},
+			},
+			account: &types.AccountIdentifier{
+				Address: "test",
+				SubAccount: &types.SubAccountIdentifier{
+					Address: "blah",
+				},
+			},
+			currency: &types.Currency{
+				Symbol:   "BTC",
+				Decimals: 8,
+			},
+			difference: "100",
+			expected: &types.BalanceExemption{
+				ExemptionType: types.BalanceDynamic,
+				Currency: &types.Currency{
+					Symbol:   "BTC",
+					Decimals: 8,
+				},
+			},
+		},
+		"currency match, wrong sign": {
+			balanceExemptions: []*types.BalanceExemption{
+				{
+					ExemptionType: types.BalanceLessOrEqual,
+					Currency: &types.Currency{
+						Symbol:   "BTC",
+						Decimals: 8,
+					},
+				},
+				{
+					ExemptionType:     types.BalanceDynamic,
+					SubAccountAddress: stringPointer("hello"),
+				},
+			},
+			account: &types.AccountIdentifier{
+				Address: "test",
+				SubAccount: &types.SubAccountIdentifier{
+					Address: "blah",
+				},
+			},
+			currency: &types.Currency{
+				Symbol:   "BTC",
+				Decimals: 8,
+			},
+			difference: "100",
+		},
+		"currency match, right sign": {
+			balanceExemptions: []*types.BalanceExemption{
+				{
+					ExemptionType: types.BalanceGreaterOrEqual,
+					Currency: &types.Currency{
+						Symbol:   "BTC",
+						Decimals: 8,
+					},
+				},
+				{
+					ExemptionType:     types.BalanceDynamic,
+					SubAccountAddress: stringPointer("hello"),
+				},
+			},
+			account: &types.AccountIdentifier{
+				Address: "test",
+				SubAccount: &types.SubAccountIdentifier{
+					Address: "blah",
+				},
+			},
+			currency: &types.Currency{
+				Symbol:   "BTC",
+				Decimals: 8,
+			},
+			difference: "100",
+			expected: &types.BalanceExemption{
+				ExemptionType: types.BalanceGreaterOrEqual,
+				Currency: &types.Currency{
+					Symbol:   "BTC",
+					Decimals: 8,
+				},
+			},
+		},
+		"currency match, zero": {
+			balanceExemptions: []*types.BalanceExemption{
+				{
+					ExemptionType: types.BalanceGreaterOrEqual,
+					Currency: &types.Currency{
+						Symbol:   "BTC",
+						Decimals: 8,
+					},
+				},
+				{
+					ExemptionType:     types.BalanceDynamic,
+					SubAccountAddress: stringPointer("hello"),
+				},
+			},
+			account: &types.AccountIdentifier{
+				Address: "test",
+				SubAccount: &types.SubAccountIdentifier{
+					Address: "blah",
+				},
+			},
+			currency: &types.Currency{
+				Symbol:   "BTC",
+				Decimals: 8,
+			},
+			difference: "0",
+			expected: &types.BalanceExemption{
+				ExemptionType: types.BalanceGreaterOrEqual,
+				Currency: &types.Currency{
+					Symbol:   "BTC",
+					Decimals: 8,
+				},
+			},
+		},
+		"subaccount match": {
+			balanceExemptions: []*types.BalanceExemption{
+				{
+					ExemptionType: types.BalanceDynamic,
+					Currency: &types.Currency{
+						Symbol:   "BTC",
+						Decimals: 7,
+					},
+				},
+				{
+					ExemptionType:     types.BalanceDynamic,
+					SubAccountAddress: stringPointer("hello"),
+				},
+			},
+			account: &types.AccountIdentifier{
+				Address: "test",
+				SubAccount: &types.SubAccountIdentifier{
+					Address: "hello",
+				},
+			},
+			currency: &types.Currency{
+				Symbol:   "BTC",
+				Decimals: 8,
+			},
+			difference: "100",
+			expected: &types.BalanceExemption{
+				ExemptionType:     types.BalanceDynamic,
+				SubAccountAddress: stringPointer("hello"),
+			},
+		},
+		"multiple match": {
+			balanceExemptions: []*types.BalanceExemption{
+				{
+					ExemptionType: types.BalanceDynamic,
+					Currency: &types.Currency{
+						Symbol:   "BTC",
+						Decimals: 8,
+					},
+				},
+				{
+					ExemptionType:     types.BalanceDynamic,
+					SubAccountAddress: stringPointer("hello"),
+				},
+			},
+			account: &types.AccountIdentifier{
+				Address: "test",
+				SubAccount: &types.SubAccountIdentifier{
+					Address: "hello",
+				},
+			},
+			currency: &types.Currency{
+				Symbol:   "BTC",
+				Decimals: 8,
+			},
+			difference: "100",
+			expected: &types.BalanceExemption{
+				ExemptionType: types.BalanceDynamic,
+				Currency: &types.Currency{
+					Symbol:   "BTC",
+					Decimals: 8,
+				},
+			},
+		},
+	}
+
+	for name, test := range tests {
+		t.Run(name, func(t *testing.T) {
+			parser := New(nil, nil, test.balanceExemptions)
+			exemptions := parser.FindExemptions(test.account, test.currency)
+			assert.Equal(t, test.expected, MatchBalanceExemption(exemptions, test.difference))
+		})
+	}
+}
diff --git a/parser/intent_test.go b/parser/intent_test.go
index 6829350..b10b5dc 100644
--- a/parser/intent_test.go
+++ b/parser/intent_test.go
@@ -543,7 +543,7 @@ func TestExpectedOperations(t *testing.T) {
 		assert.NoError(t, err)
 		assert.NotNil(t, asserter)
 
-		parser := New(asserter, nil)
+		parser := New(asserter, nil, nil)
 
 		t.Run(name, func(t *testing.T) {
 			err := parser.ExpectedOperations(
diff --git a/reconciler/reconciler_test.go b/reconciler/reconciler_test.go
index 2a01919..a3efde2 100644
--- a/reconciler/reconciler_test.go
+++ b/reconciler/reconciler_test.go
@@ -47,7 +47,7 @@ func TestNewReconciler(t *testing.T) {
 		expected *Reconciler
 	}{
 		"no options": {
-			expected: New(nil, nil),
+			expected: New(nil, nil, nil),
 		},
 		"with reconciler concurrency": {
 			options: []Option{
@@ -55,7 +55,7 @@ func TestNewReconciler(t *testing.T) {
 				WithActiveConcurrency(200),
 			},
 			expected: func() *Reconciler {
-				r := New(nil, nil)
+				r := New(nil, nil, nil)
 				r.inactiveConcurrency = 100
 				r.activeConcurrency = 200
 
@@ -69,7 +69,7 @@ func TestNewReconciler(t *testing.T) {
 				}),
 			},
 			expected: func() *Reconciler {
-				r := New(nil, nil)
+				r := New(nil, nil, nil)
 				r.interestingAccounts = []*AccountCurrency{
 					accountCurrency,
 				}
@@ -84,7 +84,7 @@ func TestNewReconciler(t *testing.T) {
 				}),
 			},
 			expected: func() *Reconciler {
-				r := New(nil, nil)
+				r := New(nil, nil, nil)
 				r.inactiveQueue = []*InactiveEntry{
 					{
 						Entry: accountCurrency,
@@ -97,26 +97,14 @@ func TestNewReconciler(t *testing.T) {
 				return r
 			}(),
 		},
-		"with lookupBalanceByBlock and exemptions": {
+		"with lookupBalanceByBlock": {
 			options: []Option{
 				WithLookupBalanceByBlock(false),
-				WithBalanceExemptions([]*types.BalanceExemption{
-					{
-						ExemptionType: types.BalanceDynamic,
-						Currency:      accountCurrency.Currency,
-					},
-				}),
 			},
 			expected: func() *Reconciler {
-				r := New(nil, nil)
+				r := New(nil, nil, nil)
 				r.lookupBalanceByBlock = false
 				r.changeQueue = make(chan *parser.BalanceChange, backlogThreshold)
-				r.exemptions = []*types.BalanceExemption{
-					{
-						ExemptionType: types.BalanceDynamic,
-						Currency:      accountCurrency.Currency,
-					},
-				}
 
 				return r
 			}(),
@@ -125,7 +113,7 @@ func TestNewReconciler(t *testing.T) {
 
 	for name, test := range tests {
 		t.Run(name, func(t *testing.T) {
-			result := New(nil, nil, test.options...)
+			result := New(nil, nil, nil, test.options...)
 			assert.ElementsMatch(t, test.expected.inactiveQueue, result.inactiveQueue)
 			assert.Equal(t, test.expected.seenAccounts, result.seenAccounts)
 			assert.ElementsMatch(t, test.expected.interestingAccounts, result.interestingAccounts)
@@ -133,7 +121,6 @@ func TestNewReconciler(t *testing.T) {
 			assert.Equal(t, test.expected.activeConcurrency, result.activeConcurrency)
 			assert.Equal(t, test.expected.lookupBalanceByBlock, result.lookupBalanceByBlock)
 			assert.Equal(t, cap(test.expected.changeQueue), cap(result.changeQueue))
-			assert.Equal(t, test.expected.exemptions, result.exemptions)
 		})
 	}
 }
@@ -308,6 +295,7 @@ func TestCompareBalance(t *testing.T) {
 	reconciler := New(
 		mh,
 		nil,
+		nil,
 	)
 
 	mh.On("CurrentBlock", ctx).Return(nil, errors.New("no head block")).Once()
@@ -508,7 +496,7 @@ func assertContainsAllAccounts(t *testing.T, m map[string]struct{}, a []*Account
 
 func TestInactiveAccountQueue(t *testing.T) {
 	var (
-		r     = New(nil, nil)
+		r     = New(nil, nil, parser.New(nil, nil, nil))
 		block = &types.BlockIdentifier{
 			Hash:  "block 1",
 			Index: 1,
@@ -775,6 +763,7 @@ func TestReconcile_SuccessOnlyActive(t *testing.T) {
 			r := New(
 				mockHelper,
 				mockHandler,
+				nil,
 				WithActiveConcurrency(1),
 				WithInactiveConcurrency(0),
 				WithInterestingAccounts([]*AccountCurrency{accountCurrency2}),
@@ -900,6 +889,7 @@ func TestReconcile_HighWaterMark(t *testing.T) {
 	r := New(
 		mockHelper,
 		mockHandler,
+		nil,
 		WithActiveConcurrency(1),
 		WithInactiveConcurrency(0),
 		WithInterestingAccounts([]*AccountCurrency{accountCurrency2}),
@@ -1008,6 +998,7 @@ func TestReconcile_Orphan(t *testing.T) {
 	r := New(
 		mockHelper,
 		mockHandler,
+		nil,
 		WithActiveConcurrency(1),
 		WithInactiveConcurrency(0),
 	)
@@ -1077,6 +1068,7 @@ func TestReconcile_FailureOnlyActive(t *testing.T) {
 			r := New(
 				mockHelper,
 				mockHandler,
+				parser.New(nil, nil, nil),
 				WithActiveConcurrency(1),
 				WithInactiveConcurrency(0),
 				WithLookupBalanceByBlock(lookup),
@@ -1147,6 +1139,9 @@ func TestReconcile_ExemptOnlyActive(t *testing.T) {
 			ExemptionType: types.BalanceGreaterOrEqual,
 			Currency:      accountCurrency.Currency,
 		}
+		p = parser.New(nil, nil, []*types.BalanceExemption{
+			exemption,
+		})
 	)
 
 	lookupBalanceByBlocks := []bool{true, false}
@@ -1157,10 +1152,10 @@ func TestReconcile_ExemptOnlyActive(t *testing.T) {
 			r := New(
 				mockHelper,
 				mockHandler,
+				p,
 				WithActiveConcurrency(1),
 				WithInactiveConcurrency(0),
 				WithLookupBalanceByBlock(lookup),
-				WithBalanceExemptions([]*types.BalanceExemption{exemption}),
 			)
 			ctx := context.Background()
 
@@ -1230,6 +1225,9 @@ func TestReconcile_ExemptAddressOnlyActive(t *testing.T) {
 			ExemptionType:     types.BalanceLessOrEqual,
 			SubAccountAddress: &subAccountAddress,
 		}
+		p = parser.New(nil, nil, []*types.BalanceExemption{
+			exemption,
+		})
 	)
 
 	lookupBalanceByBlocks := []bool{true, false}
@@ -1240,10 +1238,10 @@ func TestReconcile_ExemptAddressOnlyActive(t *testing.T) {
 			r := New(
 				mockHelper,
 				mockHandler,
+				p,
 				WithActiveConcurrency(1),
 				WithInactiveConcurrency(0),
 				WithLookupBalanceByBlock(lookup),
-				WithBalanceExemptions([]*types.BalanceExemption{exemption}),
 			)
 			ctx := context.Background()
 
@@ -1313,6 +1311,9 @@ func TestReconcile_ExemptAddressDynamicActive(t *testing.T) {
 			ExemptionType:     types.BalanceDynamic,
 			SubAccountAddress: &subAccountAddress,
 		}
+		p = parser.New(nil, nil, []*types.BalanceExemption{
+			exemption,
+		})
 	)
 
 	lookupBalanceByBlocks := []bool{true, false}
@@ -1323,10 +1324,9 @@ func TestReconcile_ExemptAddressDynamicActive(t *testing.T) {
 			r := New(
 				mockHelper,
 				mockHandler,
-				WithActiveConcurrency(1),
+				p,
 				WithInactiveConcurrency(0),
 				WithLookupBalanceByBlock(lookup),
-				WithBalanceExemptions([]*types.BalanceExemption{exemption}),
 			)
 			ctx := context.Background()
 
@@ -1396,6 +1396,9 @@ func TestReconcile_ExemptAddressDynamicActiveThrow(t *testing.T) {
 			ExemptionType:     types.BalanceDynamic,
 			SubAccountAddress: &subAccountAddress,
 		}
+		p = parser.New(nil, nil, []*types.BalanceExemption{
+			exemption,
+		})
 	)
 
 	lookupBalanceByBlocks := []bool{true, false}
@@ -1406,10 +1409,9 @@ func TestReconcile_ExemptAddressDynamicActiveThrow(t *testing.T) {
 			r := New(
 				mockHelper,
 				mockHandler,
-				WithActiveConcurrency(1),
+				p,
 				WithInactiveConcurrency(0),
 				WithLookupBalanceByBlock(lookup),
-				WithBalanceExemptions([]*types.BalanceExemption{exemption}),
 			)
 			ctx := context.Background()
 
@@ -1476,6 +1478,9 @@ func TestReconcile_NotExemptOnlyActive(t *testing.T) {
 			ExemptionType: types.BalanceLessOrEqual,
 			Currency:      accountCurrency.Currency,
 		}
+		p = parser.New(nil, nil, []*types.BalanceExemption{
+			exemption,
+		})
 	)
 
 	lookupBalanceByBlocks := []bool{true, false}
@@ -1486,10 +1491,9 @@ func TestReconcile_NotExemptOnlyActive(t *testing.T) {
 			r := New(
 				mockHelper,
 				mockHandler,
-				WithActiveConcurrency(1),
+				p,
 				WithInactiveConcurrency(0),
 				WithLookupBalanceByBlock(lookup),
-				WithBalanceExemptions([]*types.BalanceExemption{exemption}),
 			)
 			ctx := context.Background()
 
@@ -1557,6 +1561,9 @@ func TestReconcile_NotExemptAddressOnlyActive(t *testing.T) {
 			ExemptionType:     types.BalanceGreaterOrEqual,
 			SubAccountAddress: &subAddr,
 		}
+		p = parser.New(nil, nil, []*types.BalanceExemption{
+			exemption,
+		})
 	)
 
 	lookupBalanceByBlocks := []bool{true, false}
@@ -1567,10 +1574,9 @@ func TestReconcile_NotExemptAddressOnlyActive(t *testing.T) {
 			r := New(
 				mockHelper,
 				mockHandler,
-				WithActiveConcurrency(1),
+				p,
 				WithInactiveConcurrency(0),
 				WithLookupBalanceByBlock(lookup),
-				WithBalanceExemptions([]*types.BalanceExemption{exemption}),
 			)
 			ctx := context.Background()
 
@@ -1641,6 +1647,9 @@ func TestReconcile_NotExemptWrongAddressOnlyActive(t *testing.T) {
 			ExemptionType:     types.BalanceGreaterOrEqual,
 			SubAccountAddress: &subAddr,
 		}
+		p = parser.New(nil, nil, []*types.BalanceExemption{
+			exemption,
+		})
 	)
 
 	lookupBalanceByBlocks := []bool{true, false}
@@ -1651,10 +1660,10 @@ func TestReconcile_NotExemptWrongAddressOnlyActive(t *testing.T) {
 			r := New(
 				mockHelper,
 				mockHandler,
+				p,
 				WithActiveConcurrency(1),
 				WithInactiveConcurrency(0),
 				WithLookupBalanceByBlock(lookup),
-				WithBalanceExemptions([]*types.BalanceExemption{exemption}),
 			)
 			ctx := context.Background()
 
@@ -1727,6 +1736,7 @@ func TestReconcile_SuccessOnlyInactive(t *testing.T) {
 			r := New(
 				mockHelper,
 				mockHandler,
+				nil,
 				WithActiveConcurrency(0),
 				WithInactiveConcurrency(1),
 				WithSeenAccounts([]*AccountCurrency{accountCurrency}),
@@ -1827,6 +1837,7 @@ func TestReconcile_FailureOnlyInactive(t *testing.T) {
 			r := New(
 				mockHelper,
 				mockHandler,
+				parser.New(nil, nil, nil),
 				WithActiveConcurrency(0),
 				WithInactiveConcurrency(1),
 				WithSeenAccounts([]*AccountCurrency{accountCurrency}),
@@ -1891,6 +1902,7 @@ func TestReconcile_EnqueueCancel(t *testing.T) {
 	r := New(
 		mockHelper,
 		mockHandler,
+		nil,
 		WithActiveConcurrency(1),
 		WithInactiveConcurrency(0),
 		WithLookupBalanceByBlock(true),
diff --git a/storage/balance_storage_test.go b/storage/balance_storage_test.go
index e34b9aa..e02411f 100644
--- a/storage/balance_storage_test.go
+++ b/storage/balance_storage_test.go
@@ -93,6 +93,19 @@ func TestBalance(t *testing.T) {
 				},
 			},
 		}
+		exemptionAccount = &types.AccountIdentifier{
+			Address: "exemption",
+		}
+		exemptionCurrency = &types.Currency{
+			Symbol:   "exempt",
+			Decimals: 3,
+		}
+		exemptions = []*types.BalanceExemption{
+			{
+				ExemptionType: types.BalanceGreaterOrEqual,
+				Currency:      exemptionCurrency,
+			},
+		}
 		currency = &types.Currency{
 			Symbol:   "BLAH",
 			Decimals: 2,
@@ -128,6 +141,10 @@ func TestBalance(t *testing.T) {
 			Hash:  "pkdgdj",
 			Index: 123891,
 		}
+		newBlock4 = &types.BlockIdentifier{
+			Hash:  "asdjkajsdk",
+			Index: 123892,
+		}
 		largeDeduction = &types.Amount{
 			Value:    "-1000",
 			Currency: currency,
@@ -136,6 +153,7 @@ func TestBalance(t *testing.T) {
 			AccountBalances: map[string]string{
 				"genesis": "100",
 			},
+			BalExemptions: exemptions,
 		}
 	)
 
@@ -426,6 +444,116 @@ func TestBalance(t *testing.T) {
 		assert.Equal(t, newBlock, block)
 	})
 
+	t.Run("balance exemption update", func(t *testing.T) {
+		mockHelper.AccountBalanceAmount = "0"
+		txn := storage.db.NewDatabaseTransaction(ctx, true)
+		err := storage.UpdateBalance(
+			ctx,
+			txn,
+			&parser.BalanceChange{
+				Account:    exemptionAccount,
+				Currency:   exemptionCurrency,
+				Block:      newBlock,
+				Difference: amount.Value,
+			},
+			nil,
+		)
+		assert.NoError(t, err)
+		assert.NoError(t, txn.Commit(ctx))
+
+		retrievedAmount, block, err := storage.GetBalance(
+			ctx,
+			exemptionAccount,
+			exemptionCurrency,
+			newBlock,
+		)
+		assert.NoError(t, err)
+		assert.Equal(t, amount.Value, retrievedAmount.Value)
+		assert.Equal(t, newBlock, block)
+
+		// Successful (balance == computed)
+		mockHelper.AccountBalanceAmount = amount.Value
+		txn = storage.db.NewDatabaseTransaction(ctx, true)
+		err = storage.UpdateBalance(
+			ctx,
+			txn,
+			&parser.BalanceChange{
+				Account:    exemptionAccount,
+				Currency:   exemptionCurrency,
+				Block:      newBlock3,
+				Difference: "50",
+			},
+			nil,
+		)
+		assert.NoError(t, err)
+		assert.NoError(t, txn.Commit(ctx))
+
+		retrievedAmount, block, err = storage.GetBalance(
+			ctx,
+			exemptionAccount,
+			exemptionCurrency,
+			newBlock3,
+		)
+		assert.NoError(t, err)
+		assert.Equal(t, "150", retrievedAmount.Value)
+		assert.Equal(t, newBlock3, block)
+
+		// Successful (balance > computed)
+		mockHelper.AccountBalanceAmount = "200"
+		txn = storage.db.NewDatabaseTransaction(ctx, true)
+		err = storage.UpdateBalance(
+			ctx,
+			txn,
+			&parser.BalanceChange{
+				Account:    exemptionAccount,
+				Currency:   exemptionCurrency,
+				Block:      newBlock4,
+				Difference: "50",
+			},
+			nil,
+		)
+		assert.NoError(t, err)
+		assert.NoError(t, txn.Commit(ctx))
+
+		retrievedAmount, block, err = storage.GetBalance(
+			ctx,
+			exemptionAccount,
+			exemptionCurrency,
+			newBlock4,
+		)
+		assert.NoError(t, err)
+		assert.Equal(t, "250", retrievedAmount.Value)
+		assert.Equal(t, newBlock4, block)
+
+		// Unsuccessful (balance < computed)
+		mockHelper.AccountBalanceAmount = "10"
+		txn = storage.db.NewDatabaseTransaction(ctx, true)
+		err = storage.UpdateBalance(
+			ctx,
+			txn,
+			&parser.BalanceChange{
+				Account:    exemptionAccount,
+				Currency:   exemptionCurrency,
+				Block:      newBlock4,
+				Difference: "50",
+			},
+			nil,
+		)
+		assert.Error(t, err)
+		txn.Discard(ctx)
+
+		retrievedAmount, block, err = storage.GetBalance(
+			ctx,
+			exemptionAccount,
+			exemptionCurrency,
+			newBlock4,
+		)
+		assert.NoError(t, err)
+		assert.Equal(t, "250", retrievedAmount.Value)
+		assert.Equal(t, newBlock4, block)
+		mockHelper.AccountBalanceAmount = ""
+	})
+
 	t.Run("get all set AccountCurrency", func(t *testing.T) {
 		accounts, err := storage.GetAllAccountCurrency(ctx)
 		assert.NoError(t, err)
@@ -454,6 +582,10 @@ func TestBalance(t *testing.T) {
 				Account:  subAccountMetadata2,
 				Currency: currency,
 			},
+			{
+				Account:  exemptionAccount,
+				Currency: exemptionCurrency,
+			},
 		}, accounts)
 	})
 }
@@ -887,6 +1019,7 @@ type MockBalanceStorageHelper struct {
 	AccountBalanceAmount string
 	AccountBalances      map[string]string
 	ExemptAccounts       []*reconciler.AccountCurrency
+	BalExemptions        []*types.BalanceExemption
 }
 
 func (h *MockBalanceStorageHelper) AccountBalance(
@@ -952,3 +1085,7 @@ func (h *MockBalanceStorageHelper) ExemptFunc() parser.ExemptOperation {
 		return false
 	}
 }
+
+func (h *MockBalanceStorageHelper) BalanceExemptions() []*types.BalanceExemption {
+	return h.BalExemptions
+}