Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add transaction replacement detection logic #7368

Draft
wants to merge 2 commits into
base: 4.x
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npx lint-staged
#npx lint-staged
1 change: 1 addition & 0 deletions packages/web3-core/src/web3_context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ export class Web3Context<
protected _subscriptionManager: Web3SubscriptionManager<API, RegisteredSubs>;
protected _accountProvider?: Web3AccountProvider<Web3BaseWalletAccount>;
protected _wallet?: Web3BaseWallet<Web3BaseWalletAccount>;
public watchReplacement: any;

public constructor(
providerOrContext?:
Expand Down
15 changes: 15 additions & 0 deletions packages/web3-errors/src/errors/transaction_errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
Bytes,
HexString,
Numbers,
TransactionHash,
TransactionReceipt,
Web3ValidationErrorObject,
} from 'web3-types';
Expand Down Expand Up @@ -159,6 +160,20 @@ export class TransactionRevertWithCustomError<
}
}

export class TransactionReplacementError extends TransactionError {
public hash: TransactionHash;
public replacedHash: TransactionHash;
public constructor(hash: TransactionHash, replacedHash: TransactionHash) {
super(`The transaction ${hash} was replaced with ${replacedHash}`);
this.hash = hash;
this.replacedHash = replacedHash;
}

public toJSON() {
return { ...super.toJSON(), hash: this.hash, replacedHash: this.replacedHash };
}
}

export class NoContractAddressFoundError extends TransactionError {
public constructor(receipt: TransactionReceipt) {
super("The transaction receipt didn't contain a contract address.", receipt);
Expand Down
20 changes: 19 additions & 1 deletion packages/web3-eth/src/rpc_method_wrappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ import { Web3Context, Web3PromiEvent } from 'web3-core';
import { format, hexToBytes, bytesToUint8Array, numberToHex } from 'web3-utils';
import { TransactionFactory } from 'web3-eth-accounts';
import { isBlockTag, isBytes, isNullish, isString } from 'web3-validator';
import { SignatureError } from 'web3-errors';
import { SignatureError, TransactionReplacementError } from 'web3-errors';
import { ethRpcMethods } from 'web3-rpc-methods';

import { decodeSignedTransaction } from './utils/decode_signed_transaction.js';
Expand All @@ -66,6 +66,7 @@ import {
SignatureObjectSchema,
} from './schemas.js';
import {
ReplacedEvent,
SendSignedTransactionEvents,
SendSignedTransactionOptions,
SendTransactionEvents,
Expand All @@ -82,6 +83,7 @@ import { waitForTransactionReceipt } from './utils/wait_for_transaction_receipt.
import { NUMBER_DATA_FORMAT } from './constants.js';
// eslint-disable-next-line import/no-cycle
import { SendTxHelper } from './utils/send_tx_helper.js';
import { WatchReplacement } from './watch_replacement.js';

/**
* View additional documentations here: {@link Web3Eth.getProtocolVersion}
Expand Down Expand Up @@ -581,6 +583,9 @@ export function sendTransaction<
options: SendTransactionOptions<ResolveType> = { checkRevertBeforeSending: true },
transactionMiddleware?: TransactionMiddleware,
): Web3PromiEvent<ResolveType, SendTransactionEvents<ReturnFormat>> {
if (!web3Context.watchReplacement) {
web3Context.watchReplacement = new WatchReplacement(web3Context);
}
const promiEvent = new Web3PromiEvent<ResolveType, SendTransactionEvents<ReturnFormat>>(
(resolve, reject) => {
setImmediate(() => {
Expand Down Expand Up @@ -638,6 +643,17 @@ export function sendTransaction<
wallet,
tx: transactionFormatted,
});
const replacedErrorCallback = ({ hash, replacedHash }: ReplacedEvent) => {
console.log('replacedErrorCallback', hash, replacedHash);
throw new TransactionReplacementError(hash, replacedHash);
};
web3Context.watchReplacement.watch(
promiEvent,
transactionFormatted as Transaction,
transactionHash,
);

promiEvent.on('replaced', replacedErrorCallback);

const transactionHashFormatted = format(
{ format: 'bytes32' },
Expand All @@ -654,6 +670,8 @@ export function sendTransaction<
transactionHash,
returnFormat ?? web3Context.defaultReturnFormat,
);
promiEvent.off('replaced', replacedErrorCallback);
web3Context.watchReplacement.stop(transactionHash);

const transactionReceiptFormatted = sendTxHelper.getReceiptWithEvents(
format(
Expand Down
7 changes: 7 additions & 0 deletions packages/web3-eth/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,17 @@ import {
TransactionWithFromAndToLocalWalletIndex,
TransactionWithFromLocalWalletIndex,
TransactionWithToLocalWalletIndex,
TransactionHash,
} from 'web3-types';
import { Schema } from 'web3-validator';

export type InternalTransaction = FormatType<Transaction, typeof ETH_DATA_FORMAT>;

export type ReplacedEvent = {
hash: TransactionHash;
replacedHash: TransactionHash;
};

export type SendTransactionEventsBase<ReturnFormat extends DataFormat, TxType> = {
sending: FormatType<TxType, typeof ETH_DATA_FORMAT>;
sent: FormatType<TxType, typeof ETH_DATA_FORMAT>;
Expand All @@ -51,6 +57,7 @@ export type SendTransactionEventsBase<ReturnFormat extends DataFormat, TxType> =
receipt: FormatType<TransactionReceipt, ReturnFormat>;
latestBlockHash: FormatType<Bytes, ReturnFormat>;
};
replaced: FormatType<ReplacedEvent, ReturnFormat>;
error:
| TransactionRevertedWithoutReasonError<FormatType<TransactionReceipt, ReturnFormat>>
| TransactionRevertInstructionError<FormatType<TransactionReceipt, ReturnFormat>>
Expand Down
32 changes: 32 additions & 0 deletions packages/web3-eth/src/utils/wait_for_transaction_receipt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,38 @@ import { pollTillDefinedAndReturnIntervalId, rejectIfTimeout } from 'web3-utils'
import { rejectIfBlockTimeout } from './reject_if_block_timeout.js';
// eslint-disable-next-line import/no-cycle
import { getTransactionReceipt } from '../rpc_method_wrappers.js';
// @ts-expect-error
export function pollNewBlocks(web3Context: Web3Context<EthExecutionAPI>, pollInterval = 10000) {
// let latestBlockNumber = BigInt(0);
// console.log(pollInterval);
// setImmediate(async () => {
// latestBlockNumber = BigInt(await ethRpcMethods.getBlockNumber(web3Context.requestManager));
// eventEmitter.emit('data', { number: latestBlockNumber });
// });
// const eventEmitter = new EventEmitter();
// const interval = setInterval(async () => {
// try {
// const currentBlockNumber = BigInt(
// await ethRpcMethods.getBlockNumber(web3Context.requestManager),
// );
// if (currentBlockNumber > latestBlockNumber) {
// eventEmitter.emit('data', { number: currentBlockNumber });
// }
// } catch (error) {
// console.error('Error fetching new blocks:', error);
// }
// }, pollInterval);

return {
// @ts-expect-error
on: async (name: string, fn: (blockNumber: bigint) => void) => undefined, // eventEmitter.on(name, fn),
unsubscribe: () => {
console.log('Unsubscribing from new blocks');
// eventEmitter.removeAllListeners();
// clearInterval(interval);
},
};
}

export async function waitForTransactionReceipt<ReturnFormat extends DataFormat>(
web3Context: Web3Context<EthExecutionAPI>,
Expand Down
163 changes: 163 additions & 0 deletions packages/web3-eth/src/watch_replacement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/*
This file is part of web3.js.

web3.js is free software: you can redistribute it and/or modify
it under the terms of the GNU Lesser General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

web3.js is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Lesser General Public License for more details.

You should have received a copy of the GNU Lesser General Public License
along with web3.js. If not, see <http://www.gnu.org/licenses/>.
*/

// Disabling because returnTypes must be last param to match 1.x params
/* eslint-disable default-param-last */

import {
Address,
Numbers,
Transaction,
TransactionHash,
BlockHeaderOutput,
TransactionInfo,
EthExecutionAPI,
} from 'web3-types';
import { toHex } from 'web3-utils';
import { ethRpcMethods } from 'web3-rpc-methods';
import { Web3PromiEvent, Web3Context } from 'web3-core';
import { NewHeadsSubscription } from './web3_subscriptions.js';

/**
*
* The Web3Eth allows you to interact with an Ethereum blockchain.
*
* For using Web3 Eth functions, first install Web3 package using `npm i web3` or `yarn add web3` based on your package manager usage.
* After that, Web3 Eth functions will be available as mentioned in following snippet.
* ```ts
* import { Web3 } from 'web3';
* const web3 = new Web3('https://mainnet.infura.io/v3/<YOURPROJID>');
*
* const block = await web3.eth.getBlock(0);
*
* ```
*
* For using individual package install `web3-eth` package using `npm i web3-eth` or `yarn add web3-eth` and only import required functions.
* This is more efficient approach for building lightweight applications.
* ```ts
* import { Web3Eth } from 'web3-eth';
*
* const eth = new Web3Eth('https://mainnet.infura.io/v3/<YOURPROJID>');
* const block = await eth.getBlock(0);
*
* ```
*/
export class WatchReplacement {
private readonly web3Context: Web3Context<EthExecutionAPI>;
private headSubscription: NewHeadsSubscription | undefined;
private headSubscriptionTimeout: NodeJS.Timeout | undefined;
private replacementSupscriptions: {
[key: string]: { nonce: Numbers; from: Address };
};
public constructor(web3Context: Web3Context<EthExecutionAPI>) {
this.web3Context = web3Context;
this.headSubscription = undefined;
this.replacementSupscriptions = {};
}

async getSubscription(): Promise<NewHeadsSubscription> {
if (!this.headSubscription) {
try {
this.headSubscription = await this.web3Context.subscriptionManager.subscribe(
'newHeads',
);
} catch (e) {
// this.headSubscription = pollNewBlocks(
// this,
// 10,
// ) as unknown as NewHeadsSubscription;
}
}
return this.headSubscription!;
}
async offSubscription() {
if (!this.headSubscription) {
return;
}
if (this.headSubscriptionTimeout) {
clearTimeout(this.headSubscriptionTimeout);
}

this.headSubscriptionTimeout = setTimeout(async () => {
this.headSubscription?.unsubscribe();
}, 1000);
}
public async watch(
promiEvent: Web3PromiEvent<any, any>,
tx: Transaction,
transactionHash: TransactionHash,
) {
if (!this.headSubscription) {
return;
}
if (!tx.nonce && tx.from) {
tx.nonce = await ethRpcMethods.getTransactionCount(
this.web3Context.requestManager,
tx.from,
'latest',
);
}
this.replacementSupscriptions[transactionHash] = { from: tx.from!, nonce: tx.nonce! };

(await this.getSubscription()).on('data', async (blockHeader: BlockHeaderOutput) => {
if (blockHeader.number) {
const block = await ethRpcMethods.getBlockByNumber(
this.web3Context.requestManager,
toHex(blockHeader.number),
true,
);
const txes = Object.keys(this.replacementSupscriptions);
for (const blockTx of block.transactions as TransactionInfo[]) {
for (const transactionHash of txes) {
const txData = this.replacementSupscriptions[transactionHash];
console.log(
`${blockTx.from} === ${txData.from} && ${blockTx.nonce} === ${txData.nonce}`,
);
if (blockTx.from === txData.from && blockTx.nonce === txData.nonce) {
if (blockTx.hash !== transactionHash) {
promiEvent.emit('replaced', {
hash: transactionHash,
replacedHash: String(blockTx.hash),
});
}
await this.stop(transactionHash);
}
}
console.log(
`${blockTx.from} === ${tx.from} && ${blockTx.nonce} === ${tx.nonce}`,
);
if (blockTx.from === tx.from && blockTx.nonce === tx.nonce) {
if (blockTx.hash !== transactionHash) {
promiEvent.emit('replaced', {
hash: transactionHash,
replacedHash: String(blockTx.hash),
});
}
await this.stop(transactionHash);
}
}
}
});
}
public async stop(transactionHash: string): Promise<void> {
console.log('stopWatchingForReplacement', transactionHash);
delete this.replacementSupscriptions[transactionHash];
if (Object.keys(this.replacementSupscriptions).length === 0) {
await this.offSubscription();
}
}
}
Loading
Loading