/** * Helpers both wallet examples share: the signer the examples expect, one viem * client per chain from the wallet's own RPC endpoints, an allowance reader, * a sender that honours `waitForReceipt`, and a status poller. * * The packages do not depend on web3.js, so the signer is a small * interface. A web3.js v4 adapter for it is shown in a comment below. * * These files are typechecked only. They are never run against a live chain. */ import { type FetchTransferStatusOptions, fetchTransferStatus, type TransferSource, type TransferStatus, } from '@gibs/bridge-indexer'; import type { AllowanceReader, PlannedTx } from '@gibs/bridge-sdk/execution'; import { type Chain, createPublicClient, erc20Abi, type Hex, http, type PublicClient, zeroAddress } from 'viem'; import { mainnet, pulsechain } from 'viem/chains'; /** One transaction for the wallet to sign and broadcast. */ export type WalletTransactionRequest = { readonly chainId: number; readonly from: Hex; readonly to: Hex; readonly data: Hex; readonly value: bigint; /** Set only when the plan names a gas limit. Otherwise the wallet estimates it. */ readonly gas?: bigint; }; /** * The signer the examples expect. It broadcasts one transaction on the chain * the request names and resolves with the transaction hash, before the * transaction is mined. The examples wait for receipts themselves, through * the viem clients, only where the plan says `waitForReceipt`. * * A web3.js v4 adapter, with one `Web3` instance per chain: * * ```ts * import { Web3 } from 'web3'; * * const web3ByChainId = new Map([[1, ethereumWeb3], [369, pulsechainWeb3]]); * const signer: WalletSigner = { * address: account, * sendTransaction: (request) => * new Promise((resolve, reject) => { * const web3 = web3ByChainId.get(request.chainId); * if (web3 === undefined) return reject(new Error(`no web3 for chain ${request.chainId}`)); * // web3.eth.sendTransaction returns a PromiEvent. Awaiting it resolves * // with the mined receipt; the 'transactionHash' event fires first. * web3.eth * .sendTransaction({ * from: request.from, * to: request.to, * data: request.data, * value: request.value, * ...(request.gas === undefined ? {} : { gas: request.gas }), * }) * .on('transactionHash', (hash) => resolve(hash as Hex)) * .on('error', reject); * }), * }; * ``` */ export type WalletSigner = { readonly address: Hex; readonly sendTransaction: (request: WalletTransactionRequest) => Promise; }; /** The wallet's own RPC endpoint for each chain it supports, by decimal chain id. */ export type RpcUrlByChainId = ReadonlyMap; /** The chains these examples touch. A real wallet lists every chain it supports. */ const viemChainById: ReadonlyMap = new Map([ [mainnet.id, mainnet], [pulsechain.id, pulsechain], ]); /** * Builds one viem public client per chain, from the wallet's own endpoints. * Never the demo endpoints (`demoRpcUrls` in `@gibs/bridge-sdk/chains`). * * @param rpcUrlByChainId - the wallet's endpoint for each chain * @returns a lookup that answers null for a chain the wallet has no endpoint for */ export const createClientLookup = ( rpcUrlByChainId: RpcUrlByChainId, ): ((chainId: number) => PublicClient | null) => { const clients = new Map(); for (const [chainId, url] of rpcUrlByChainId) { const chain = viemChainById.get(chainId); if (chain === undefined) continue; clients.set(chainId, createPublicClient({ chain, transport: http(url) })); } return (chainId) => clients.get(chainId) ?? null; }; /** * Wraps a client lookup so that a missing chain fails fast with a clear message. * * @param clientForChain - the lookup from {@link createClientLookup} * @returns a lookup that throws for a chain with no client */ export const requireClientWith = (clientForChain: (chainId: number) => PublicClient | null) => (chainId: number): PublicClient => { const client = clientForChain(chainId); if (client === null) throw new Error(`the wallet has no RPC endpoint for chain ${chainId}`); return client; }; /** * An allowance reader for `planCandidateExecution`, on the wallet's own clients. * * @param requireClient - see {@link requireClientWith} * @returns the reader */ export const allowanceReaderWith = (requireClient: (chainId: number) => PublicClient): AllowanceReader => (chainId, token, owner, spender) => requireClient(chainId).readContract({ address: token, abi: erc20Abi, functionName: 'allowance', args: [owner, spender], }); /** * Reads what an account holds of one token, or of the native coin when the * token is the zero address. The examples read it before and after a * crossing, so a later section spends what actually arrived. * * @param options.client - a client on the token's chain * @param options.token - the token, or the zero address for the native coin * @param options.owner - the account * @returns the balance, in the token's base units */ export const readBalance = (options: { readonly client: PublicClient; readonly token: string; readonly owner: Hex; }): Promise => { const { client, token, owner } = options; if (token.toLowerCase() === zeroAddress) return client.getBalance({ address: owner }); return client.readContract({ address: token as Hex, abi: erc20Abi, functionName: 'balanceOf', args: [owner] }); }; /** One transaction the sender broadcast, with its hash. */ export type SentTransaction = { readonly transaction: PlannedTx; readonly hash: Hex }; /** * Sends a plan's transactions in order. * * - It waits for a receipt only where the plan sets `waitForReceipt`. * - A receipt is not arrival. When the next transaction is on another chain, * it calls `waitForArrival` with the transaction that started the crossing, * and sends nothing more until that resolves. * * @param options.transactions - the plan's transactions, in plan order * @param options.signer - the wallet's signer * @param options.requireClient - see {@link requireClientWith} * @param options.waitForArrival - resolves once the crossing's funds arrived on the next chain * @returns every transaction sent, with its hash, in order * @throws when a receipt reports a revert */ export const sendPlannedTransactions = async (options: { readonly transactions: readonly PlannedTx[]; readonly signer: WalletSigner; readonly requireClient: (chainId: number) => PublicClient; readonly waitForArrival: (crossing: SentTransaction) => Promise; }): Promise => { const { transactions, signer, requireClient, waitForArrival } = options; const sent: SentTransaction[] = []; for (const [index, transaction] of transactions.entries()) { const hash = await signer.sendTransaction({ chainId: transaction.chainId, from: signer.address, to: transaction.to, data: transaction.data, value: transaction.value, ...(transaction.gasLimit === null ? {} : { gas: transaction.gasLimit }), }); sent.push({ transaction, hash }); if (!transaction.waitForReceipt) continue; const receipt = await requireClient(transaction.chainId).waitForTransactionReceipt({ hash }); if (receipt.status !== 'success') { throw new Error( `${transaction.kind} transaction ${hash} reverted on chain ${transaction.chainId}`, ); } const next = transactions[index + 1]; if (next !== undefined && next.chainId !== transaction.chainId) { await waitForArrival({ transaction, hash }); } } return sent; }; /** * Waits for a time, or rejects when the signal fires. * * @param milliseconds - how long to wait * @param signal - cancels the wait */ export const sleep = (milliseconds: number, signal?: AbortSignal): Promise => new Promise((resolve, reject) => { if (signal?.aborted) return reject(signal.reason); const timer = setTimeout(resolve, milliseconds); signal?.addEventListener( 'abort', () => { clearTimeout(timer); reject(signal.reason); }, { once: true }, ); }); /** A transfer that ended badly. Waiting longer does not change it. */ export class TransferEndedError extends Error { constructor(readonly status: TransferStatus) { super(`the transfer ended as ${status.state}`); this.name = 'TransferEndedError'; } } /** * Polls `fetchTransferStatus` until `isDone` accepts an answer. * * @param options.source - the transaction that started the transfer * @param options.statusOptions - passed through to `fetchTransferStatus` * @param options.isDone - true for the answer the caller waits for * @param options.intervalMs - the pause between polls * @returns the first answer `isDone` accepts * @throws {TransferEndedError} on `failed` or `stranded` */ export const waitForTransferStatus = async (options: { readonly source: TransferSource; readonly statusOptions: FetchTransferStatusOptions; readonly isDone: (status: TransferStatus) => boolean; readonly intervalMs: number; }): Promise => { const { source, statusOptions, isDone, intervalMs } = options; for (;;) { const status = await fetchTransferStatus(source, statusOptions); if (status.state === 'failed' || status.state === 'stranded') throw new TransferEndedError(status); if (isDone(status)) return status; await sleep(intervalMs, statusOptions.signal); } }; /** * The unix-seconds deadline a PulseX swap carries, `seconds` from now. * * @param seconds - how long the swap stays valid * @returns the deadline */ export const deadlineIn = (seconds: number): bigint => BigInt(Math.floor(Date.now() / 1000) + seconds);