Skip to content
docs

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.

Terminal window
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-jupiter

The 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'
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

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 ?? ''),
})
}
// 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 allowUnlistedTokens on the engine: see Custom tokens.
  • Omit toChain for a same-chain swap.
  • Amounts are bigint in base units. Use toTokenAmount instead of fromTokenAmount for exact-output swaps.
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.

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.

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.

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.

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.

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
}
}

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.

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')