viem
Version:
347 lines (319 loc) • 12.5 kB
text/typescript
import type { Client } from '../../clients/createClient.js'
import type { Transport } from '../../clients/transports/createTransport.js'
import { BlockNotFoundError } from '../../errors/block.js'
import {
TransactionNotFoundError,
TransactionReceiptNotFoundError,
WaitForTransactionReceiptTimeoutError,
type WaitForTransactionReceiptTimeoutErrorType,
} from '../../errors/transaction.js'
import type { ErrorType } from '../../errors/utils.js'
import type { Chain } from '../../types/chain.js'
import type { Hash } from '../../types/misc.js'
import type { Transaction } from '../../types/transaction.js'
import { getAction } from '../../utils/getAction.js'
import { type ObserveErrorType, observe } from '../../utils/observe.js'
import { withResolvers } from '../../utils/promise/withResolvers.js'
import {
type WithRetryParameters,
withRetry,
} from '../../utils/promise/withRetry.js'
import { stringify } from '../../utils/stringify.js'
import { type GetBlockErrorType, getBlock } from './getBlock.js'
import {
type GetTransactionErrorType,
type GetTransactionReturnType,
getTransaction,
} from './getTransaction.js'
import {
type GetTransactionReceiptErrorType,
type GetTransactionReceiptReturnType,
getTransactionReceipt,
} from './getTransactionReceipt.js'
import {
type WatchBlockNumberErrorType,
watchBlockNumber,
} from './watchBlockNumber.js'
export type ReplacementReason = 'cancelled' | 'replaced' | 'repriced'
export type ReplacementReturnType<
chain extends Chain | undefined = Chain | undefined,
> = {
reason: ReplacementReason
replacedTransaction: Transaction
transaction: Transaction
transactionReceipt: GetTransactionReceiptReturnType<chain>
}
export type WaitForTransactionReceiptReturnType<
chain extends Chain | undefined = Chain | undefined,
> = GetTransactionReceiptReturnType<chain>
export type WaitForTransactionReceiptParameters<
chain extends Chain | undefined = Chain | undefined,
> = {
/**
* The number of confirmations (blocks that have passed) to wait before resolving.
* @default 1
*/
confirmations?: number | undefined
/** The hash of the transaction. */
hash: Hash
/** Optional callback to emit if the transaction has been replaced. */
onReplaced?: ((response: ReplacementReturnType<chain>) => void) | undefined
/**
* Polling frequency (in ms). Defaults to the client's pollingInterval config.
* @default client.pollingInterval
*/
pollingInterval?: number | undefined
/**
* Number of times to retry if the transaction or block is not found.
* @default 6 (exponential backoff)
*/
retryCount?: WithRetryParameters['retryCount'] | undefined
/**
* Time to wait (in ms) between retries.
* @default `({ count }) => ~~(1 << count) * 200` (exponential backoff)
*/
retryDelay?: WithRetryParameters['delay'] | undefined
/**
* Optional timeout (in milliseconds) to wait before stopping polling.
* @default 180_000
*/
timeout?: number | undefined
}
export type WaitForTransactionReceiptErrorType =
| ObserveErrorType
| GetBlockErrorType
| GetTransactionErrorType
| GetTransactionReceiptErrorType
| WatchBlockNumberErrorType
| WaitForTransactionReceiptTimeoutErrorType
| ErrorType
/**
* Waits for the [Transaction](https://viem.sh/docs/glossary/terms#transaction) to be included on a [Block](https://viem.sh/docs/glossary/terms#block) (one confirmation), and then returns the [Transaction Receipt](https://viem.sh/docs/glossary/terms#transaction-receipt).
*
* - Docs: https://viem.sh/docs/actions/public/waitForTransactionReceipt
* - Example: https://stackblitz.com/github/wevm/viem/tree/main/examples/transactions_sending-transactions
* - JSON-RPC Methods:
* - Polls [`eth_getTransactionReceipt`](https://ethereum.org/en/developers/docs/apis/json-rpc/#eth_getTransactionReceipt) on each block until it has been processed.
* - If a Transaction has been replaced:
* - Calls [`eth_getBlockByNumber`](https://ethereum.org/en/developers/docs/apis/json-rpc/#eth_getblockbynumber) and extracts the transactions
* - Checks if one of the Transactions is a replacement
* - If so, calls [`eth_getTransactionReceipt`](https://ethereum.org/en/developers/docs/apis/json-rpc/#eth_getTransactionReceipt).
*
* The `waitForTransactionReceipt` action additionally supports Replacement detection (e.g. sped up Transactions).
*
* Transactions can be replaced when a user modifies their transaction in their wallet (to speed up or cancel). Transactions are replaced when they are sent from the same nonce.
*
* There are 3 types of Transaction Replacement reasons:
*
* - `repriced`: The gas price has been modified (e.g. different `maxFeePerGas`)
* - `cancelled`: The Transaction has been cancelled (e.g. `value === 0n`)
* - `replaced`: The Transaction has been replaced (e.g. different `value` or `data`)
*
* @param client - Client to use
* @param parameters - {@link WaitForTransactionReceiptParameters}
* @returns The transaction receipt. {@link WaitForTransactionReceiptReturnType}
*
* @example
* import { createPublicClient, waitForTransactionReceipt, http } from 'viem'
* import { mainnet } from 'viem/chains'
*
* const client = createPublicClient({
* chain: mainnet,
* transport: http(),
* })
* const transactionReceipt = await waitForTransactionReceipt(client, {
* hash: '0x4ca7ee652d57678f26e887c149ab0735f41de37bcad58c9f6d3ed5824f15b74d',
* })
*/
export async function waitForTransactionReceipt<
chain extends Chain | undefined,
>(
client: Client<Transport, chain>,
{
confirmations = 1,
hash,
onReplaced,
pollingInterval = client.pollingInterval,
retryCount = 6,
retryDelay = ({ count }) => ~~(1 << count) * 200, // exponential backoff
timeout = 180_000,
}: WaitForTransactionReceiptParameters<chain>,
): Promise<WaitForTransactionReceiptReturnType<chain>> {
const observerId = stringify(['waitForTransactionReceipt', client.uid, hash])
let transaction: GetTransactionReturnType<chain> | undefined
let replacedTransaction: GetTransactionReturnType<chain> | undefined
let receipt: GetTransactionReceiptReturnType<chain>
let retrying = false
const { promise, resolve, reject } =
withResolvers<WaitForTransactionReceiptReturnType<chain>>()
const timer = timeout
? setTimeout(
() => reject(new WaitForTransactionReceiptTimeoutError({ hash })),
timeout,
)
: undefined
const _unobserve = observe(
observerId,
{ onReplaced, resolve, reject },
(emit) => {
const _unwatch = getAction(
client,
watchBlockNumber,
'watchBlockNumber',
)({
emitMissed: true,
emitOnBegin: true,
poll: true,
pollingInterval,
async onBlockNumber(blockNumber_) {
const done = (fn: () => void) => {
clearTimeout(timer)
_unwatch()
fn()
_unobserve()
}
let blockNumber = blockNumber_
if (retrying) return
try {
// If we already have a valid receipt, let's check if we have enough
// confirmations. If we do, then we can resolve.
if (receipt) {
if (
confirmations > 1 &&
(!receipt.blockNumber ||
blockNumber - receipt.blockNumber + 1n < confirmations)
)
return
done(() => emit.resolve(receipt))
return
}
// Get the transaction to check if it's been replaced.
// We need to retry as some RPC Providers may be slow to sync
// up mined transactions.
if (!transaction) {
retrying = true
await withRetry(
async () => {
transaction = (await getAction(
client,
getTransaction,
'getTransaction',
)({ hash })) as GetTransactionReturnType<chain>
if (transaction.blockNumber)
blockNumber = transaction.blockNumber
},
{
delay: retryDelay,
retryCount,
},
)
retrying = false
}
// Get the receipt to check if it's been processed.
receipt = await getAction(
client,
getTransactionReceipt,
'getTransactionReceipt',
)({ hash })
// Check if we have enough confirmations. If not, continue polling.
if (
confirmations > 1 &&
(!receipt.blockNumber ||
blockNumber - receipt.blockNumber + 1n < confirmations)
)
return
done(() => emit.resolve(receipt))
} catch (err) {
// If the receipt is not found, the transaction will be pending.
// We need to check if it has potentially been replaced.
if (
err instanceof TransactionNotFoundError ||
err instanceof TransactionReceiptNotFoundError
) {
if (!transaction) {
retrying = false
return
}
try {
replacedTransaction = transaction
// Let's retrieve the transactions from the current block.
// We need to retry as some RPC Providers may be slow to sync
// up mined blocks.
retrying = true
const block = await withRetry(
() =>
getAction(
client,
getBlock,
'getBlock',
)({
blockNumber,
includeTransactions: true,
}),
{
delay: retryDelay,
retryCount,
shouldRetry: ({ error }) =>
error instanceof BlockNotFoundError,
},
)
retrying = false
const replacementTransaction = (
block.transactions as {} as Transaction[]
).find(
({ from, nonce }) =>
from === replacedTransaction!.from &&
nonce === replacedTransaction!.nonce,
)
// If we couldn't find a replacement transaction, continue polling.
if (!replacementTransaction) return
// If we found a replacement transaction, return it's receipt.
receipt = await getAction(
client,
getTransactionReceipt,
'getTransactionReceipt',
)({
hash: replacementTransaction.hash,
})
// Check if we have enough confirmations. If not, continue polling.
if (
confirmations > 1 &&
(!receipt.blockNumber ||
blockNumber - receipt.blockNumber + 1n < confirmations)
)
return
let reason: ReplacementReason = 'replaced'
if (
replacementTransaction.to === replacedTransaction.to &&
replacementTransaction.value === replacedTransaction.value &&
replacementTransaction.input === replacedTransaction.input
) {
reason = 'repriced'
} else if (
replacementTransaction.from === replacementTransaction.to &&
replacementTransaction.value === 0n
) {
reason = 'cancelled'
}
done(() => {
emit.onReplaced?.({
reason,
replacedTransaction: replacedTransaction! as any,
transaction: replacementTransaction,
transactionReceipt: receipt,
})
emit.resolve(receipt)
})
} catch (err_) {
done(() => emit.reject(err_))
}
} else {
done(() => emit.reject(err))
}
}
},
})
},
)
return promise
}