diff --git a/pkg/chain/ethereum/ethutil/nonce.go b/pkg/chain/ethereum/ethutil/nonce.go new file mode 100644 index 0000000..983e7a3 --- /dev/null +++ b/pkg/chain/ethereum/ethutil/nonce.go @@ -0,0 +1,122 @@ +package ethutil + +import ( + "context" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" +) + +// The inactivity time after which the local nonce is refreshed with the value +// from the chain. The local value is invalidated after the certain duration to +// let the nonce recover in case the mempool crashed before propagating the last +// transaction sent. +const localNonceTrustDuration = 5 * time.Second + +// NonceManager tracks the nonce for the account and allows to update it after +// each successfully submitted transaction. Tracking the nonce locally is +// required when transactions are submitted from multiple goroutines or when +// multiple Ethereum clients are deployed behind a load balancer, there are no +// sticky sessions and mempool synchronization between them takes some time. +// +// NonceManager provides no synchronization and is NOT safe for concurrent use. +// It is up to the client code to implement the required synchronization. +// +// An example execution might work as follows: +// 1. Obtain transaction lock, +// 2. Calculate CurrentNonce(), +// 3. Submit transaction with the calculated nonce, +// 4. Call IncrementNonce(), +// 5. Release transaction lock. +type NonceManager struct { + account common.Address + transactor bind.ContractTransactor + localNonce uint64 + expirationDate time.Time +} + +// NewNonceManager creates NonceManager instance for the provided account using +// the provided contract transactor. Contract transactor is used for every +// CurrentNonce execution to check the pending nonce value as seen by the +// Ethereum client. +func NewNonceManager( + account common.Address, + transactor bind.ContractTransactor, +) *NonceManager { + return &NonceManager{ + account: account, + transactor: transactor, + localNonce: 0, + } +} + +// CurrentNonce returns the nonce value that should be used for the next +// transaction. The nonce is evaluated as the higher value from the local +// nonce and pending nonce fetched from the Ethereum client. The local nonce +// is cached for the specific duration. If the local nonce expired, the pending +// nonce returned from the chain is used. +// +// CurrentNonce is NOT safe for concurrent use. It is up to the code using this +// function to provide the required synchronization, optionally including +// IncrementNonce call as well. +func (nm *NonceManager) CurrentNonce() (uint64, error) { + pendingNonce, err := nm.transactor.PendingNonceAt( + context.TODO(), + nm.account, + ) + if err != nil { + return 0, err + } + + now := time.Now() + + if pendingNonce < nm.localNonce { + if now.Before(nm.expirationDate) { + logger.Infof( + "local nonce [%v] is higher than pending [%v]; using the local one", + nm.localNonce, + pendingNonce, + ) + } else { + logger.Infof( + "local nonce [%v] is higher than pending [%v] but local "+ + "nonce expired; updating local nonce", + nm.localNonce, + pendingNonce, + ) + + nm.localNonce = pendingNonce + } + } + + // After localNonceTrustDuration of inactivity (no CurrentNonce() calls), + // the local copy is considered as no longer up-to-date and it's always + // reset to the pending nonce value as seen by the chain. + // + // We do it to recover from potential mempool crashes. + // + // Keep in mind, the local copy is considered valid as long as transactions + // are submitted one after another. + nm.expirationDate = now.Add(localNonceTrustDuration) + + if pendingNonce > nm.localNonce { + logger.Infof( + "local nonce [%v] is lower than pending [%v]; updating local nonce", + nm.localNonce, + pendingNonce, + ) + + nm.localNonce = pendingNonce + } + + return nm.localNonce, nil +} + +// IncrementNonce increments the value of the nonce kept locally by one. +// This function is NOT safe for concurrent use. It is up to the client code +// using this function to provide the required synchronization. +func (nm *NonceManager) IncrementNonce() uint64 { + nm.localNonce++ + return nm.localNonce +} diff --git a/pkg/chain/ethereum/ethutil/nonce_test.go b/pkg/chain/ethereum/ethutil/nonce_test.go new file mode 100644 index 0000000..5ae9e6e --- /dev/null +++ b/pkg/chain/ethereum/ethutil/nonce_test.go @@ -0,0 +1,123 @@ +package ethutil + +import ( + "context" + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" +) + +func TestResolveAndIncrement(t *testing.T) { + tests := map[string]struct { + pendingNonce uint64 + localNonce uint64 + expirationDate time.Time + expectedNonce uint64 + expectedNextNonce uint64 + }{ + "pending and local the same": { + pendingNonce: 10, + localNonce: 10, + expirationDate: time.Now().Add(time.Second), + expectedNonce: 10, + expectedNextNonce: 11, + }, + "pending nonce higher": { + pendingNonce: 121, + localNonce: 120, + expirationDate: time.Now().Add(time.Second), + expectedNonce: 121, + expectedNextNonce: 122, + }, + "pending nonce lower": { + pendingNonce: 110, + localNonce: 111, + expirationDate: time.Now().Add(time.Second), + expectedNonce: 111, + expectedNextNonce: 112, + }, + "pending nonce lower and local one expired": { + pendingNonce: 110, + localNonce: 111, + expirationDate: time.Now().Add(-1 * time.Second), + expectedNonce: 110, + expectedNextNonce: 111, + }, + } + + for testName, test := range tests { + t.Run(testName, func(t *testing.T) { + transactor := &mockTransactor{test.pendingNonce} + manager := &NonceManager{ + transactor: transactor, + localNonce: test.localNonce, + expirationDate: test.expirationDate, + } + + nonce, err := manager.CurrentNonce() + if err != nil { + t.Fatal(err) + } + + if nonce != test.expectedNonce { + t.Errorf( + "unexpected nonce\nexpected: [%v]\nactual: [%v]", + test.expectedNonce, + nonce, + ) + } + + nextNonce := manager.IncrementNonce() + + if nextNonce != test.expectedNextNonce { + t.Errorf( + "unexpected nonce\nexpected: [%v]\nactual: [%v]", + test.expectedNextNonce, + nextNonce, + ) + } + }) + } +} + +type mockTransactor struct { + nextNonce uint64 +} + +func (mt *mockTransactor) PendingCodeAt( + ctx context.Context, + account common.Address, +) ([]byte, error) { + panic("not implemented") +} + +func (mt *mockTransactor) PendingNonceAt( + ctx context.Context, + account common.Address, +) (uint64, error) { + return mt.nextNonce, nil +} + +func (mt *mockTransactor) SuggestGasPrice( + ctx context.Context, +) (*big.Int, error) { + panic("not implemented") +} + +func (mt *mockTransactor) EstimateGas( + ctx context.Context, + call ethereum.CallMsg, +) (gas uint64, err error) { + panic("not implemented") +} + +func (mt *mockTransactor) SendTransaction( + ctx context.Context, + tx *types.Transaction, +) error { + panic("not implemented") +} diff --git a/tools/generators/ethereum/command.go.tmpl b/tools/generators/ethereum/command.go.tmpl index 17b4e19..3a835e0 100644 --- a/tools/generators/ethereum/command.go.tmpl +++ b/tools/generators/ethereum/command.go.tmpl @@ -221,6 +221,7 @@ func initialize{{.Class}}(c *cli.Context) (*contract.{{.Class}}, error) { address, key, client, + ethutil.NewNonceManager(key.Address, client), &sync.Mutex{}, ) } diff --git a/tools/generators/ethereum/command_template_content.go b/tools/generators/ethereum/command_template_content.go index 87997f8..e1e5fea 100644 --- a/tools/generators/ethereum/command_template_content.go +++ b/tools/generators/ethereum/command_template_content.go @@ -224,6 +224,7 @@ func initialize{{.Class}}(c *cli.Context) (*contract.{{.Class}}, error) { address, key, client, + ethutil.NewNonceManager(key.Address, client), &sync.Mutex{}, ) } diff --git a/tools/generators/ethereum/contract.go.tmpl b/tools/generators/ethereum/contract.go.tmpl index 72d67f8..fe1c36b 100644 --- a/tools/generators/ethereum/contract.go.tmpl +++ b/tools/generators/ethereum/contract.go.tmpl @@ -34,6 +34,7 @@ type {{.Class}} struct { callerOptions *bind.CallOpts transactorOptions *bind.TransactOpts errorResolver *ethutil.ErrorResolver + nonceManager *ethutil.NonceManager transactionMutex *sync.Mutex } @@ -42,6 +43,7 @@ func New{{.Class}}( contractAddress common.Address, accountKey *keystore.Key, backend bind.ContractBackend, + nonceManager *ethutil.NonceManager, transactionMutex *sync.Mutex, ) (*{{.Class}}, error) { callerOptions := &bind.CallOpts{ @@ -78,6 +80,7 @@ func New{{.Class}}( callerOptions: callerOptions, transactorOptions: transactorOptions, errorResolver: ethutil.NewErrorResolver(backend, &contractABI, &contractAddress), + nonceManager: nonceManager, transactionMutex: transactionMutex, }, nil } diff --git a/tools/generators/ethereum/contract_non_const_methods.go.tmpl b/tools/generators/ethereum/contract_non_const_methods.go.tmpl index 466737f..e2e61ed 100644 --- a/tools/generators/ethereum/contract_non_const_methods.go.tmpl +++ b/tools/generators/ethereum/contract_non_const_methods.go.tmpl @@ -42,11 +42,17 @@ func ({{$contract.ShortVar}} *{{$contract.Class}}) {{$method.CapsName}}( transactionOptions[0].Apply(transactorOptions) } + nonce, err := {{$contract.ShortVar}}.nonceManager.CurrentNonce() + if err != nil { + return nil, fmt.Errorf("failed to retrieve account nonce: %v", err) + } + + transactorOptions.Nonce = new(big.Int).SetUint64(nonce) + transaction, err := {{$contract.ShortVar}}.contract.{{$method.CapsName}}( transactorOptions, {{$method.Params}} ) - if err != nil { return transaction, {{$contract.ShortVar}}.errorResolver.ResolveError( err, @@ -66,6 +72,8 @@ func ({{$contract.ShortVar}} *{{$contract.Class}}) {{$method.CapsName}}( transaction.Hash().Hex(), ) + {{$contract.ShortVar}}.nonceManager.IncrementNonce() + return transaction, err } diff --git a/tools/generators/ethereum/contract_non_const_methods_template_content.go b/tools/generators/ethereum/contract_non_const_methods_template_content.go index 635cdbc..5c9d45b 100644 --- a/tools/generators/ethereum/contract_non_const_methods_template_content.go +++ b/tools/generators/ethereum/contract_non_const_methods_template_content.go @@ -45,11 +45,17 @@ func ({{$contract.ShortVar}} *{{$contract.Class}}) {{$method.CapsName}}( transactionOptions[0].Apply(transactorOptions) } + nonce, err := {{$contract.ShortVar}}.nonceManager.CurrentNonce() + if err != nil { + return nil, fmt.Errorf("failed to retrieve account nonce: %v", err) + } + + transactorOptions.Nonce = new(big.Int).SetUint64(nonce) + transaction, err := {{$contract.ShortVar}}.contract.{{$method.CapsName}}( transactorOptions, {{$method.Params}} ) - if err != nil { return transaction, {{$contract.ShortVar}}.errorResolver.ResolveError( err, @@ -69,6 +75,8 @@ func ({{$contract.ShortVar}} *{{$contract.Class}}) {{$method.CapsName}}( transaction.Hash().Hex(), ) + {{$contract.ShortVar}}.nonceManager.IncrementNonce() + return transaction, err } diff --git a/tools/generators/ethereum/contract_template_content.go b/tools/generators/ethereum/contract_template_content.go index 6c0ee07..88377b7 100644 --- a/tools/generators/ethereum/contract_template_content.go +++ b/tools/generators/ethereum/contract_template_content.go @@ -37,6 +37,7 @@ type {{.Class}} struct { callerOptions *bind.CallOpts transactorOptions *bind.TransactOpts errorResolver *ethutil.ErrorResolver + nonceManager *ethutil.NonceManager transactionMutex *sync.Mutex } @@ -45,6 +46,7 @@ func New{{.Class}}( contractAddress common.Address, accountKey *keystore.Key, backend bind.ContractBackend, + nonceManager *ethutil.NonceManager, transactionMutex *sync.Mutex, ) (*{{.Class}}, error) { callerOptions := &bind.CallOpts{ @@ -81,6 +83,7 @@ func New{{.Class}}( callerOptions: callerOptions, transactorOptions: transactorOptions, errorResolver: ethutil.NewErrorResolver(backend, &contractABI, &contractAddress), + nonceManager: nonceManager, transactionMutex: transactionMutex, }, nil }