From 5144acf456b51c95bbe3950bd37609abecc7ebc7 Mon Sep 17 00:00:00 2001 From: Richard Moore Date: Thu, 13 May 2021 23:28:47 -0400 Subject: [PATCH] Added initial support for detecting replacement transactions (#1477). --- packages/logger/src.ts/index.ts | 1 + packages/providers/src.ts/base-provider.ts | 162 +++++++++++++++++---- 2 files changed, 135 insertions(+), 28 deletions(-) diff --git a/packages/logger/src.ts/index.ts b/packages/logger/src.ts/index.ts index d480ce6eb..1fe31fbbd 100644 --- a/packages/logger/src.ts/index.ts +++ b/packages/logger/src.ts/index.ts @@ -144,6 +144,7 @@ export enum ErrorCode { // - cancelled: true if reason == "cancelled" or reason == "replaced") // - hash: original transaction hash // - replacement: the full TransactionsResponse for the replacement + // - receipt: the receipt of the replacement TRANSACTION_REPLACED = "TRANSACTION_REPLACED", }; diff --git a/packages/providers/src.ts/base-provider.ts b/packages/providers/src.ts/base-provider.ts index a320a3324..3c5af9908 100644 --- a/packages/providers/src.ts/base-provider.ts +++ b/packages/providers/src.ts/base-provider.ts @@ -24,7 +24,6 @@ const logger = new Logger(version); import { Formatter } from "./formatter"; - ////////////////////////////// // Event Serializeing @@ -925,8 +924,10 @@ export class BaseProvider extends Provider implements EnsProvider { } async waitForTransaction(transactionHash: string, confirmations?: number, timeout?: number): Promise { - if (confirmations == null) { confirmations = 1; } + return this._waitForTransaction(transactionHash, (confirmations == null) ? 1: confirmations, timeout || 0, null); + } + async _waitForTransaction(transactionHash: string, confirmations: number, timeout: number, replaceable: { data: string, from: string, nonce: number, to: string, value: BigNumber, startBlock: number }): Promise { const receipt = await this.getTransactionReceipt(transactionHash); // Receipt is already good @@ -934,31 +935,128 @@ export class BaseProvider extends Provider implements EnsProvider { // Poll until the receipt is good... return new Promise((resolve, reject) => { - let timer: NodeJS.Timer = null; + const cancelFuncs: Array<() => void> = []; + let done = false; - - const handler = (receipt: TransactionReceipt) => { - if (receipt.confirmations < confirmations) { return; } - - if (timer) { clearTimeout(timer); } - if (done) { return; } + const alreadyDone = function() { + if (done) { return true; } done = true; + cancelFuncs.forEach((func) => { func(); }); + return false; + }; - this.removeListener(transactionHash, handler); + const minedHandler = (receipt: TransactionReceipt) => { + if (receipt.confirmations < confirmations) { return; } + if (alreadyDone()) { return; } resolve(receipt); } - this.on(transactionHash, handler); + this.on(transactionHash, minedHandler); + cancelFuncs.push(() => { this.removeListener(transactionHash, minedHandler); }); + + if (replaceable) { + let lastBlockNumber = replaceable.startBlock; + let scannedBlock: number = null; + const replaceHandler = async (blockNumber: number) => { + if (done) { return; } + + // Wait 1 second; this is only used in the case of a fault, so + // we will trade off a little bit of latency for more consistent + // results and fewer JSON-RPC calls + await stall(1000); + + this.getTransactionCount(replaceable.from).then(async (nonce) => { + if (done) { return; } + + if (nonce <= replaceable.nonce) { + lastBlockNumber = blockNumber; + + } else { + // First check if the transaction was mined + { + const mined = await this.getTransaction(transactionHash); + if (mined && mined.blockNumber != null) { return; } + } + + // First time scanning. We start a little earlier for some + // wiggle room here to handle the eventually consistent nature + // of blockchain (e.g. the getTransactionCount was for a + // different block) + if (scannedBlock == null) { + scannedBlock = lastBlockNumber - 3; + if (scannedBlock < replaceable.startBlock) { + scannedBlock = replaceable.startBlock; + } + } + + while (scannedBlock <= blockNumber) { + if (done) { return; } + + const block = await this.getBlockWithTransactions(scannedBlock); + for (let ti = 0; ti < block.transactions.length; ti++) { + const tx = block.transactions[ti]; + + // Successfully mined! + if (tx.hash === transactionHash) { return; } + + // Matches our transaction from and nonce; its a replacement + if (tx.from === replaceable.from && tx.nonce === replaceable.nonce) { + if (done) { return; } + + // Get the receipt of the replacement + const receipt = await this.waitForTransaction(tx.hash, confirmations); + + // Already resolved or rejected (prolly a timeout) + if (alreadyDone()) { return; } + + // The reason we were replaced + let reason = "replaced"; + if (tx.data === replaceable.data && tx.to === replaceable.to && tx.value.eq(replaceable.value)) { + reason = "repriced"; + } else if (tx.data === "0x" && tx.from === tx.to && tx.value.isZero()) { + reason = "cancelled" + } + + // Explain why we were replaced + reject(logger.makeError("transaction was replaced", Logger.errors.TRANSACTION_REPLACED, { + cancelled: (reason === "replaced" || reason === "cancelled"), + reason, + replacement: this._wrapTransaction(tx), + hash: transactionHash, + receipt + })); + + return; + } + } + scannedBlock++; + } + } + + if (done) { return; } + this.once("block", replaceHandler); + + }, (error) => { + if (done) { return; } + this.once("block", replaceHandler); + }); + }; + + if (done) { return; } + this.once("block", replaceHandler); + + cancelFuncs.push(() => { + this.removeListener("block", replaceHandler); + }); + } if (typeof(timeout) === "number" && timeout > 0) { - timer = setTimeout(() => { - if (done) { return; } - timer = null; - done = true; - - this.removeListener(transactionHash, handler); + const timer = setTimeout(() => { + if (alreadyDone()) { return; } reject(logger.makeError("timeout exceeded", Logger.errors.TIMEOUT, { timeout: timeout })); }, timeout); if (timer.unref) { timer.unref(); } + + cancelFuncs.push(() => { clearTimeout(timer); }); } }); } @@ -1054,7 +1152,7 @@ export class BaseProvider extends Provider implements EnsProvider { } // This should be called by any subclass wrapping a TransactionResponse - _wrapTransaction(tx: Transaction, hash?: string): TransactionResponse { + _wrapTransaction(tx: Transaction, hash?: string, startBlock?: number): TransactionResponse { if (hash != null && hexDataLength(hash) !== 32) { throw new Error("invalid response - sendTransaction"); } const result = tx; @@ -1064,18 +1162,25 @@ export class BaseProvider extends Provider implements EnsProvider { logger.throwError("Transaction hash mismatch from Provider.sendTransaction.", Logger.errors.UNKNOWN_ERROR, { expectedHash: tx.hash, returnedHash: hash }); } - // @TODO: (confirmations? number, timeout? number) - result.wait = async (confirmations?: number) => { + result.wait = async (confirms?: number, timeout?: number) => { + if (confirms == null) { confirms = 1; } + if (timeout == null) { timeout = 0; } - // We know this transaction *must* exist (whether it gets mined is - // another story), so setting an emitted value forces us to - // wait even if the node returns null for the receipt - if (confirmations !== 0) { - this._emitted["t:" + tx.hash] = "pending"; + // Get the details to detect replacement + let replacement = undefined; + if (confirms !== 0 && startBlock != null) { + replacement = { + data: tx.data, + from: tx.from, + nonce: tx.nonce, + to: tx.to, + value: tx.value, + startBlock + }; } - const receipt = await this.waitForTransaction(tx.hash, confirmations) - if (receipt == null && confirmations === 0) { return null; } + const receipt = await this._waitForTransaction(tx.hash, confirms, timeout, replacement); + if (receipt == null && confirms === 0) { return null; } // No longer pending, allow the polling loop to garbage collect this this._emitted["t:" + tx.hash] = receipt.blockNumber; @@ -1097,9 +1202,10 @@ export class BaseProvider extends Provider implements EnsProvider { await this.getNetwork(); const hexTx = await Promise.resolve(signedTransaction).then(t => hexlify(t)); const tx = this.formatter.transaction(signedTransaction); + const blockNumber = await this._getInternalBlockNumber(100 + 2 * this.pollingInterval); try { const hash = await this.perform("sendTransaction", { signedTransaction: hexTx }); - return this._wrapTransaction(tx, hash); + return this._wrapTransaction(tx, hash, blockNumber); } catch (error) { (error).transaction = tx; (error).transactionHash = tx.hash;