/** * Shared EVM transaction confirmation via `eth_getTransactionReceipt` + {@link pollLoop}. * * **Use in each domain:** * - **Yield** (multi-step): Prefer {@link Portal.waitForConfirmation} (returns `false` on soft timeout) * or standalone `pollForEvmReceipt` (same engine). * - **LiFi** (`tradeAsset`): Use `onTimeout: 'throw'` with the same RPC `request` as Portal’s * gateway so a stuck tx fails before bridge status polling. * - **Legacy swaps** (`@portal-hq/swaps`): Call `portal.waitForConfirmation(txHash, caip2)` after * `eth_sendTransaction` so “submitted” reflects chain inclusion when the network is known. * * Domain-specific orchestration (LiFi status, Yield multi-tx, route steps) stays outside this module. */ import { sdkLogger } from '@portal-hq/utils' import { pollLoop, PollLoopTimeoutError, type PollTickResult, } from './pollLoop' const LOG_PREFIX = '[Portal.evmReceipt]' /** Shape of the relevant fields in an eth_getTransactionReceipt response. */ interface EvmReceipt { blockNumber: string | null /** '0x1' = success, '0x0' = reverted, absent on pending txs */ status?: string } /** Minimal RPC: any implementation of JSON-RPC `eth_getTransactionReceipt`. */ export type EvmRequestFn = ( method: string, params: unknown[], network: string, ) => Promise export type WaitForEvmTxConfirmationOnTimeout = 'resolve_false' | 'throw' export interface WaitForEvmTxConfirmationOptions { pollIntervalMs?: number timeoutMs?: number /** * Yield / `Portal.waitForConfirmation`: `resolve_false` (optimistic continuation). * LiFi default waiter: `throw` so `tradeAsset` does not poll bridge status after a timed-out tx. * @default 'resolve_false' */ onTimeout?: WaitForEvmTxConfirmationOnTimeout } interface EvmReceiptPollTickContext { txHash: string network: string request: EvmRequestFn pollIntervalMs: number } async function evmReceiptPollTick( ctx: EvmReceiptPollTickContext, ): Promise> { const { txHash, network, request, pollIntervalMs } = ctx try { const raw = await request('eth_getTransactionReceipt', [txHash], network) const receipt = raw as EvmReceipt | null if (receipt?.blockNumber != null) { if (receipt.status === '0x0') { return { kind: 'throw', error: new Error( `${LOG_PREFIX} Transaction ${txHash} was reverted on-chain (status 0x0). ` + 'The transaction did not succeed.', ), } } sdkLogger.debug(`${LOG_PREFIX} receipt confirmed`, { txHash, network, blockNumber: receipt.blockNumber, status: receipt.status, }) return { kind: 'stop', value: true } } sdkLogger.debug(`${LOG_PREFIX} not yet mined, retrying`, { txHash, network }) return { kind: 'continue' } } catch (error) { if (error instanceof Error && error.message.includes('reverted')) { return { kind: 'throw', error } } sdkLogger.warn( `${LOG_PREFIX} transient error polling receipt for ${txHash} (${network}), will retry in ${pollIntervalMs}ms:`, error, ) return { kind: 'continue' } } } /** * Poll until the tx is mined with success (`0x1`), reverted (`0x0` → throw), or timeout. * Pending receipts and transient RPC errors retry with warn logs; logs always include `txHash` / `network` where relevant. */ export async function waitForEvmTxConfirmation( txHash: string, network: string, request: EvmRequestFn, options: WaitForEvmTxConfirmationOptions = {}, ): Promise { const { pollIntervalMs = 4_000, timeoutMs = 300_000, onTimeout = 'resolve_false', } = options sdkLogger.debug(`${LOG_PREFIX} waiting for receipt`, { txHash, network, pollIntervalMs, timeoutMs, onTimeout, }) const tickCtx: EvmReceiptPollTickContext = { txHash, network, request, pollIntervalMs, } try { return await pollLoop({ tick: () => evmReceiptPollTick(tickCtx), intervalMs: pollIntervalMs, initialDelayMs: 0, timeoutMs, }) } catch (error) { if (error instanceof PollLoopTimeoutError) { const msg = `${LOG_PREFIX} timeout after ${timeoutMs}ms waiting for receipt on ${txHash} (${network}).` if (onTimeout === 'throw') { throw new Error( `${msg} Transaction may still confirm; aborting this wait.`, ) } sdkLogger.warn( `${msg} Transaction may still confirm. Proceeding optimistically.`, ) return false } throw error } }