/** * Example B: an ERC-20 token from PulseChain to Ethereum, with delivery off. * * 1. Read how the token maps across the bridge, and search for the route. * 2. Plan the direct crossing with `shouldDeliver: false`: an exact approval, * then `relayTokens`. * 3. Poll `fetchTransferStatus` until the crossing is `claimable`. * 4. Read the bridge request with `fetchClaimRecords` and build the claim * with `buildClaimTransaction`, handling every typed refusal. * 5. Send the claim on Ethereum, then poll until the transfer is delivered. * * Typechecked only (`docs/examples/tsconfig.json` in `@gibs/bridge-sdk`). Never run against mainnet. */ import { type ClaimRecord, fetchClaimRecords, type TransferStatus } from '@gibs/bridge-indexer'; import { tokenBridgeInfo } from '@gibs/bridge-sdk/chain-info'; import { buildClaimTransaction, type ClaimRefusal } from '@gibs/bridge-sdk/claims'; import { Chains, Providers } from '@gibs/bridge-sdk/config'; import { planCandidateExecution } from '@gibs/bridge-sdk/execution'; import type { BridgeKey, Token } from '@gibs/bridge-sdk/types'; import { searchBridgeRoutes } from '@gibs/quotes/search'; import type { Hex } from 'viem'; import { allowanceReaderWith, createClientLookup, type RpcUrlByChainId, requireClientWith, sendPlannedTransactions, sleep, type WalletSigner, waitForTransferStatus, } from './shared.js'; const pulsechainChainId = 369; /** PulseX (PLSX), an ERC-20 token native to PulseChain. */ const plsx: Token = { chainId: pulsechainChainId, address: '0x95B303987A60C71504D99Aa1b13B4DA07b0790ab', decimals: 18, symbol: 'PLSX', name: 'PulseX', logoURI: null, }; const pulsechainToEthereum: BridgeKey = [Providers.PULSECHAIN, Chains.PLS, Chains.ETH]; /** What the example needs from the wallet. */ export type TokenToEthereumOptions = { readonly signer: WalletSigner; readonly rpcUrlByChainId: RpcUrlByChainId; /** The amount to send, in the token's base units. */ readonly amount: bigint; /** Cancels the search and every poll. */ readonly signal: AbortSignal; }; /** A claim the wallet must not send, and must not retry by waiting. */ export class ClaimRefusedError extends Error { constructor(readonly refusal: ClaimRefusal) { super(`the claim was refused: ${refusal.reason}`); this.name = 'ClaimRefusedError'; } } /** * Reads the one bridge request a crossing transaction started. * `fetchClaimRecords` asks the indexer for exactly the fields a claim reads, * lowercases the hash (the indexer matches hex in lowercase only), and never * reads a relation the indexer leaves null. * * @param options.txHash - the crossing transaction on PulseChain * @param options.signal - cancels the request * @returns the record, in the shape `buildClaimTransaction` takes * @throws on a network or GraphQL failure, or when the transaction started other than one request */ const readClaimRecord = async (options: { readonly txHash: Hex; readonly signal: AbortSignal; }): Promise => { const records = await fetchClaimRecords({ source: { chainId: pulsechainChainId, txHash: options.txHash }, signal: options.signal, }); const [record] = records; if (record === undefined || records.length !== 1) { throw new Error(`expected one bridge request for ${options.txHash}, found ${records.length}`); } return record; }; /** What to do with a refused claim. */ type RefusalAction = | { readonly kind: 'done' } | { readonly kind: 'wait' } | { readonly kind: 'stop'; readonly error: Error }; /** * Decides what each typed refusal means for the wallet. The switch is * exhaustive: a new refusal reason fails the typecheck here. * * @param refusal - the refusal from `buildClaimTransaction` * @returns the action */ const actionForRefusal = (refusal: ClaimRefusal): RefusalAction => { switch (refusal.reason) { case 'already-delivered': // Somebody executed the release already. Nothing to send. return { kind: 'done' }; case 'not-signed-yet': // The validators are still signing. Ask again later. return { kind: 'wait' }; case 'signatures-incomplete': // The indexer stored fewer signatures than the bridge needs (see // "Known limits": the submitSignature case). Waiting does not fix it. // Tell the user, and report the transfer to Gibs. return { kind: 'stop', error: new ClaimRefusedError(refusal) }; case 'wrong-type': // Not a signature request. The validators execute an affirmation themselves. return { kind: 'stop', error: new ClaimRefusedError(refusal) }; case 'malformed-request': // A field is not the shape the indexer documents. Report it. return { kind: 'stop', error: new ClaimRefusedError(refusal) }; default: { const unhandled: never = refusal; return { kind: 'stop', error: new Error(`unhandled refusal ${JSON.stringify(unhandled)}`) }; } } }; /** * Sends an ERC-20 token from PulseChain to Ethereum and claims it there by hand. * * @param options - see {@link TokenToEthereumOptions} * @returns the crossing hash, the claim hash (null when somebody else released it), and the final status */ export const sendTokenToEthereum = async ( options: TokenToEthereumOptions, ): Promise<{ readonly crossingHash: Hex; readonly claimHash: Hex | null; readonly finalStatus: TransferStatus; }> => { const { signer, signal } = options; const clientForChain = createClientLookup(options.rpcUrlByChainId); const requireClient = requireClientWith(clientForChain); const pulsechainClient = requireClient(pulsechainChainId); const ethereumClient = requireClient(Number(Chains.ETH)); // 1. How PLSX maps onto Ethereum. A null `assetOutAddress` means the token // has not crossed before; the first transfer creates it. The search needs // a destination address, so this example stops there. const assetLink = await tokenBridgeInfo({ bridgeKey: pulsechainToEthereum, assetIn: plsx, isProd: true, fromChainClient: pulsechainClient, toChainClient: ethereumClient, }); if (assetLink === null || assetLink.assetOutAddress === null) { throw new Error('PLSX has no bridged address on Ethereum yet'); } const search = await searchBridgeRoutes({ origin: { chainId: pulsechainChainId, token: plsx, amount: options.amount, account: signer.address, }, destination: { chainId: Number(Chains.ETH), token: { address: assetLink.assetOutAddress, decimals: plsx.decimals, symbol: plsx.symbol }, recipient: signer.address, }, clientForChain, fetch: (input, init) => globalThis.fetch(input, init), signal, }); // The direct crossing: one section, one omnibridge hop. const crossing = search.routes.find( ({ plan }) => plan.signatures.length === 1 && plan.hops[0]?.edge.carrier === Providers.PULSECHAIN, ); if (crossing === undefined) throw new Error('the search served no direct crossing for this token'); // 2. Plan it. Delivery is off: nobody is paid to release it on Ethereum, so // the user claims it there and pays the Ethereum gas. const plan = await planCandidateExecution({ candidate: crossing.candidate, account: signer.address, readAllowance: allowanceReaderWith(requireClient), resolveToken: search.resolveToken, sections: [ { amountIn: options.amount, recipient: signer.address, omnibridge: { isProd: true, assetLink, delivery: { shouldDeliver: false }, unwrap: false }, }, ], }); if (plan.kind === 'refused') throw new Error(`cannot plan the crossing: ${plan.refusal.reason}`); // plan.transactions is [approve (when the allowance is short), bridge], both on PulseChain. const sent = await sendPlannedTransactions({ transactions: plan.transactions, signer, requireClient, // Every transaction is on PulseChain, so this is never called. waitForArrival: async () => {}, }); const bridge = sent.find(({ transaction }) => transaction.kind === 'bridge'); if (bridge === undefined) throw new Error('the plan held no bridge transaction'); const source = { chainId: pulsechainChainId, txHash: bridge.hash } as const; const statusOptions = { signal, originClient: pulsechainClient } as const; // 3. Wait until the validators have signed. `claimable` is true in both // ready states. `delivered` means somebody released it already. const ready = await waitForTransferStatus({ source, statusOptions, isDone: (status) => status.claimable || status.state === 'delivered', intervalMs: 30_000, }); if (ready.state === 'delivered') return { crossingHash: bridge.hash, claimHash: null, finalStatus: ready }; // 4. Build the claim from the indexer's record. Retry only on `not-signed-yet`. for (;;) { const request = await readClaimRecord({ txHash: bridge.hash, signal }); // Delivery was off, so the claim goes straight to the destination bridge and pays no tip. const claim = buildClaimTransaction({ request, via: { kind: 'direct' } }); if (claim.kind === 'refused') { const action = actionForRefusal(claim.refusal); if (action.kind === 'stop') throw action.error; if (action.kind === 'done') { const finalStatus = await waitForTransferStatus({ source, statusOptions, isDone: (status) => status.state === 'delivered', intervalMs: 15_000, }); return { crossingHash: bridge.hash, claimHash: null, finalStatus }; } await sleep(30_000, signal); continue; } // 5. Send the claim on Ethereum (`claim.transaction.chainId` is 1). const [claimSent] = await sendPlannedTransactions({ transactions: [claim.transaction], signer, requireClient, waitForArrival: async () => {}, }); if (claimSent === undefined) throw new Error('the claim was not sent'); const receipt = await requireClient(claim.transaction.chainId).waitForTransactionReceipt({ hash: claimSent.hash, }); if (receipt.status !== 'success') throw new Error(`the claim ${claimSent.hash} reverted`); const finalStatus = await waitForTransferStatus({ source, statusOptions, isDone: (status) => status.state === 'delivered', intervalMs: 15_000, }); return { crossingHash: bridge.hash, claimHash: claimSent.hash, finalStatus }; } };