Added initial support for detecting replacement transactions (#1477).

This commit is contained in:
Richard Moore 2021-05-13 23:28:47 -04:00
parent aadc5cd3d6
commit 5144acf456
No known key found for this signature in database
GPG Key ID: 665176BE8E9DC651
2 changed files with 135 additions and 28 deletions

@ -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",
};

@ -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<TransactionReceipt> {
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<TransactionReceipt> {
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 = <TransactionResponse>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) {
(<any>error).transaction = tx;
(<any>error).transactionHash = tx.hash;