Repository navigation
Description
Problem
Transaction workflows need to stop waiting when a component unmounts, a user starts another operation, or the owning request ends. In v2, waitForTransactionReceipt exposes a timeout but no cancellation option or teardown handle. Racing an abort promise only stops awaiting the result; it does not stop the underlying receipt wait.
client.scope({ signal }) aborts the RPC requests, but an active receipt wait can remain pending until its timeout and retain its receipt/block-number observers. With timeout: 0, there is no deadline to clean them up.
Verified on main at 9bdccb06c55782bd5e88b36d4529a43d83ae30e2 (source package 2.57.16). A local HTTP reproduction retained two observers after abort. Network requests stopped for a scoped client, while the receipt promise remained pending and eventually rejected with WaitForTransactionReceiptTimeoutError.
Consumer workflows
- Worldcoin MiniKit aborts on unmount/reset and checks the signal before and after
client.waitForTransactionReceipt, but cannot interrupt that wait. - Phala Cloud races cancellation against its own receipt polling loop, built from Viem receipt/block lookups. The losing polling loop is not cancelled.
Reproduction
Run as an ES module with Viem v2 that includes client.scope. The local JSON-RPC peer keeps the transaction pending, so no chain or keys are needed.
import { createServer } from 'node:http'
import { createPublicClient, http } from 'viem'
const server = createServer(async (request, response) => {
let text = ''
for await (const chunk of request) text += chunk
const { id, method } = JSON.parse(text)
response.setHeader('content-type', 'application/json')
response.end(JSON.stringify({
id,
jsonrpc: '2.0',
result: method === 'eth_blockNumber' ? '0x1' : null,
}))
})
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
const controller = new AbortController()
const client = createPublicClient({
pollingInterval: 20,
transport: http(`http://127.0.0.1:${server.address().port}`),
}).scope({ signal: controller.signal })
const started = Date.now()
setTimeout(() => controller.abort(), 100)
try {
await client.waitForTransactionReceipt({
hash: `0x${'ab'.repeat(32)}`,
checkReplacement: false,
timeout: 750,
})
} catch (error) {
console.log(error.name, Date.now() - started)
// WaitForTransactionReceiptTimeoutError around 750ms, rather than an abort.
} finally {
server.closeAllConnections()
await new Promise(resolve => server.close(resolve))
}Proposed v2 behavior
Add requestOptions.signal to waitForTransactionReceipt, using the existing call action's option naming:
await client.waitForTransactionReceipt({
hash,
requestOptions: { signal: controller.signal },
})Aborting would reject with the signal's reason, remove the wait's observers, abort scoped requests, and cancel retry backoffs. It would not cancel the submitted transaction or interfere with an independent wait for the same hash. Calls without the new option would retain their existing behavior.
The implementation can reuse client.scope, getAbortError, and withRetry({ signal }), with no new dependencies. A disposable prototype passed seven lifecycle checks and a strict public-client TypeScript fixture.
Design decision
Related: #1701 discussed cancellation and the requestOptions shape; #5258 introduced scoped clients. V3 already returns a receipt watcher with .off(), so this is specifically a v2 proposal, not a claim that v3 lacks teardown.
Would you accept this capability in v2, or prefer that callers migrate to the v3 watcher API? The proposed type supports only signal in the options bag rather than silently accepting other unsupported request overrides.
Activity