Skip to main content
Some Passage offers settle directly on-chain through a swap contract: the user pays a stablecoin (USDC) and receives the offer’s tokenized fund share - an ERC-20 - in the same flow. This replaces the approve-then-record pattern from Build the invest flow with a single on-chain swap. The SDK’s coinlist.swap namespace is headless - it drives quoting, wallet authorization, and swap execution, but you bring the wallet by implementing a small EvmWallet interface. This page mirrors partner-demo’s useSwapViewModel.tsx, trimmed to the minimum that still compiles and runs.

Prerequisites

  • Completed OAuth with a working CoinListProvider
  • A swap-enabled offer. CoinList provides the offer ID during onboarding - keep it in a constant (SWAP_OFFER_ID).
    Offers expose a type field ("sale" or "swap"), so you can detect swap offers from fetch offers data via offer.type === "swap" instead of hard-coding IDs.
  • A wallet integration. The partner demo uses wagmi + Reown AppKit; any stack that can sign messages and send transactions works (see Step 2).

Flow at a glance

  1. connect_wallet - your wallet UI; the SDK only needs the connected account.
  2. authorizing - coinlist.swap.authorizeWallet proves the user owns the wallet (a message signature) and allow-lists it for the offer, broadcasting an allow-list transaction if the contract requires one. One-time per wallet per offer.
  3. quoting - useSwapOutputToken + useSwapQuote show a live quote (input, fee, output), refreshed every 15s.
  4. swapping - coinlist.swap.executeSwap runs the ERC-20 approval and then the swap, reporting progress phases.
  5. success / error - show the confirmed output amount and an explorer link, or a readable error.
The flow involves two identities: the CoinList session (from OAuth) identifies who is buying, and the external wallet is where the shares are delivered. authorizeWallet is what links them - which is why the user signs a message before ever swapping.

Step 1: Pin the swap parameters

Keep the offer, chain, contract, and input token in one place. TOKEN_REGISTRY knows the ERC-20 metadata and per-chain address for USDC and USDT.
constants.ts
This page runs on Sepolia throughout. The SDK ships SUPERSTATE_SWAP_CONTRACT_ADDRESS_SEPOLIA; CoinList provides the production mainnet address when swaps go live. To move to mainnet, set SWAP_CHAIN to "ethereum_mainnet" and swap the contract constant - TOKEN_REGISTRY already knows the mainnet USDC/USDT addresses.

Step 2: Adapt your wallet to EvmWallet

The swap flows never import wagmi. They drive any wallet through a five-method EvmWallet interface: address, signMessage, writeContract, broadcastRawTx, and awaitTx. Here’s the adapter for wagmi + AppKit:
wagmiEvmWallet.ts
Let your wallet library’s errors propagate as-is. The SDK catches and classifies them into a typed WalletError (user_rejected, insufficient_funds, contract_reverted, timeout, …), so you never parse wallet errors yourself. See wagmiEvmWallet.ts in the partner demo.

Step 3: Authorize the wallet

Build the wallet from your connected account, then call authorizeWallet. It proves ownership with a signed message and allow-lists the address on the swap contract.
authorizeWallet reports these phases in order - two of them open a wallet popup: checking-authorizationrequesting-challengesigning-message (wallet popup)submitting-signaturebroadcasting-transaction (wallet popup, only if the contract needs an allow-list tx)awaiting-confirmationverifying-authorization It is idempotent: if the wallet is already authorized it returns success immediately without prompting, so you can safely call it every session. Flow-level failures come back as { type: "error" } rather than throwing.

Step 4: Show a live quote

Read the output token to learn its decimals, parse the user’s input, then poll a quote. useSwapQuote skips quoting until it has both the output-token decimals and a valid amount.
A SwapQuote has inputTokenAmount, fee, and outputTokenAmount, all BlockchainAmounts. Render them with formatAmount(amount, LOCALE), and derive the total and the slippage-protected minimum:
The fee is charged on top of the amount that buys shares. The wallet is debited inputTokenAmount + fee, so always show that sum as the total cost.
The quote refreshes every 15s (SWAP_POLL_INTERVAL_MS). isRefreshing flags a background refresh; a failed poll keeps the last quote and retries on the next tick.

Step 5: Execute the swap

executeSwap runs the whole on-chain sequence and returns a discriminated result.
Phases (wallet popups marked): checking-statuschecking-allowance(resetting-allowanceconfirming-allowance-reset, only for stale non-zero allowances)approving (wallet popup)confirming-approvalswapping (wallet popup)confirming-swap
The user signs up to two transactions - an ERC-20 approve for input + fee, then the swap itself. USDT-style tokens with a stale non-zero allowance need one extra reset transaction first. All read-only checks run before the first popup, so the user never signs a transaction that would revert.
slippageBps sets the on-chain minimum-output guard. DEFAULT_SLIPPAGE_BPS is 0.5% and is fine for a minimal integration; see Going further for a picker.

Putting it together

A complete, minimal swap component. It compiles against @coinlist-co/react@0.9.0 and reuses constants.ts (Step 1) and buildEvmWallet (Step 2).
MinimalSwap.tsx
The production-grade version - with balances, a slippage picker, per-step error copy, and a review screen - lives in useSwapViewModel.tsx in the partner demo.

Going further

  • Balances and a Max button - useSwapTokenBalances({ address, chain, assets: [USDC_SYMBOL], enabled }) returns a Map of per-asset balances so you can show the user’s USDC and prefill the max amount.
  • A slippage picker - SLIPPAGE_OPTIONS_BPS (0.25%–5%) with formatBpsAsPercent(bps, LOCALE) for labels; pass the selected Bps to executeSwap.
  • More formatting helpers in @coinlist-co/react/shared: shortenAddress, formattedPricePerShare.

Common questions

The flow has two identities: the OAuth CoinList session is who is buying, and the external wallet is where shares are delivered. authorizeWallet links them - the user signs an ownership challenge, and the SDK allow-lists that address on the swap contract. It’s one-time per wallet per offer, and returns success immediately on later calls if the wallet is already authorized.
A swap needs an ERC-20 approve (for input + fee) before the swap itself, so the contract can pull the stablecoin. USDT-style tokens that already have a stale non-zero allowance need one extra reset-to-zero transaction first. Every read-only check (status, allowance) runs before the first popup, so the user never signs a transaction that would revert.
useSwapQuote polls every 15s. On execution, executeSwap passes a slippage-derived minimum output to the contract, so if the price moves beyond your slippageBps the swap reverts rather than over-charging. The confirmed outputAmount in the result is decoded from the on-chain Swapped event, not the quote estimate.
No. Anything implementing the five-method EvmWallet interface works - viem directly, Privy, ethers with a thin adapter. The SDK only calls signMessage, writeContract, broadcastRawTx, and awaitTx; it never sees your wallet library.
This page uses Sepolia everywhere. CoinList provides the production swap contract address and swap offer ID during onboarding. Set SWAP_CHAIN to "ethereum_mainnet", point SWAP_CONTRACT_ADDRESS at the mainnet contract, and the wallet adapter’s CHAINS map handles the network switch.

Next step

See the full swap implementation

The partner demo’s swap feature adds balances, slippage selection, a review screen, and typed error handling on top of this flow.