/** * Example A: ETH on Ethereum to PLS on PulseChain. * * 1. Build viem clients from the wallet's own RPC endpoints. * 2. Search with `searchBridgeRoutes` and pick the best served route. * 3. Plan ONE section at a time with `planCandidateExecution` * (`firstSectionIndex`), so a later section spends what actually arrived, * with a fresh swap floor and deadline, or a fresh aggregator quote. * 4. Send each section's transactions, honouring `waitForReceipt`, and wait * for the crossing to arrive before planning the next section. * 5. Poll `fetchTransferStatus` until the last crossing is delivered. * * Typechecked only (`docs/examples/tsconfig.json` in `@gibs/bridge-sdk`). Never run against mainnet. */ import type { AggregatorStatusSource, FetchTransferStatusOptions } from '@gibs/bridge-indexer'; import { tokenBridgeInfo } from '@gibs/bridge-sdk/chain-info'; import { type Provider, Providers, toChain } from '@gibs/bridge-sdk/config'; import type { ConsolidatedQuote } from '@gibs/bridge-sdk/consolidated-quote'; import { type CandidateExecutionPlan, planCandidateExecution, readPulseXSectionQuote, type SectionExecutionInputs, } from '@gibs/bridge-sdk/execution'; import type { PlannedHop, ResolveNodeToken, RoutePlan } from '@gibs/bridge-sdk/routing'; import { fetchLiveSectionQuote, type ServedBridgeRoute, searchBridgeRoutes } from '@gibs/quotes/search'; import { type Hex, type PublicClient, zeroAddress } from 'viem'; import { allowanceReaderWith, createClientLookup, deadlineIn, type RpcUrlByChainId, readBalance, requireClientWith, type SentTransaction, sendPlannedTransactions, type WalletSigner, waitForTransferStatus, } from './shared.js'; const ethereumChainId = 1; const pulsechainChainId = 369; /** What the example needs from the wallet. */ export type EtherToPulseOptions = { readonly signer: WalletSigner; readonly rpcUrlByChainId: RpcUrlByChainId; /** The ether to send, in wei. */ readonly amountWei: bigint; /** Cancels the search, every live quote and every status poll. */ readonly signal: AbortSignal; /** The wallet's own LI.FI key, sent only as the `x-lifi-api-key` header. Leave it out to use LI.FI's keyless tier. */ readonly lifiApiKey?: string; /** The wallet's own Relay key, sent only as a header. Leave it out to use Relay's anonymous tier. */ readonly relayApiKey?: string; }; const omnibridgeCarriers: ReadonlySet = new Set(Object.values(Providers)); const isOmnibridgeCarrier = (carrier: string): carrier is Provider => omnibridgeCarriers.has(carrier); /** A plan's sections, one per signature, each as its hops. */ const sectionsOf = (plan: RoutePlan): readonly (readonly PlannedHop[])[] => plan.signatures.map((group) => group.flatMap((hopIndex) => plan.hops[hopIndex] ?? [])); /** Where `fetchTransferStatus` asks about an aggregator's leg. */ const aggregatorSourceOf = ( quote: ConsolidatedQuote, relayApiKey: string | undefined, ): AggregatorStatusSource => { if (quote.provider === 'lifi') return { provider: 'lifi' }; if (quote.provider === 'relay') { return { provider: 'relay', requestId: quote.quoteId, ...(relayApiKey === undefined ? {} : { apiKey: relayApiKey }), }; } return { provider: 'near-intents', depositAddress: quote.quoteId }; }; /** One section's inputs, and the live quote it signs when an aggregator carries it. */ type SectionPlanInputs = { readonly inputs: SectionExecutionInputs; readonly liveQuote: ConsolidatedQuote | null; }; /** * Builds the inputs for ONE section, from what it spends NOW: the typed * amount for the first section, what actually arrived for a later one. */ const buildSectionInputs = async (options: { readonly served: ServedBridgeRoute; readonly sectionIndex: number; readonly hops: readonly PlannedHop[]; readonly amountIn: bigint; readonly resolveToken: ResolveNodeToken; readonly account: Hex; readonly requireClient: (chainId: number) => PublicClient; readonly clientForChain: (chainId: number) => PublicClient | null; readonly signal: AbortSignal; readonly lifiApiKey: string | undefined; readonly relayApiKey: string | undefined; }): Promise => { const { served, sectionIndex, hops, amountIn, resolveToken, account, requireClient } = options; const firstHop = hops[0]; if (firstHop === undefined) throw new Error(`section ${sectionIndex} has no hops`); // Every section delivers to the signer; this example sends to the signer too. const recipient = account; const { carrier, from, to } = firstHop.edge; if (isOmnibridgeCarrier(carrier)) { // How the token maps across the bridge. Native ether reads the wrapped-ether mapping. const assetLink = await tokenBridgeInfo({ bridgeKey: [carrier, toChain(from.chainId), toChain(to.chainId)], assetIn: resolveToken(from), isProd: true, fromChainClient: requireClient(from.chainId), toChainClient: requireClient(to.chainId), }); if (assetLink === null) throw new Error(`no omnibridge mapping for ${from.address} on chain ${from.chainId}`); // The explicit delivery choice. Entering PulseChain, the bridge itself // executes the crossing, so `shouldDeliver: false` is the only accepted // choice here. Leaving PulseChain, see Example B. const omnibridge = { isProd: true, assetLink, delivery: { shouldDeliver: false }, unwrap: false } as const; return { inputs: { amountIn, recipient, omnibridge }, liveQuote: null }; } if (carrier === 'PulseX') { // A FRESH QUOTE, read now along the exact path the swap signs, for the // amount that actually arrived. The floor is one percent below it, and the // deadline starts now, not before the crossing. const quotedAmountOut = await readPulseXSectionQuote({ hop: firstHop, amountIn, client: requireClient(from.chainId), }); if (quotedAmountOut === null) throw new Error(`section ${sectionIndex}: PulseX gave no quote`); return { inputs: { amountIn, recipient, swap: { slippageBasisPoints: 100n, deadline: deadlineIn(20 * 60), quotedAmountOut } }, liveQuote: null, }; } if (carrier === 'wrap') return { inputs: { amountIn, recipient }, liveQuote: null }; // Any other carrier is an aggregator. It signs only a LIVE quote, asked now // for this wallet, this recipient and this amount. const live = await fetchLiveSectionQuote({ plan: served.plan, sectionIndex, resolveToken, amountIn, account, recipient, clientForChain: options.clientForChain, fetch: (input, init) => globalThis.fetch(input, init), signal: options.signal, keys: { ...(options.lifiApiKey === undefined ? {} : { lifiApiKey: options.lifiApiKey }), ...(options.relayApiKey === undefined ? {} : { relayApiKey: options.relayApiKey }), }, nearIntentsUserAgent: 'your-wallet/1.0', }); if (live.kind !== 'quoted') throw new Error(`section ${sectionIndex}: no live quote (${live.kind})`); return { inputs: { amountIn, recipient, aggregator: { quote: live.quote, request: live.request } }, liveQuote: live.quote }; }; /** Turns a refused plan into an error a person can act on. */ const assertPlanned = ( plan: CandidateExecutionPlan, ): Extract => { if (plan.kind === 'planned') return plan; throw new Error( `section ${plan.sectionIndex} cannot be planned: ${JSON.stringify(plan.refusal, (_key, value) => (typeof value === 'bigint' ? value.toString() : value))}`, ); }; /** * Sends ether from Ethereum and delivers Pulse on PulseChain. * * @param options - see {@link EtherToPulseOptions} * @returns the transactions sent, and the final status of the last crossing */ export const sendEtherToPulse = async (options: EtherToPulseOptions) => { const { signer, signal } = options; const clientForChain = createClientLookup(options.rpcUrlByChainId); const requireClient = requireClientWith(clientForChain); // 1. Search. No attribution and no fee: the wallet names none here. const search = await searchBridgeRoutes({ origin: { chainId: ethereumChainId, token: { address: zeroAddress, decimals: 18, symbol: 'ETH' }, amount: options.amountWei, account: signer.address, }, destination: { chainId: pulsechainChainId, token: { address: zeroAddress, decimals: 18, symbol: 'PLS' }, recipient: signer.address, }, clientForChain, fetch: (input, init) => globalThis.fetch(input, init), signal, timeouts: { requestMs: 15_000, searchMs: 60_000 }, ...(options.lifiApiKey === undefined ? {} : { lifiApiKey: options.lifiApiKey }), ...(options.relayApiKey === undefined ? {} : { relayApiKey: options.relayApiKey }), nearIntentsUserAgent: 'your-wallet/1.0', }); // 2. Pick a route. `routes` is best first, one per provider. const served = search.routes[0]; if (served === undefined) { const why = search.priceImpactRefusal === null ? `${search.refusals.length} refusals` : 'price impact'; throw new Error(`no route can be served (${why})`); } const hopsBySection = sectionsOf(served.plan); const statusOptionsFor = ( crossing: SentTransaction, liveQuote: ConsolidatedQuote | null, ): FetchTransferStatusOptions => ({ signal, originClient: requireClient(crossing.transaction.chainId), ...(liveQuote === null ? {} : { aggregator: aggregatorSourceOf(liveQuote, options.relayApiKey) }), }); const waitForDelivered = (crossing: SentTransaction, liveQuote: ConsolidatedQuote | null) => waitForTransferStatus({ source: { chainId: crossing.transaction.chainId, txHash: crossing.hash }, statusOptions: statusOptionsFor(crossing, liveQuote), isDone: (status) => status.state === 'delivered', intervalMs: 15_000, }); // 3 and 4. One section at a time. The first spends the typed amount; each // later one spends what actually arrived from the one before it. const sent: SentTransaction[] = []; let amountIn = options.amountWei; for (const [sectionIndex, hops] of hopsBySection.entries()) { const { inputs, liveQuote } = await buildSectionInputs({ served, sectionIndex, hops, amountIn, resolveToken: search.resolveToken, account: signer.address, requireClient, clientForChain, signal, lifiApiKey: options.lifiApiKey, relayApiKey: options.relayApiKey, }); const plan = assertPlanned( await planCandidateExecution({ candidate: served.candidate, account: signer.address, readAllowance: allowanceReaderWith(requireClient), resolveToken: search.resolveToken, firstSectionIndex: sectionIndex, sections: [inputs], }), ); // Show `plan.deliveries` and every fee before the user signs. See "Fees and attribution". // What the next section will spend arrives on its own chain. Read the // balance there before this section runs, and again once it arrived. const nextHop = hopsBySection[sectionIndex + 1]?.[0]; const nextToken = nextHop === undefined ? null : search.resolveToken(nextHop.edge.from); const readNextBalance = () => nextToken === null ? Promise.resolve(0n) : readBalance({ client: requireClient(nextToken.chainId), token: nextToken.address, owner: signer.address }); const balanceBefore = await readNextBalance(); // Every transaction of one section is on one chain. const sectionSent = await sendPlannedTransactions({ transactions: plan.transactions, signer, requireClient, waitForArrival: async () => {}, }); sent.push(...sectionSent); const last = sectionSent.at(-1); if (last === undefined) throw new Error(`section ${sectionIndex} planned no transactions`); const receipt = await requireClient(last.transaction.chainId).waitForTransactionReceipt({ hash: last.hash }); if (receipt.status !== 'success') throw new Error(`the transaction ${last.hash} reverted`); const crossesChains = last.transaction.kind === 'bridge'; // 5. The route ends with this section. A crossing is done when delivered; // a swap is done when its receipt succeeds. if (nextToken === null) { return { sent, finalStatus: crossesChains ? await waitForDelivered(last, liveQuote) : null }; } if (crossesChains) await waitForDelivered(last, liveQuote); amountIn = (await readNextBalance()) - balanceBefore; if (amountIn <= 0n) throw new Error(`nothing arrived for section ${sectionIndex + 1}`); } throw new Error('the route had no sections'); };