From ead990a734e0caf0ce0e5d0297c487756894acf2 Mon Sep 17 00:00:00 2001 From: Jacob Evans Date: Wed, 31 Jan 2018 16:19:47 +1100 Subject: Nonce tracker subprovider Caches the nonce when a request to getTransactionCount is made and increments the pending nonce after successful transactions --- packages/subproviders/src/globals.d.ts | 21 +++ packages/subproviders/src/index.ts | 1 + .../subproviders/src/subproviders/nonce_tracker.ts | 112 ++++++++++++ .../test/unit/nonce_tracker_subprovider_test.ts | 195 +++++++++++++++++++++ 4 files changed, 329 insertions(+) create mode 100644 packages/subproviders/src/subproviders/nonce_tracker.ts create mode 100644 packages/subproviders/test/unit/nonce_tracker_subprovider_test.ts (limited to 'packages/subproviders') diff --git a/packages/subproviders/src/globals.d.ts b/packages/subproviders/src/globals.d.ts index 53457fa24..595bae89e 100644 --- a/packages/subproviders/src/globals.d.ts +++ b/packages/subproviders/src/globals.d.ts @@ -1,3 +1,4 @@ + declare module 'dirty-chai'; declare module 'es6-promisify'; @@ -13,7 +14,9 @@ declare module 'ethereumjs-tx' { public r: Buffer; public s: Buffer; public v: Buffer; + public nonce: Buffer; public serialize(): Buffer; + public getSenderAddress(): Buffer; constructor(txParams: any); } export = EthereumTx; @@ -97,6 +100,24 @@ declare module 'web3-provider-engine' { } export = Web3ProviderEngine; } +declare module 'web3-provider-engine/util/rpc-cache-utils' { + class ProviderEngineRpcUtils { + public static blockTagForPayload(payload: any): string|null; + } + export = ProviderEngineRpcUtils; +} +declare module 'web3-provider-engine/subproviders/fixture' { + import * as Web3 from 'web3'; + class FixtureSubprovider { + constructor(staticResponses: any); + public handleRequest( + payload: Web3.JSONRPCRequestPayload, + next: () => void, + end: (err: Error | null, data?: any) => void, + ): void; + } + export = FixtureSubprovider; +} // hdkey declarations declare module 'hdkey' { diff --git a/packages/subproviders/src/index.ts b/packages/subproviders/src/index.ts index 720c4362f..4b3be4efd 100644 --- a/packages/subproviders/src/index.ts +++ b/packages/subproviders/src/index.ts @@ -9,6 +9,7 @@ import { LedgerEthereumClient } from './types'; export { InjectedWeb3Subprovider } from './subproviders/injected_web3'; export { RedundantRPCSubprovider } from './subproviders/redundant_rpc'; export { LedgerSubprovider } from './subproviders/ledger'; +export { NonceTrackerSubprovider } from './subproviders/nonce_tracker'; export { ECSignature, LedgerWalletSubprovider, LedgerCommunicationClient } from './types'; /** diff --git a/packages/subproviders/src/subproviders/nonce_tracker.ts b/packages/subproviders/src/subproviders/nonce_tracker.ts new file mode 100644 index 000000000..540a91771 --- /dev/null +++ b/packages/subproviders/src/subproviders/nonce_tracker.ts @@ -0,0 +1,112 @@ +import { promisify } from '@0xproject/utils'; +import * as _ from 'lodash'; + +import EthereumTx = require('ethereumjs-tx'); +import ethUtil = require('ethereumjs-util'); +import providerEngineUtils = require('web3-provider-engine/util/rpc-cache-utils'); + +import { JSONRPCPayload } from '../types'; + +import { Subprovider } from './subprovider'; + +const NONCE_TOO_LOW_ERROR_MESSAGE = 'Transaction nonce is too low'; + +export class NonceTrackerSubprovider extends Subprovider { + private _nonceCache: { [address: string]: string } = {}; + private static _reconstructTransaction(payload: JSONRPCPayload): EthereumTx { + const raw = payload.params[0]; + const transactionData = ethUtil.stripHexPrefix(raw); + const rawData = new Buffer(transactionData, 'hex'); + return new EthereumTx(rawData); + } + private static _determineAddress(payload: JSONRPCPayload): string { + switch (payload.method) { + case 'eth_getTransactionCount': + return payload.params[0].toLowerCase(); + case 'eth_sendRawTransaction': + const transaction = NonceTrackerSubprovider._reconstructTransaction(payload); + return `0x${transaction.getSenderAddress().toString('hex')}`.toLowerCase(); + default: + throw new Error('Invalid Method'); + } + } + constructor() { + super(); + } + // tslint:disable-next-line:async-suffix + public async handleRequest( + payload: JSONRPCPayload, + next: (callback?: (err: Error | null, result: any, cb: any) => void) => void, + end: (err: Error | null, data?: any) => void, + ): Promise { + switch (payload.method) { + case 'eth_getTransactionCount': + const blockTag = providerEngineUtils.blockTagForPayload(payload); + if (!_.isNull(blockTag) && blockTag === 'pending') { + const address = NonceTrackerSubprovider._determineAddress(payload); + const cachedResult = this._nonceCache[address]; + if (cachedResult) { + end(null, cachedResult); + return; + } else { + next((requestError: Error | null, requestResult: any, cb: any) => { + if (_.isNull(requestError)) { + this._nonceCache[address] = requestResult as string; + } + cb(); + return; + }); + return; + } + } else { + next(); + return; + } + case 'eth_sendRawTransaction': + return next(async (sendTransactionError: Error | null, txResult: any, cb: any) => { + if (_.isNull(sendTransactionError)) { + this._handleSuccessfulTransaction(payload); + } else { + await this._handleSendTransactionErrorAsync(payload, sendTransactionError); + } + cb(); + }); + default: + return next(); + } + } + private _handleSuccessfulTransaction(payload: JSONRPCPayload): void { + const address = NonceTrackerSubprovider._determineAddress(payload); + const transaction = NonceTrackerSubprovider._reconstructTransaction(payload); + // Increment the nonce from the previous successfully submitted transaction + let nonce = ethUtil.bufferToInt(transaction.nonce); + nonce++; + let nextHexNonce = nonce.toString(16); + if (nextHexNonce.length % 2) { + nextHexNonce = `0${nextHexNonce}`; + } + nextHexNonce = `0x${nextHexNonce}`; + this._nonceCache[address] = nextHexNonce; + } + private async _handleSendTransactionErrorAsync(payload: JSONRPCPayload, err: Error): Promise { + const address = NonceTrackerSubprovider._determineAddress(payload); + if (this._nonceCache[address]) { + if (_.includes(err.message, NONCE_TOO_LOW_ERROR_MESSAGE)) { + await this._handleNonceTooLowErrorAsync(address); + } + } + } + private async _handleNonceTooLowErrorAsync(address: string): Promise { + const oldNonceInt = ethUtil.bufferToInt(new Buffer(this._nonceCache[address], 'hex')); + delete this._nonceCache[address]; + const nonceResult = await this.emitPayloadAsync({ + method: 'eth_getTransactionCount', + params: [address, 'pending'], + }); + const nonce = nonceResult.result; + const latestNonceInt = ethUtil.bufferToInt(new Buffer(nonce, 'hex')); + if (latestNonceInt > oldNonceInt) { + this._nonceCache[address] = nonce; + } + } +} diff --git a/packages/subproviders/test/unit/nonce_tracker_subprovider_test.ts b/packages/subproviders/test/unit/nonce_tracker_subprovider_test.ts new file mode 100644 index 000000000..a99f1f9fb --- /dev/null +++ b/packages/subproviders/test/unit/nonce_tracker_subprovider_test.ts @@ -0,0 +1,195 @@ +import * as chai from 'chai'; +import * as _ from 'lodash'; +import Web3 = require('web3'); +import Web3ProviderEngine = require('web3-provider-engine'); +import FixtureSubprovider = require('web3-provider-engine/subproviders/fixture'); + +import promisify = require('es6-promisify'); +import EthereumTx = require('ethereumjs-tx'); + +import { NonceTrackerSubprovider } from '../../src'; +import { DoneCallback } from '../../src/types'; +import { chaiSetup } from '../chai_setup'; +import { reportCallbackErrors } from '../utils/report_callback_errors'; + +import { Subprovider } from '../../src/subproviders/subprovider'; + +const expect = chai.expect; +chaiSetup.configure(); + +describe('NonceTrackerSubprovider', () => { + let provider: Web3ProviderEngine; + it('successfully caches the transaction count', async () => { + provider = new Web3ProviderEngine(); + let called = false; + const nonceTrackerSubprovider = new NonceTrackerSubprovider(); + provider.addProvider(nonceTrackerSubprovider); + provider.addProvider(new FixtureSubprovider({ + 'eth_getBlockByNumber': '0x01', + 'eth_getTransactionCount': (data: any, next: any, end: any) => { + if (called) { + return end(null, '0x99'); + } else { + called = true; + end(null, '0x01'); + } + }, + })); + provider.start(); + + const payload = { + jsonrpc: '2.0', + method: 'eth_getTransactionCount', + params: ['0x0', 'pending'], + id: 1, + }; + + const response = await promisify(provider.sendAsync, provider)(payload); + expect(response.result).to.be.eq('0x01'); + const secondResponse = await promisify(provider.sendAsync, provider)(payload); + expect(secondResponse.result).to.be.eq('0x01'); + }); + it('does not cache the result for latest transaction count', async () => { + provider = new Web3ProviderEngine(); + let called = false; + const nonceTrackerSubprovider = new NonceTrackerSubprovider(); + provider.addProvider(nonceTrackerSubprovider); + provider.addProvider(new FixtureSubprovider({ + 'eth_getBlockByNumber': '0x01', + 'eth_getTransactionCount': (data: any, next: any, end: any) => { + if (called) { + return end(null, '0x99'); + } else { + called = true; + end(null, '0x01'); + } + }, + })); + provider.start(); + + const payload = { + jsonrpc: '2.0', + method: 'eth_getTransactionCount', + params: ['0x0', 'latest'], + id: 1, + }; + + const response = await promisify(provider.sendAsync, provider)(payload); + expect(response.result).to.be.eq('0x01'); + const secondResponse = await promisify(provider.sendAsync, provider)(payload); + expect(secondResponse.result).to.be.eq('0x99'); + }); + it('clears the cache on a Nonce Too Low Error', async () => { + provider = new Web3ProviderEngine(); + let called = false; + const nonceTrackerSubprovider = new NonceTrackerSubprovider(); + provider.addProvider(nonceTrackerSubprovider); + provider.addProvider(new FixtureSubprovider({ + 'eth_getBlockByNumber': '0x01', + 'eth_getTransactionCount': (data: any, next: any, end: any) => { + if (called) { + return end(null, '0x99'); + } else { + called = true; + end(null, '0x01'); + } + }, + 'eth_sendRawTransaction': (data: any, next: any, end: any) => { + end(new Error('Transaction nonce is too low')); + }, + })); + provider.start(); + + const noncePayload = { + jsonrpc: '2.0', + method: 'eth_getTransactionCount', + params: ['0x1f36f546477cda21bf2296c50976f2740247906f', 'pending'], + id: 1, + }; + const txParams = [ + '0x', + '0x09184e72a000', + '0x2710', + '0x0000000000000000000000000000000000000000', + '0x', + '0x7f7465737432000000000000000000000000000000000000000000000000000000600057', + '0x1c', + '0x5e1d3a76fbf824220eafc8c79ad578ad2b67d01b0c2425eb1f1347e8f50882ab', + '0x5bd428537f05f9830e93792f90ea6a3e2d1ee84952dd96edbae9f658f831ab13' + ]; + + const transaction = new EthereumTx(txParams); + const txPayload = { + jsonrpc: '2.0', + method: 'eth_sendRawTransaction', + params: [transaction.serialize()], + id: 1, + }; + + const response = await promisify(provider.sendAsync, provider)(noncePayload); + expect(response.result).to.be.eq('0x01'); + const secondResponse = await promisify(provider.sendAsync, provider)(noncePayload); + expect(secondResponse.result).to.be.eq('0x01'); + try { + const txResponse = await promisify(provider.sendAsync, provider)(txPayload); + } catch (err) { + const thirdResponse = await promisify(provider.sendAsync, provider)(noncePayload); + expect(thirdResponse.result).to.be.eq('0x99'); + } + }); + it('increments the used nonce', async () => { + provider = new Web3ProviderEngine(); + let called = false; + const nonceTrackerSubprovider = new NonceTrackerSubprovider(); + provider.addProvider(nonceTrackerSubprovider); + provider.addProvider(new FixtureSubprovider({ + 'eth_getBlockByNumber': '0x01', + 'eth_getTransactionCount': (data: any, next: any, end: any) => { + if (called) { + return end(null, '0x99'); + } else { + called = true; + end(null, '0x00'); + } + }, + 'eth_sendRawTransaction': (data: any, next: any, end: any) => { + end(null); + }, + })); + provider.start(); + + const noncePayload = { + jsonrpc: '2.0', + method: 'eth_getTransactionCount', + params: ['0x1f36f546477cda21bf2296c50976f2740247906f', 'pending'], + id: 1, + }; + const txParams = [ + '0x', + '0x09184e72a000', + '0x2710', + '0x0000000000000000000000000000000000000000', + '0x', + '0x7f7465737432000000000000000000000000000000000000000000000000000000600057', + '0x1c', + '0x5e1d3a76fbf824220eafc8c79ad578ad2b67d01b0c2425eb1f1347e8f50882ab', + '0x5bd428537f05f9830e93792f90ea6a3e2d1ee84952dd96edbae9f658f831ab13' + ]; + + const transaction = new EthereumTx(txParams); + const txPayload = { + jsonrpc: '2.0', + method: 'eth_sendRawTransaction', + params: [transaction.serialize()], + id: 1, + }; + + const response = await promisify(provider.sendAsync, provider)(noncePayload); + expect(response.result).to.be.eq('0x00'); + const secondResponse = await promisify(provider.sendAsync, provider)(noncePayload); + expect(secondResponse.result).to.be.eq('0x00'); + const txResponse = await promisify(provider.sendAsync, provider)(txPayload); + const thirdResponse = await promisify(provider.sendAsync, provider)(noncePayload); + expect(thirdResponse.result).to.be.eq('0x01'); + }); +}); -- cgit v1.2.3