Skip to content
This repository has been archived by the owner on Oct 12, 2021. It is now read-only.

Commit

Permalink
Added initial support for detecting replacement transactions (ethers-…
Browse files Browse the repository at this point in the history
  • Loading branch information
ricmoo authored and pull[bot] committed Jun 4, 2021
1 parent 738907e commit a1b5f7b
Show file tree
Hide file tree
Showing 2 changed files with 134 additions and 27 deletions.
1 change: 1 addition & 0 deletions packages/logger/src.ts/index.ts
Expand Up @@ -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",
};

Expand Down
160 changes: 133 additions & 27 deletions packages/providers/src.ts/base-provider.ts
Expand Up @@ -24,7 +24,6 @@ const logger = new Logger(version);

import { Formatter } from "./formatter";


//////////////////////////////
// Event Serializeing

Expand Down Expand Up @@ -925,40 +924,139 @@ 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
if ((receipt ? receipt.confirmations: 0) >= confirmations) { return receipt; }

// Poll until the receipt is good...
return new Promise((resolve, reject) => {
let timer: NodeJS.Timer = null;
const cancelFuncs: Array<() => void> = [];

let done = false;
const alreadyDone = function() {
if (done) { return true; }
done = true;
cancelFuncs.forEach((func) => { func(); });
return false;
};

const handler = (receipt: TransactionReceipt) => {
const minedHandler = (receipt: TransactionReceipt) => {
if (receipt.confirmations < confirmations) { return; }
if (alreadyDone()) { return; }
resolve(receipt);
}
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 (timer) { clearTimeout(timer); }
if (done) { return; }
done = true;
this.once("block", replaceHandler);

this.removeListener(transactionHash, handler);
resolve(receipt);
cancelFuncs.push(() => {
this.removeListener("block", replaceHandler);
});
}
this.on(transactionHash, handler);

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); });
}
});
}
Expand Down Expand Up @@ -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;
Expand All @@ -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) => {

// 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";
result.wait = async (confirms?: number, timeout?: number) => {
if (confirms == null) { confirms = 1; }
if (timeout == null) { timeout = 0; }

// 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;
Expand All @@ -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;
Expand Down

0 comments on commit a1b5f7b

Please sign in to comment.