From 5f1065fb56c4e5b31f6195656b45005f122b1d74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Sant=C3=A1ngelo?= Date: Wed, 9 May 2018 15:17:41 +0200 Subject: [PATCH] feat: fail if the tx is dropped (#12) --- specs/Contracts.spec.ts | 69 +++----------------- specs/NodeConnectionFactory.ts | 54 +++++++++++++++ specs/deployContract.ts | 33 ++++++++++ specs/ethProviderFactory.tx | 0 specs/txUtils.spec.ts | 116 +++++++++++++++++++++++++++++++++ src/ethereum/eth.ts | 25 ------- src/ethereum/txUtils.ts | 43 +++++++++--- src/ethereum/wallets/Wallet.ts | 13 +++- 8 files changed, 258 insertions(+), 95 deletions(-) create mode 100644 specs/NodeConnectionFactory.ts create mode 100644 specs/deployContract.ts create mode 100644 specs/ethProviderFactory.tx create mode 100644 specs/txUtils.spec.ts diff --git a/specs/Contracts.spec.ts b/specs/Contracts.spec.ts index 3fd984e..11f3b19 100644 --- a/specs/Contracts.spec.ts +++ b/specs/Contracts.spec.ts @@ -1,29 +1,18 @@ import { expect } from 'chai' +import { NodeConnectionFactory } from './NodeConnectionFactory' +import { deployContract } from './deployContract' import { eth, txUtils } from '../dist' import { MANAToken } from '../dist/contracts' -const ganache = require('ganache-cli') describe('ETH tests', () => { + const nodeConnectionFactory = new NodeConnectionFactory() + it('should return no instantiated contracts', () => { expect(() => eth.getContract('')).to.throw() }) describe('ETH using provider', function() { - const provider: object = ganache.provider({ - accounts: [ - { - balance: 100012300001 /* gas */, - secretKey: '0x8485898485bbe08a0a9b97fdf695aec8e9f1d196c589d4b6ff50a0232518b682' - } - ], - network_id: 3, - logger: { - log(...args) { - console.log(...args) - } - }, - vmErrorsOnRPCResponse: true - }) + const provider = nodeConnectionFactory.createProvider() it('should call .connect({}) and it works', async () => { const r = await eth.connect({ provider }) @@ -34,21 +23,7 @@ describe('ETH tests', () => { }) describe('ETH using http RPC', function() { - const provider = ganache.server({ - accounts: [ - { - balance: 100012300001 /* gas */, - secretKey: '0x8485898485bbe08a0a9b97fdf695aec8e9f1d196c589d4b6ff50a0232518b682' - } - ], - network_id: 3, - logger: { - log(...args) { - console.log(...args) - } - }, - vmErrorsOnRPCResponse: true - }) + const provider = nodeConnectionFactory.createServer() it('should start the server', done => { provider.listen(7654, function(err) { @@ -104,13 +79,16 @@ function doTest() { this.timeout(100000) const contract = await deployContract(eth.wallet, 'MANA', require('./fixtures/MANAToken.json')) console.log(`> Tx: ${contract.transactionHash}`) + const txRecipt = await eth.wallet.getTransactionReceipt(contract.transactionHash) expect(typeof txRecipt.contractAddress).to.eq('string') expect(txRecipt.contractAddress.length).to.be.greaterThan(0) + const x = await txUtils.getTransaction(contract.transactionHash) expect(typeof x).eq('object') expect(x.hash).eq(contract.transactionHash) expect(typeof x.recepeit).eq('object') + manaAddress = txRecipt.contractAddress }) @@ -179,32 +157,3 @@ function doTest() { } }) } - -async function deployContract(wallet, name: string, contract: any) { - const account = await wallet.getAccount() - - const newContract = await wallet.getContract(contract.abi) - const gasEstimate = await wallet.estimateGas({ data: contract.bytecode }) - - console.log(`> Will deploy contract ${name} with gas: ${gasEstimate}`) - - const options = { from: account, data: contract.bytecode, gas: gasEstimate } - - let resolver = null - let rejecter = null - - const prom = new Promise((x, y) => { - resolver = x - rejecter = y - }) - - newContract.new(options, function(err, myContract) { - if (err) { - rejecter(err) - } else { - resolver(myContract) - } - }) - - return prom -} diff --git a/specs/NodeConnectionFactory.ts b/specs/NodeConnectionFactory.ts new file mode 100644 index 0000000..c5f5fd8 --- /dev/null +++ b/specs/NodeConnectionFactory.ts @@ -0,0 +1,54 @@ +const ganache = require('ganache-cli') + +export type ConnectionOptions = { + accounts?: Array + debug?: boolean + logger?: object | Function + mnemonic?: string + port?: number + seed?: string + total_accounts?: number + fork?: string + network_id?: number + time?: Date + locked?: boolean + unlocked_accounts?: Array + db_path?: string + account_keys_path?: string + vmErrorsOnRPCResponse?: boolean +} + +export class NodeConnectionFactory { + connectionOptions: ConnectionOptions + + constructor(options?: ConnectionOptions) { + this.assignConnectionOptions() + } + + assignConnectionOptions(options?: ConnectionOptions) { + this.connectionOptions = { + accounts: [ + { + balance: 100012300001 /* gas */, + secretKey: '0x8485898485bbe08a0a9b97fdf695aec8e9f1d196c589d4b6ff50a0232518b682' + } + ], + network_id: 3, + logger: { + log(...args) { + console.log(...args) + } + }, + vmErrorsOnRPCResponse: true, + ...options + } + } + + createProvider() { + return ganache.provider(this.connectionOptions) + } + + createServer() { + return ganache.server(this.connectionOptions) + } +} diff --git a/specs/deployContract.ts b/specs/deployContract.ts new file mode 100644 index 0000000..f18985c --- /dev/null +++ b/specs/deployContract.ts @@ -0,0 +1,33 @@ +export type Artifact = { + abi: object + bytecode: string +} + +export async function deployContract(wallet, name: string, contract: Artifact) { + const account = await wallet.getAccount() + + const newContract = await wallet.getContract(contract.abi) + const gasEstimate = await wallet.estimateGas({ data: contract.bytecode }) + + console.log(`> Will deploy contract ${name} with gas: ${gasEstimate}`) + + const options = { from: account, data: contract.bytecode, gas: gasEstimate } + + let resolver = null + let rejecter = null + + const prom = new Promise((x, y) => { + resolver = x + rejecter = y + }) + + newContract.new(options, function(err, myContract) { + if (err) { + rejecter(err) + } else { + resolver(myContract) + } + }) + + return prom +} diff --git a/specs/ethProviderFactory.tx b/specs/ethProviderFactory.tx new file mode 100644 index 0000000..e69de29 diff --git a/specs/txUtils.spec.ts b/specs/txUtils.spec.ts new file mode 100644 index 0000000..c3a0e96 --- /dev/null +++ b/specs/txUtils.spec.ts @@ -0,0 +1,116 @@ +import { expect } from 'chai' +import { NodeConnectionFactory } from './NodeConnectionFactory' +import { deployContract } from './deployContract' +import { eth, txUtils } from '../dist' + +describe('txUtils tests', () => { + const DEFAULT_FETCH_DELAY = txUtils.TRANSACTION_FETCH_DELAY + const nodeConnectionFactory = new NodeConnectionFactory() + let provider + + before(() => { + provider = nodeConnectionFactory.createProvider() + return eth.connect({ provider }) + }) + + describe('.getTransaction', function() { + it('should return the transaction status and its recepeit', async function() { + this.timeout(100000) + + const contract = await deployContract(eth.wallet, 'MANA', require('./fixtures/MANAToken.json')) + const { recepeit, ...tx } = await txUtils.getTransaction(contract.transactionHash) + + expect(Object.keys(tx)).to.be.deep.equal([ + 'hash', + 'nonce', + 'blockHash', + 'blockNumber', + 'transactionIndex', + 'from', + 'to', + 'value', + 'gas', + 'gasPrice', + 'input' + ]) + expect(tx.hash).to.be.equal('0x505d58d5b6a38304deaad305ff2d773354cc939afc456562ba6bddbbf201e27f') + + expect(Object.keys(recepeit)).to.be.deep.equal([ + 'transactionHash', + 'transactionIndex', + 'blockHash', + 'blockNumber', + 'gasUsed', + 'cumulativeGasUsed', + 'contractAddress', + 'logs', + 'status', + 'logsBloom' + ]) + expect(recepeit.transactionHash).to.be.equal('0x505d58d5b6a38304deaad305ff2d773354cc939afc456562ba6bddbbf201e27f') + }) + + it('should return null if the tx hash is invalid or dropped', async () => { + const invalidTx = await txUtils.getTransaction( + '0xc15c7dda554711eac29d4a983e53aa161dd1bdc6e1d013bb29da1f607916de1' + ) + expect(invalidTx).to.be.equal(null) + + const droppedTx = await txUtils.getTransaction( + '0x24615f57f5754f2479d6657f7ac9a56006d8d6f634c6955310a5af1c79f4969' + ) + expect(droppedTx).to.be.equal(null) + }) + }) + + describe('.isTxDropped', function() { + it('should wait TRANSACTION_FETCH_DELAY for each retry attempts', async function() { + txUtils.TRANSACTION_FETCH_DELAY = 50 + const retryAttemps = 5 + const totalTime = 5 * 50 + + const begining = Date.now() + await txUtils.isTxDropped('0x24615f57f5754f2479d6657f7ac9a56006d8d6f634c6955310a5af1c79f4969', retryAttemps) + const end = Date.now() + const delay = end - begining + + expect(delay).to.be.within(totalTime, totalTime + 100) // give it 100ms of leway + }) + + afterEach(() => { + txUtils.TRANSACTION_FETCH_DELAY = DEFAULT_FETCH_DELAY + }) + }) + + describe('.waitForCompletion', function() { + it('should return a failed tx for a dropped hash', async function() { + txUtils.TRANSACTION_FETCH_DELAY = 10 + + const droppedTx = await txUtils.waitForCompletion( + '0x24615f57f5754f2479d6657f7ac9a56006d8d6f634c6955310a5af1c79f4969' + ) + + expect(droppedTx).to.be.deep.equal({ + hash: '0x24615f57f5754f2479d6657f7ac9a56006d8d6f634c6955310a5af1c79f4969', + status: txUtils.TRANSACTION_STATUS.failed + }) + }) + + it('should return the full transaction after it finishes', async function() { + txUtils.TRANSACTION_FETCH_DELAY = 10 + + // compiled solidity source code + const code = + '603d80600c6000396000f3007c01000000000000000000000000000000000000000000000000000000006000350463c6888fa18114602d57005b6007600435028060005260206000f3' + const txHash = await eth.wallet.sendTransaction({ data: code }) + const tx = await txUtils.waitForCompletion(txHash) + + expect(tx.hash).to.be.equal(txHash) + expect(tx.recepeit).not.to.be.equal(undefined) + }) + + afterEach(() => { + txUtils.TRANSACTION_FETCH_DELAY = DEFAULT_FETCH_DELAY + }) + }) +}) diff --git a/src/ethereum/eth.ts b/src/ethereum/eth.ts index d9fab60..ce135b1 100644 --- a/src/ethereum/eth.ts +++ b/src/ethereum/eth.ts @@ -3,7 +3,6 @@ import { ethUtils } from './ethUtils' import { promisify } from '../utils/index' import { Contract } from './Contract' import { Wallet } from './wallets/Wallet' -import { Abi } from './abi' export type ConnectOptions = { /** An array of objects defining contracts or Contract subclasses to use. Check {@link eth#setContracts} */ @@ -151,30 +150,6 @@ export namespace eth { return contracts[name] } - /** - * Interface for the web3 `getTransaction` method - * @param {string} txId - Transaction id/hash - * @return {object} - An object describing the transaction (if it exists) - */ - export function fetchTxStatus(txId: string): Promise { - return promisify(wallet.getWeb3().eth.getTransaction)(txId) - } - - /** - * Interface for the web3 `getTransactionReceipt` method. It adds the decoded logs to the result (if any) - * @param {string} txId - Transaction id/hash - * @return {object} - An object describing the transaction receipt (if it exists) with it's logs - */ - export async function fetchTxReceipt(txId: string): Promise { - const receipt = await promisify(wallet.getWeb3().eth.getTransactionReceipt)(txId) - - if (receipt) { - receipt.logs = Abi.decodeLogs(receipt.logs) - } - - return receipt - } - export async function sign(payload) { const message = ethUtils.toHex(payload) const signature = await wallet.sign(message) diff --git a/src/ethereum/txUtils.ts b/src/ethereum/txUtils.ts index 677cdca..b63c831 100644 --- a/src/ethereum/txUtils.ts +++ b/src/ethereum/txUtils.ts @@ -6,9 +6,9 @@ import { eth } from './eth' * @namespace */ export namespace txUtils { - export let DUMMY_TX_ID = '0xdeadbeef' + export let DUMMY_TX_ID: string = '0xdeadbeef' - export let TRANSACTION_FETCH_DELAY = 2 * 1000 + export let TRANSACTION_FETCH_DELAY: number = 2 * 1000 export let TRANSACTION_STATUS = Object.freeze({ pending: 'pending', @@ -40,18 +40,45 @@ export namespace txUtils { /** * Wait until a transaction finishes by either being mined or failing * @param {string} txId - Transaction id to watch - * @return {object} data - Current transaction data. See {@link txUtils#getTransaction} + * @param {number} [retriesOnEmpty] - Number of retries when a transaction status returns empty + * @return {Promise} data - Current transaction data. See {@link txUtils#getTransaction} */ - export async function waitForCompletion(txId: string): Promise { + export async function waitForCompletion(txId: string, retriesOnEmpty?: number): Promise { + const isDropped = await isTxDropped(txId, retriesOnEmpty) + if (isDropped) { + return { hash: txId, status: TRANSACTION_STATUS.failed } + } + while (true) { const tx = await getTransaction(txId) - if (isPending(tx) || !tx.recepeit) { - await sleep(TRANSACTION_FETCH_DELAY) - } else { + if (!isPending(tx) && tx.recepeit) { return tx } + + await sleep(TRANSACTION_FETCH_DELAY) + } + } + + /* + * Wait retryAttemps*TRANSACTION_FETCH_DELAY for a transaction status to be in the mempool + * @param {string} txId - Transaction id to watch + * @param {number} [retryAttemps=15] - Number of retries when a transaction status returns empty + * @return {Promise} + */ + export async function isTxDropped(txId: string, retryAttemps: number = 15): Promise { + while (retryAttemps > 0) { + const tx = await getTransaction(txId) + + if (tx !== null) { + return false + } + + retryAttemps -= 1 + await sleep(TRANSACTION_FETCH_DELAY) } + + return true } /** @@ -66,7 +93,7 @@ export namespace txUtils { eth.wallet.getTransactionReceipt(txId) ]) - return { ...tx, recepeit } + return tx ? { ...tx, recepeit } : null } /** diff --git a/src/ethereum/wallets/Wallet.ts b/src/ethereum/wallets/Wallet.ts index f19714b..7614bce 100644 --- a/src/ethereum/wallets/Wallet.ts +++ b/src/ethereum/wallets/Wallet.ts @@ -92,6 +92,10 @@ export abstract class Wallet { return promisify(this.getWeb3().eth.estimateGas)(options) } + async sendTransaction(transactionObject: any) { + return promisify(this.getWeb3().eth.sendTransaction)(transactionObject) + } + async getContract(abi: any[]) { return this.getWeb3().eth.contract(abi) } @@ -105,11 +109,16 @@ export abstract class Wallet { return promisify(this.getWeb3().eth.getTransaction)(txId) } + /** + * Interface for the web3 `getTransactionReceipt` method. It adds the decoded logs to the result (if any) + * @param {string} txId - Transaction id/hash + * @return {object} - An object describing the transaction receipt (if it exists) with it's logs + */ async getTransactionReceipt(txId: string): Promise { const receipt = await promisify(this.getWeb3().eth.getTransactionReceipt)(txId) - if (receipt && receipt['logs']) { - receipt['logs'] = Abi.decodeLogs(receipt['logs']) + if (receipt && receipt.logs) { + receipt.logs = Abi.decodeLogs(receipt.logs) } return receipt