> ## Documentation Index
> Fetch the complete documentation index at: https://docs.passage.coinlist.co/llms.txt
> Use this file to discover all available pages before exploring further.

# Build the swap flow

> Wallet authorization, live on-chain quotes, and executeSwap - buy tokenized fund shares end-to-end, as built in the partner demo.

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](/sdk/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`](https://github.com/coinlist/partner-demo/blob/main/src/features/swap/useSwapViewModel.tsx), trimmed to the minimum that still compiles and runs.

## Prerequisites

* Completed [OAuth](/sdk/oauth-authentication) with a working `CoinListProvider`
* A **swap-enabled offer**. CoinList provides the offer ID during onboarding - keep it in a constant (`SWAP_OFFER_ID`).
  <Note>
    Offers expose a `type` field (`"sale"` or `"swap"`), so you can detect swap offers from [fetch offers](/sdk/fetch-offers) data via `offer.type === "swap"` instead of hard-coding IDs.
  </Note>
* A wallet integration. The partner demo uses [wagmi](https://wagmi.sh/) + [Reown AppKit](https://reown.com/appkit); any stack that can sign messages and send transactions works (see Step 2).

```bash theme={null}
npm install @coinlist-co/react wagmi @wagmi/core viem @reown/appkit @reown/appkit-adapter-wagmi @tanstack/react-query
```

## Flow at a glance

```mermaid theme={null}
flowchart LR
  connect[connect_wallet]
  authorize[authorizing]
  quote[quoting]
  swapping[swapping]
  success[success]
  error[error]

  connect -->|wallet connected| authorize
  authorize -->|sign challenge + allow-list tx| quote
  authorize -->|rejected / not authorized| error
  quote -->|user confirms amount| swapping
  swapping -->|approve + swap mined| success
  swapping -->|rejected / reverted| error
  error -->|retry| connect
```

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.

<Note>
  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.
</Note>

## 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.

```ts constants.ts theme={null}
import {
  type EthereumChain,
  OfferId,
  SUPERSTATE_SWAP_CONTRACT_ADDRESS_SEPOLIA,
  TOKEN_REGISTRY,
  USDC_SYMBOL,
} from "@coinlist-co/react/shared";

// Provided by CoinList during onboarding.
export const SWAP_OFFER_ID = OfferId("YOUR_SWAP_OFFER_ID");

export const SWAP_CHAIN: EthereumChain = "ethereum_sepolia";
export const SWAP_CONTRACT_ADDRESS = SUPERSTATE_SWAP_CONTRACT_ADDRESS_SEPOLIA;

// The stablecoin the user pays with (USDC, 6 decimals).
export const INPUT_TOKEN = TOKEN_REGISTRY.erc20(USDC_SYMBOL);
export const INPUT_TOKEN_ADDRESS = TOKEN_REGISTRY.contractAddress(
  USDC_SYMBOL,
  SWAP_CHAIN,
);

// BCP-47 locale used for amount formatting.
export const LOCALE = "en-US";
```

<Note>
  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.
</Note>

## 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:

```ts wagmiEvmWallet.ts theme={null}
import type { EvmWallet } from "@coinlist-co/react";
import type {
  EthereumChain,
  EvmWalletAddress,
} from "@coinlist-co/react/shared";
import {
  type Config,
  getChainId,
  switchChain,
  waitForTransactionReceipt,
} from "@wagmi/core";
import { type Chain, encodeFunctionData, type WalletClient } from "viem";
import { mainnet, sepolia } from "viem/chains";

const CHAINS: Record<EthereumChain, Chain> = {
  ethereum_mainnet: mainnet,
  ethereum_sepolia: sepolia,
};

export function buildEvmWallet({
  address,
  walletClient,
  config,
}: {
  address: EvmWalletAddress;
  walletClient: WalletClient;
  config: Config;
}): EvmWallet {
  const account = address as `0x${string}`;

  // Each flow call passes the target chain; switch the wallet there first.
  const ensureChain = async (chain: EthereumChain): Promise<Chain> => {
    const target = CHAINS[chain];
    if (getChainId(config) !== target.id) {
      await switchChain(config, { chainId: target.id });
    }
    return target;
  };

  return {
    address,
    signMessage: (message) => walletClient.signMessage({ account, message }),
    async writeContract({ abi, address: to, functionName, args, value, chain }) {
      const targetChain = await ensureChain(chain);
      const data = encodeFunctionData({ abi, functionName, args });
      return walletClient.sendTransaction({
        account,
        chain: targetChain,
        to,
        data,
        value,
      });
    },
    async broadcastRawTx({ to, data, chain }) {
      const targetChain = await ensureChain(chain);
      return walletClient.sendTransaction({ account, chain: targetChain, to, data });
    },
    awaitTx: (hash, chain) =>
      waitForTransactionReceipt(config, { hash, chainId: CHAINS[chain].id }),
  };
}
```

<Note>
  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`](https://github.com/coinlist/partner-demo/blob/main/src/features/swap/wagmiEvmWallet.ts) in the partner demo.
</Note>

## 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.

```tsx theme={null}
const wallet = buildEvmWallet({
  address: EvmWalletAddress(address),
  walletClient,
  config,
});

const result = await coinlist.swap.authorizeWallet({
  wallet,
  offerId: SWAP_OFFER_ID,
  contractAddress: SWAP_CONTRACT_ADDRESS,
  chain: SWAP_CHAIN,
  onProgress: (phase) => setBusy({ kind: "authorize", phase }),
});

if (result.type === "success") {
  setAuthorized(true);
} else {
  // result.error.step: "authorization-check" | "challenge-request" | "signing"
  //   | "allow-wallet" | "broadcast" | "not-authorized"
  setError("Could not authorize this wallet.");
}
```

`authorizeWallet` reports these phases in order - two of them open a wallet popup:

`checking-authorization` → `requesting-challenge` → **`signing-message`** *(wallet popup)* → `submitting-signature` → **`broadcasting-transaction`** *(wallet popup, only if the contract needs an allow-list tx)* → `awaiting-confirmation` → `verifying-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.

```tsx theme={null}
const { outputTokenState } = useSwapOutputToken({
  contractAddress: SWAP_CONTRACT_ADDRESS,
  chain: SWAP_CHAIN,
  enabled: true,
});
const outputToken =
  outputTokenState.type === "CONTENT" ? outputTokenState.outputToken : null;

const parsed = parseBlockchainAmount(amountInput, INPUT_TOKEN.decimals);
const hasAmount = parsed.valid && parsed.amount.raw > 0n;
const inputAmount =
  parsed.valid && parsed.amount.raw > 0n
    ? parsed.amount
    : BlockchainAmount({ raw: 0n, decimals: INPUT_TOKEN.decimals });

const { quote, isRefreshing } = useSwapQuote({
  contractAddress: SWAP_CONTRACT_ADDRESS,
  chain: SWAP_CHAIN,
  inputAmount,
  inputTokenAddress: INPUT_TOKEN_ADDRESS,
  outputTokenDecimals: outputToken?.decimals ?? null,
  enabled: authorized && hasAmount,
});
```

A `SwapQuote` has `inputTokenAmount`, `fee`, and `outputTokenAmount`, all `BlockchainAmount`s. Render them with `formatAmount(amount, LOCALE)`, and derive the total and the slippage-protected minimum:

```tsx theme={null}
const total = BlockchainAmount.add(quote.inputTokenAmount, quote.fee);
const minReceived = computeSlip(quote.outputTokenAmount, DEFAULT_SLIPPAGE_BPS);
```

<Note>
  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.
</Note>

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.

```tsx theme={null}
const result = await coinlist.swap.executeSwap({
  wallet,
  contractAddress: SWAP_CONTRACT_ADDRESS,
  chain: SWAP_CHAIN,
  inputTokenAddress: INPUT_TOKEN_ADDRESS,
  quote,
  slippageBps: DEFAULT_SLIPPAGE_BPS,
  onProgress: (phase) => setBusy({ kind: "swap", phase }),
});

if (result.type === "success") {
  // result: swapTxHash, inputAmount, fee, outputAmount, pricePerShare, recipientAddress
  const received = `${formatAmount(result.outputAmount, LOCALE)} ${outputToken.symbol}`;
  const explorerUrl = txExplorerUrl(SWAP_CHAIN, result.swapTxHash);
} else {
  // result.error.step: "status-check" | "swap-stopped" | "allowance-check"
  //   | "approval" | "approval-reverted" | "swap" | "swap-reverted"
  setError("The swap did not go through.");
}
```

Phases (wallet popups marked):

`checking-status` → `checking-allowance` → *(`resetting-allowance` → `confirming-allowance-reset`, only for stale non-zero allowances)* → **`approving`** *(wallet popup)* → `confirming-approval` → **`swapping`** *(wallet popup)* → `confirming-swap`

<Note>
  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.
</Note>

`slippageBps` sets the on-chain minimum-output guard. `DEFAULT_SLIPPAGE_BPS` is 0.5% and is fine for a minimal integration; see [Going further](#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).

```tsx MinimalSwap.tsx theme={null}
"use client";

import {
  type SwapExecutionPhase,
  useCoinList,
  useSwapOutputToken,
  useSwapQuote,
  type WalletAuthorizationPhase,
} from "@coinlist-co/react";
import {
  BlockchainAmount,
  computeSlip,
  DEFAULT_SLIPPAGE_BPS,
  EvmWalletAddress,
  formatAmount,
  parseBlockchainAmount,
  txExplorerUrl,
} from "@coinlist-co/react/shared";
import { useAppKit, useAppKitAccount } from "@reown/appkit/react";
import { useEffect, useState } from "react";
import { useConfig, useWalletClient } from "wagmi";
import {
  INPUT_TOKEN,
  INPUT_TOKEN_ADDRESS,
  LOCALE,
  SWAP_CHAIN,
  SWAP_CONTRACT_ADDRESS,
  SWAP_OFFER_ID,
} from "./constants";
import { buildEvmWallet } from "./wagmiEvmWallet";

type Busy =
  | { kind: "authorize"; phase: WalletAuthorizationPhase }
  | { kind: "swap"; phase: SwapExecutionPhase };

const GENERIC_ERROR = "Something went wrong. Please try again.";

export function MinimalSwap() {
  const { coinlist, isReady } = useCoinList();
  const { open } = useAppKit();
  const { address, isConnected } = useAppKitAccount();
  const { data: walletClient } = useWalletClient();
  const config = useConfig();

  const [authorized, setAuthorized] = useState(false);
  const [amountInput, setAmountInput] = useState("");
  const [busy, setBusy] = useState<Busy | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [confirmed, setConfirmed] = useState<{
    received: string;
    explorerUrl: string;
  } | null>(null);

  // The output token (the fund share) tells us the decimals needed to quote.
  const { outputTokenState } = useSwapOutputToken({
    contractAddress: SWAP_CONTRACT_ADDRESS,
    chain: SWAP_CHAIN,
    enabled: true,
  });
  const outputToken =
    outputTokenState.type === "CONTENT" ? outputTokenState.outputToken : null;

  // Quoting stays disabled until the wallet is authorized, the output token is
  // known, and the amount is a valid non-zero value.
  const parsed = parseBlockchainAmount(amountInput, INPUT_TOKEN.decimals);
  const hasAmount = parsed.valid && parsed.amount.raw > 0n;
  const inputAmount =
    parsed.valid && parsed.amount.raw > 0n
      ? parsed.amount
      : BlockchainAmount({ raw: 0n, decimals: INPUT_TOKEN.decimals });

  const { quote, isRefreshing } = useSwapQuote({
    contractAddress: SWAP_CONTRACT_ADDRESS,
    chain: SWAP_CHAIN,
    inputAmount,
    inputTokenAddress: INPUT_TOKEN_ADDRESS,
    outputTokenDecimals: outputToken?.decimals ?? null,
    enabled: authorized && hasAmount,
  });

  // If the connected account changes, the prior authorization no longer applies.
  useEffect(() => {
    setAuthorized(false);
  }, [address]);

  const buildWallet = () => {
    if (!walletClient || !address) return null;
    return buildEvmWallet({
      address: EvmWalletAddress(address),
      walletClient,
      config,
    });
  };

  const handleAuthorize = async () => {
    const wallet = buildWallet();
    if (!isReady || !wallet) return;
    setError(null);
    setBusy({ kind: "authorize", phase: "checking-authorization" });
    try {
      const result = await coinlist.swap.authorizeWallet({
        wallet,
        offerId: SWAP_OFFER_ID,
        contractAddress: SWAP_CONTRACT_ADDRESS,
        chain: SWAP_CHAIN,
        onProgress: (phase) => setBusy({ kind: "authorize", phase }),
      });
      if (result.type === "success") setAuthorized(true);
      else setError(GENERIC_ERROR);
    } catch {
      setError(GENERIC_ERROR);
    } finally {
      setBusy(null);
    }
  };

  const handleSwap = async () => {
    const wallet = buildWallet();
    if (!isReady || !wallet || !quote) return;
    setError(null);
    setBusy({ kind: "swap", phase: "checking-status" });
    try {
      const result = await coinlist.swap.executeSwap({
        wallet,
        contractAddress: SWAP_CONTRACT_ADDRESS,
        chain: SWAP_CHAIN,
        inputTokenAddress: INPUT_TOKEN_ADDRESS,
        quote,
        slippageBps: DEFAULT_SLIPPAGE_BPS,
        onProgress: (phase) => setBusy({ kind: "swap", phase }),
      });
      if (result.type === "success" && outputToken) {
        setConfirmed({
          received: `${formatAmount(result.outputAmount, LOCALE)} ${outputToken.symbol}`,
          explorerUrl: txExplorerUrl(SWAP_CHAIN, result.swapTxHash),
        });
      } else {
        setError(GENERIC_ERROR);
      }
    } catch {
      setError(GENERIC_ERROR);
    } finally {
      setBusy(null);
    }
  };

  if (outputTokenState.type === "ERROR") {
    return <p>Swaps are unavailable right now. Please try again later.</p>;
  }

  if (confirmed) {
    return (
      <div>
        <h3>Swap complete</h3>
        <p>You received {confirmed.received}.</p>
        <a href={confirmed.explorerUrl} target="_blank" rel="noreferrer">
          View transaction
        </a>
      </div>
    );
  }

  if (!isConnected || !address) {
    return <button onClick={() => open()}>Connect wallet</button>;
  }

  if (!authorized) {
    return (
      <div>
        <button onClick={handleAuthorize} disabled={busy !== null || !walletClient}>
          Authorize wallet
        </button>
        {busy?.kind === "authorize" && <p>{authorizePhaseLabel(busy.phase)}</p>}
        {error && <p role="alert">{error}</p>}
      </div>
    );
  }

  const total = quote
    ? BlockchainAmount.add(quote.inputTokenAmount, quote.fee)
    : null;

  return (
    <div>
      <input
        inputMode="decimal"
        placeholder={`Amount in ${INPUT_TOKEN.symbol}`}
        value={amountInput}
        onChange={(e) => {
          if (e.target.value === "" || /^\d*\.?\d*$/.test(e.target.value)) {
            setAmountInput(e.target.value);
          }
        }}
      />

      {quote && total && outputToken ? (
        <dl>
          <dt>You pay</dt>
          <dd>
            {formatAmount(quote.inputTokenAmount, LOCALE)} {INPUT_TOKEN.symbol}
          </dd>
          <dt>CoinList fee</dt>
          <dd>
            {formatAmount(quote.fee, LOCALE)} {INPUT_TOKEN.symbol}
          </dd>
          <dt>Total (charged to your wallet)</dt>
          <dd>
            {formatAmount(total, LOCALE)} {INPUT_TOKEN.symbol}
          </dd>
          <dt>You receive</dt>
          <dd>
            {formatAmount(quote.outputTokenAmount, LOCALE)} {outputToken.symbol}
          </dd>
          <dt>Minimum received</dt>
          <dd>
            {formatAmount(
              computeSlip(quote.outputTokenAmount, DEFAULT_SLIPPAGE_BPS),
              LOCALE,
            )}{" "}
            {outputToken.symbol}
          </dd>
        </dl>
      ) : hasAmount ? (
        <p>Fetching quote…</p>
      ) : null}

      <button
        onClick={handleSwap}
        disabled={!isReady || !quote || !walletClient || busy !== null}
      >
        Swap
      </button>

      {isRefreshing && <span> Refreshing quote…</span>}
      {busy?.kind === "swap" && <p>{swapPhaseLabel(busy.phase)}</p>}
      {error && <p role="alert">{error}</p>}
    </div>
  );
}

function authorizePhaseLabel(phase: WalletAuthorizationPhase): string {
  switch (phase) {
    case "signing-message":
      return "Sign the message in your wallet…";
    case "broadcasting-transaction":
      return "Confirm the transaction in your wallet…";
    default:
      return "Authorizing…";
  }
}

function swapPhaseLabel(phase: SwapExecutionPhase): string {
  switch (phase) {
    case "approving":
      return `Approve ${INPUT_TOKEN.symbol} in your wallet…`;
    case "swapping":
      return "Confirm the swap in your wallet…";
    default:
      return "Working…";
  }
}
```

The production-grade version - with balances, a slippage picker, per-step error copy, and a review screen - lives in [`useSwapViewModel.tsx`](https://github.com/coinlist/partner-demo/blob/main/src/features/swap/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

<AccordionGroup>
  <Accordion title="Why does the user sign a message before swapping?">
    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.
  </Accordion>

  <Accordion title="Why does my wallet ask for two transactions?">
    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.
  </Accordion>

  <Accordion title="How fresh is the quote, and what if the price moves?">
    `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.
  </Accordion>

  <Accordion title="Do I need wagmi / Reown AppKit specifically?">
    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.
  </Accordion>

  <Accordion title="How do I go to mainnet?">
    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.
  </Accordion>
</AccordionGroup>

## Next step

<Card title="See the full swap implementation" icon="github" href="https://github.com/coinlist/partner-demo/tree/main/src/features/swap" horizontal>
  The partner demo's swap feature adds balances, slippage selection, a review screen, and typed error handling on top of this flow.
</Card>
