Swaps and bridges
One interface covers same-chain swaps and cross-chain transfers. A router asks every
provider you install in parallel, checks each quote the same way, ranks the routes and
executes the one you choose. Snippets on this page come from examples/swap, which our CI
typechecks and tests.
Install
Section titled “Install”npm install @anywalletkit/protocol-swap2-router \ @anywalletkit/protocol-swap2-lifi @anywalletkit/protocol-swap2-near-intents \ @anywalletkit/protocol-swap2-thorchain @anywalletkit/protocol-swap2-chainflip \ @anywalletkit/protocol-swap2-cctp \ @anywalletkit/protocol-swap2-usdt0 \ @anywalletkit/protocol-swap2-uniswap @anywalletkit/protocol-swap2-wrap \ @anywalletkit/protocol-swap2-jupiterThe router bundles no provider. Only the providers you install and pass in end up in your app, and each provider also works on its own without the router.
import type { IWalletAccount, Swap2Options } from '@anywalletkit/core'import { RouterSwap2Protocol, type Swap2Route } from '@anywalletkit/protocol-swap2-router'import { lifiProvider } from '@anywalletkit/protocol-swap2-lifi/provider'import { nearIntentsProvider } from '@anywalletkit/protocol-swap2-near-intents/provider'import { thorchainProvider } from '@anywalletkit/protocol-swap2-thorchain/provider'import { chainflipProvider } from '@anywalletkit/protocol-swap2-chainflip/provider'import { jupiterProvider } from '@anywalletkit/protocol-swap2-jupiter/provider'import { cctpProvider } from '@anywalletkit/protocol-swap2-cctp/provider'import { usdt0Provider } from '@anywalletkit/protocol-swap2-usdt0/provider'import { uniswapProvider } from '@anywalletkit/protocol-swap2-uniswap/provider'import { wrapProvider } from '@anywalletkit/protocol-swap2-wrap/provider'import { Swap2FeeLimitError, Swap2InFlightError, Swap2InsufficientGasError, Swap2MaybeSentError, Swap2QuoteError,} from '@anywalletkit/protocol-swap2-core'1. Pick providers
Section titled “1. Pick providers”const RPC = { 1: ['https://ethereum-rpc.publicnode.com'], 8453: ['https://base-rpc.publicnode.com'], 42161: ['https://arbitrum-one-rpc.publicnode.com'],} as const
export function providers() { return [ // Cross-chain venues: their APIs quote and build the route. lifiProvider({}), // apiKey: raises LI.FI's rate limit nearIntentsProvider({}), // jwt: removes 1Click's fee thorchainProvider({}), chainflipProvider(), // native BTC / SOL / TRX and stables; no key, 0 bps cctpProvider({ rpcUrls: { 'eip155:1': RPC[1][0], 'eip155:8453': RPC[8453][0] } }), usdt0Provider({ rpcUrls: { 'eip155:1': RPC[1], 'eip155:42161': RPC[42161] } }), // RPC for each source chain // Same-chain venues priced straight from the contracts: no API, no key. uniswapProvider({ rpcUrls: RPC }), wrapProvider({ rpcUrls: RPC }), // Same-chain Solana: Jupiter's /build instructions, checked and assembled locally; apiKey optional. jupiterProvider({ rpcUrls: ['https://api.mainnet-beta.solana.com'] }), ]}| Provider | Kind | Good at |
|---|---|---|
lifi |
Aggregator API | Same-chain and cross-chain, many bridges and DEXes behind one quote |
near_intents |
Intents (1Click) | Cross-chain between different ecosystems: EVM, Solana, Tron, Bitcoin, NEAR… |
thorchain |
Native cross-chain | Native BTC and other L1 coins |
chainflip |
Native cross-chain (vault swaps) | Native BTC, ETH, SOL, TRX and USDC / USDT; from Ethereum, Arbitrum and Tron to those plus Solana and Bitcoin; no key, 0 bps |
omniston |
STON.fi | TON swaps, TON ↔ EVM and EVM ↔ EVM stablecoin orders |
cctp |
Circle burn-and-mint | USDC between EVM chains, no slippage |
usdt0 |
Tether’s LayerZero OFT | USDT / USDT0 between 16 EVM chains in one transaction; the direct USDT path into HyperEVM |
uniswap, balancer |
On-chain DEX | Same-chain, priced from the contracts, no API key |
wrap |
WETH9 | Native ↔ wrapped native, 1:1 |
jupiter |
Solana aggregator (Metis) | Same-chain Solana swaps; instructions from Jupiter’s /build, checked and assembled locally, no Jupiter fee; key optional |
fynd |
EVM aggregator (Propeller Heads) | Same-chain swaps on Ethereum, Base, Arbitrum, BNB Chain, Polygon, Unichain and Robinhood Chain through the Tycho router; key or your own Fynd server. Its outputs are under the Fynd License: read it before you ship |
2. Create the router
Section titled “2. Create the router”The router is bound to one account: the source of the swap. Its chain is the source chain.
export function swapsFor(account: IWalletAccount): RouterSwap2Protocol { return new RouterSwap2Protocol(account, { fromChain: account.chain, // the source chain is the account's chain providers: providers(), prefer: 'best', // or 'fastest' // Progress for your UI: approving → requoting → preflight → signing → broadcast. onExecutionPhase: (phase, detail) => console.log('swap', phase, detail ?? ''), })}3. Describe the swap
Section titled “3. Describe the swap”// 100 USDC on Ethereum → USDC on Base, delivered to the same address.export async function usdcToBase(account: IWalletAccount): Promise<Swap2Options> { return { fromToken: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // USDC on Ethereum (or the symbol 'USDC') toToken: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', // USDC on Base: the destination chain's own id toChain: 'eip155:8453', fromTokenAmount: 100_000_000n, // 100 USDC, 6 decimals recipient: await account.getAddress(), }}fromToken: a token on the source chain, as its contract address or a catalog symbol ('USDC','ETH').toToken: the token on the destination chain, as that chain’s own id: a contract address, a Solana mint or a TON jetton master. A symbol that does not exist in the catalog of the destination chain leads to “no route”.- Tokens outside the catalog (the user’s own) need
allowUnlistedTokenson the engine: see Custom tokens. - Omit
toChainfor a same-chain swap. - Amounts are
bigintin base units. UsetoTokenAmountinstead offromTokenAmountfor exact-output swaps.
4. Quote
Section titled “4. Quote”export async function bestQuote(swaps: RouterSwap2Protocol, options: Swap2Options) { const quote = await swaps.quoteSwap2(options) // asks every provider in parallel, nothing is signed return { receive: quote.toTokenAmount, // expected output atLeast: quote.toTokenAmountMin, // guaranteed output: show this one seconds: quote.estimatedDuration, fees: quote.fees, // network / protocol / affiliate, each in its own token }}Show the user toTokenAmountMin, not toTokenAmount: it is what the route guarantees.
5. Let the user pick a route
Section titled “5. Let the user pick a route”export async function routePicker( swaps: RouterSwap2Protocol, options: Swap2Options, render: (routes: Swap2Route[], searching: boolean) => void,): Promise<Swap2Route[]> { return swaps.quoteSwap2Routes(options, { // Called each time another provider answers: show the first price at once. onUpdate: (routes, pending) => render(routes, pending.length > 0), })}// route = { provider, quote, netValueUsd, relativeValue, tags: ['best'] | ['fastest'] | … }Routes come back ranked by value. Two of them are tagged:
best: the highest value. Routes within 0.2% of each other count as the same price; inside that band the faster and more reliable route wins.fastest: the lowest estimated time.
For a real USDC Ethereum → Base quote on 100 USDC, this returned four routes: CCTP
tagged best (99.989 USDC guaranteed, ~3 minutes), LI.FI tagged fastest (99.735 USDC,
seconds), then NEAR Intents and THORChain. For 25 USDT from Ethereum to USDT0 on Arbitrum,
USDT0 came out best (exactly 25 guaranteed, ~17 minutes) and LI.FI fastest.
Pass valueUsd: usdValue(prices) to the router to rank on net USD value. Gas and native
bridge fees are then subtracted.
6. Execute
Section titled “6. Execute”export async function execute(swaps: RouterSwap2Protocol, options: Swap2Options, route: Swap2Route) { const result = await swaps .using(route.provider) // exactly the route the user picked .swap2({ ...options, minAmountOut: route.quote.toTokenAmountMin }) // never accept less than you showed return result.id // keep it: status, claims and support all start from this id}Before anything is signed, the router fetches a live quote from the chosen provider and checks it against what you showed:
- the output is not below your
minAmountOut; - the recipient, amounts and chains match the request;
- the transaction sends exactly the quoted amount, and any transfer or approval inside it names only your account, the quoted deposit address or the declared spender;
- aggregator calls go only to that provider’s audited contracts.
A route that fails any check is refused, and nothing is sent. ERC-20 approvals are for the exact amount, never unlimited, and are revoked again if the swap fails after approving. On Ethereum, USDT is reset to zero first, as that token requires.
Show a review screen
Section titled “Show a review screen”export function swapsWithReview(account: IWalletAccount, confirm: (text: string) => Promise<boolean>) { return new RouterSwap2Protocol(account, { fromChain: account.chain, providers: providers(), // Runs after every check passed and BEFORE the first approval or signature. // It receives exactly what will be signed; throwing cancels with nothing sent. reviewExecution: async (plan) => { const ok = await confirm( `${plan.sourceAmount} → at least ${plan.minDestAmount} on ${plan.destChain} via ${plan.provider}, ` + `${plan.approvals.length} approval(s), ~${plan.etaSeconds}s`, ) if (!ok) throw new Error('cancelled by the user') }, })}plan holds exactly what will be signed: provider, amounts, guaranteed output, approvals,
ETA and the source transaction itself.
7. Track delivery
Section titled “7. Track delivery”A cross-chain swap finishes on another chain, minutes later. Poll its status with the id
returned by swap2():
const DONE = new Set(['completed', 'failed', 'refunded', 'cancelled', 'expired', 'partial'])
export async function waitForDelivery( swaps: RouterSwap2Protocol, id: string, destinationAccount: () => Promise<IWalletAccount>, { intervalMs = 10_000, timeoutMs = 2 * 60 * 60_000 }: { intervalMs?: number; timeoutMs?: number } = {},) { const deadline = Date.now() + timeoutMs let claimed = false while (Date.now() < deadline) { const { status, transactions } = await swaps.getSwap2Status(id) if (status === 'action-required' && !claimed) { // Two-phase routes (CCTP): the mint is a transaction on the DESTINATION chain, // signed by the user's account there and paid in that chain's gas. Once: the // provider says 'action-required' until it sees the mint, so keep polling the // id the claim returns (it carries the claim hash) instead of claiming again. id = (await swaps.claimSwap2(id, await destinationAccount())).id claimed = true } else if (DONE.has(status)) { return { status, transactions } } await new Promise((r) => setTimeout(r, intervalMs)) } throw new Error(`Swap ${id} not settled within ${Math.ceil(timeoutMs / 1000)} s: keep its id and check again later.`)}| Status | Meaning |
|---|---|
pending |
In flight |
action-required |
Two-phase route (CCTP): the user must claim on the destination chain |
completed |
Delivered |
refund-pending / refunded |
The provider is returning / has returned the funds |
failed, expired, cancelled, partial |
Final, not delivered in full |
The id is a string (awk1.<provider>.…). Store it with the user’s activity: it is all you
need to resume tracking after an app restart.
8. Handle errors
Section titled “8. Handle errors”export async function swapSafely(swaps: RouterSwap2Protocol, options: Swap2Options) { try { return await swaps.swap2(options) } catch (e) { if (e instanceof Swap2MaybeSentError) { // The broadcast may have reached the network. Do NOT retry: track e.id / e.hash. return { pending: e.id ?? e.hash } } if (e instanceof Swap2InFlightError) return null // the same swap is already running (double tap) if (e instanceof Swap2InsufficientGasError) throw new Error('Not enough native coin for gas') if (e instanceof Swap2FeeLimitError) throw new Error('Fees are above your cap') if (e instanceof Swap2QuoteError) throw new Error('No route right now: ' + e.message) throw e }}Fee caps
Section titled “Fee caps”maxNetworkFeeBps and maxProtocolFeeBps (basis points of the input) refuse routes whose
itemized fees are above the cap. The check is fail-closed: a route whose provider does
not itemize its fees is refused whenever a cap is set. With caps on, the USDC example above
keeps only the CCTP route. To protect the user without losing routes, compare the
guaranteed output (toTokenAmountMin) instead.
With the kit
Section titled “With the kit”Register the router as a protocol and the policies wrap the account it signs with:
import { Wallet } from '@anywalletkit/kit'import { WalletManagerEvm } from '@anywalletkit/wallet-evm'import { RouterSwap2Protocol } from '@anywalletkit/protocol-swap2-router'import { providers } from './swap.ts'
export function walletWithSwaps(seedPhrase: string): Wallet { const wallet = new Wallet(seedPhrase, { // Policies wrap the account the router signs with: swaps cannot skip them. policies: { maxFee: { 'eip155:1': 5_000_000_000_000_000n } }, }) wallet.registerWallet('eip155:1', WalletManagerEvm, { provider: 'https://ethereum-rpc.publicnode.com', chainId: 1 }) wallet.registerProtocol('swap', RouterSwap2Protocol, { chain: 'eip155:1', config: { fromChain: 'eip155:1', providers: providers() }, }) return wallet}
// const swaps = await wallet.getProtocol<RouterSwap2Protocol>('swap')