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

# Passage by CoinList Integration

> Use a Para-powered wallet to buy and sell tokenized real-world assets including US equities, yield products, vaults, and more from providers like Ondo, Superstate, and more through the Passage SDK.

This walkthrough explains how to integrate [Passage](https://docs.passage.coinlist.co/?utm_source=para_docs\&utm_medium=referral\&utm_content=intro) with a Para embedded wallet in a React application. Passage provides an SDK that lets platforms offer tokenized real-world assets and token sales to their users through a single integration, with everything you may need like KYC, wallet screening, eligibility, disclosures, doc signing, order routing, and settlement handled for you.

The assets come from Passage's partners: tokenized equities from Superstate, a broad catalog of tokenized stocks, treasuries and commodities from Ondo, and token sales run by CoinList, with many more assets and asset partners coming. Please refer to the [asset catalog](https://docs.passage.coinlist.co/assets/catalog?utm_source=para_docs\&utm_medium=referral\&utm_content=catalog) in the docs for a full list of available assets and offers at any given time.

Asset and asset partners have different models and may have different requirements. Equities, yield, etc. differ between providers. Some may be available with no KYC, some are restricted in the U.S. and some are available globally. Some are instant swaps and others are vaults. Everything is available under a single integration, but what you offer and how is totally up to you.

By default, Passage ships **no wallet stack**. It is not coupled to Privy, Turnkey, AppKit, wagmi, or anything else, and it never asks a user to connect. Instead it exposes one small interface, `EvmWallet`, and drives whatever you hand it. That is the whole of the Para side of this integration. Write one adapter that turns Para's viem account into an `EvmWallet` and every Passage on-chain flow works: Ondo buys and sells, Superstate swaps, token-sale approvals, wallet ownership proofs, etc.

## Overview

A Passage integration has three parts, and only the middle one is Para-specific:

1. **A Passage session.** The user signs in through OAuth 2.0 with PKCE. This identifies *who* is investing, and it uses a backend you control so that the client secret and refresh token stay off the browser.
2. **A wallet.** Para's embedded wallet is *where* the assets are delivered. You adapt it once to the `EvmWallet` interface.
3. **A checkout.** `CheckoutContainer` reads an offer's `type` and renders the right provider's flow: Ondo buy, Ondo sell, Superstate swap, or your own token-sale UI.

The two identities in step 1 and step 2 are distinct, and linking them is what the ownership proof is for. The user signs a message to prove they control the wallet, and for Superstate the issuer also allowlists the address on-chain. The SDK runs both for you.

Passage exposes each product at three rungs, and you can drop between them per flow:

| Rung | What it is | When to use it |
| - | - | - |
| **L3** components | `CheckoutContainer` and friends, fully styled | Start here. One integration covers every provider. |
| **L2** hooks | Per-product viewmodels returning `{ state, onEvent }` | You want the SDK's logic and state machine behind your own UI. |
| **L1** client | Plain namespaces on `CoinListClient`, no React | Non-React apps, custom state layers, or a step the flows do not cover. |

## What You Need

* Node.js 18 or later, and a package manager.
* A Para API key and a completed [Para React setup](/v3/react/setup/nextjs), so the user has an embedded EVM wallet.
* Passage OAuth credentials: a `client_id`, a registered `redirect_uri`, and a `client_secret`. Request them by filling out this [form](https://airtable.com/appAjHhqZC9FIPEuR/pagRl25Yb4UNpremc/form?utm_source=Para\&utm_medium=Referral\&utm_campaign=Para).
* A backend you control, meaning any server that can host three routes. The client secret stays there.
* The examples use **Next.js App Router**, but the React pieces work in any framework.
* An offer to transact against. Passage provides offer IDs during onboarding, and offers also carry a `type` field, so you can route on the catalog rather than hard-coding IDs.
* An RPC endpoint for the chain you target, plus gas and a funding stablecoin in the signing wallet.

### Install Dependencies

```bash theme={null}
npm install @getpara/react-sdk @coinlist-co/react viem @tanstack/react-query
```

Passage's peer dependencies are `react` and `react-dom` >= 18, and `viem` ^2. The SDK ships and uses viem types throughout, which is what makes the Para adapter below so short.

***

## Step-by-Step Integration

### Step 1: Wrap the app in both providers

`ParaProvider` supplies the wallet and `CoinListProvider` supplies the session. Nest `CoinListProvider` inside `ParaProvider` so that hooks reading the wallet are available to anything the checkout renders.

```tsx app/providers.tsx theme={null}
'use client'

import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ParaProvider } from '@getpara/react-sdk'
import { ClientId, CoinListProvider, RedirectUri } from '@coinlist-co/react'
import type { ClientConfig } from '@coinlist-co/react'
import '@getpara/react-sdk/styles.css'

const queryClient = new QueryClient()

const coinlistConfig: ClientConfig = {
  clientId: ClientId(process.env.NEXT_PUBLIC_COINLIST_CLIENT_ID!),
  redirectUri: RedirectUri(process.env.NEXT_PUBLIC_COINLIST_REDIRECT_URI!),
  // The browser never sees a refresh token or the client secret. It asks your
  // backend for a short-lived access token, and the backend refreshes it.
  getAccessToken: async () => {
    const res = await fetch('/api/coinlist/oauth/access-token', {
      credentials: 'include',
    })
    if (res.status === 204) return null
    if (!res.ok) throw new Error('GET /api/coinlist/oauth/access-token failed')
    const data = (await res.json()) as { value: string; expiresAt: string }
    return { value: data.value, expiresAt: new Date(data.expiresAt) }
  },
}

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <QueryClientProvider client={queryClient}>
      <ParaProvider
        paraClientConfig={{ apiKey: process.env.NEXT_PUBLIC_PARA_API_KEY! }}
        config={{ appName: 'Your App' }}
        fallback={<Loading />}
      >
        <CoinListProvider config={coinlistConfig}>{children}</CoinListProvider>
      </ParaProvider>
    </QueryClientProvider>
  )
}
```

<Note>
  `CoinListProvider` also takes a `locale`, a BCP-47 tag applied to number, date and currency formatting in every SDK component in the tree. It defaults to `'en-US'`, a fixed constant rather than `navigator.language`, so server and client render the same markup.
</Note>

### Step 2: Run OAuth through your backend

Passage uses OAuth 2.0 with PKCE. The React SDK handles PKCE in the browser, and your server does the code exchange and holds the refresh token. Three routes are all you need.

**Bind the server SDK to your session store.** The example uses HTTP-only cookies, so adjust it to your security model.

```typescript lib/coinlist-server.ts theme={null}
import type { NextResponse } from 'next/server'
import type { CoinListServer } from '@coinlist-co/react/server'
import { createCoinListServer } from '@coinlist-co/react/server'
import { ClientId, ClientSecret, RedirectUri } from '@coinlist-co/react/universal'
import { createSessionCookiesStore } from './session-store'

export function coinListServer(outgoingResponse: NextResponse): CoinListServer {
  return createCoinListServer({
    clientId: ClientId(process.env.NEXT_PUBLIC_COINLIST_CLIENT_ID!),
    clientSecret: ClientSecret(process.env.COINLIST_CLIENT_SECRET!),
    redirectUri: RedirectUri(process.env.NEXT_PUBLIC_COINLIST_REDIRECT_URI!),
    sessionStore: createSessionCookiesStore(outgoingResponse),
  })
}
```

`createCoinListServer` returns a `CoinListServer`, and its methods are grouped into namespaces: `auth` for the OAuth session, alongside `offers`, `tokenSale` and the rest. So `coinListServer(response).auth` is the half of OAuth for the server SDK, and it is what the three routes below call.

Each route builds its own instance bound to the response it is about to return. That is what lets the SDK write a refreshed session cookie onto the response the browser receives.

<Note>
  The browser client is laid out the same way. `coinlist` from `useCoinList()` has its own `coinlist.auth`, for the half of OAuth that happens in the page, such as `startOAuth()`. The server namespace holds the client secret and the refresh token, and the browser one never sees either.
</Note>

**Exchange the code.** The server SDK performs the token exchange, so you never call the token URL with raw `fetch` in application code.

```typescript app/api/coinlist/oauth/complete/route.ts theme={null}
import { NextResponse } from 'next/server'
import { AuthorizationCode, CodeVerifier } from '@coinlist-co/react/universal'
import { coinListServer } from '@/lib/coinlist-server'

export async function POST(req: Request) {
  const { code, codeVerifier } = await req.json()
  if (typeof code !== 'string' || typeof codeVerifier !== 'string') {
    return NextResponse.json({ error: 'missing_fields' }, { status: 400 })
  }

  const response = NextResponse.json({ ok: true })
  await coinListServer(response).auth.completeOAuth({
    code: AuthorizationCode(code),
    codeVerifier: CodeVerifier(codeVerifier),
  })
  return response
}
```

**Serve access tokens.** This is the route the `getAccessToken` callback in Step 1 calls. `auth.getAccessToken()` on that same server object returns the current token, and refreshes it server-side when it has expired.

```typescript app/api/coinlist/oauth/access-token/route.ts theme={null}
import { NextResponse } from 'next/server'
import { coinListServer } from '@/lib/coinlist-server'
import { copyCookiesFromTo } from '@/lib/session-store'

export async function GET() {
  // A scratch response for the SDK to write a refreshed session cookie onto.
  const cookieSink = new NextResponse(null, { status: 204 })
  const token = await coinListServer(cookieSink).auth.getAccessToken()
  if (token == null) {
    return cookieSink
  }

  const response = NextResponse.json({
    value: token.value,
    expiresAt: token.expiresAt.toISOString(),
  })
  // Carry any refreshed cookie over to the response the browser receives.
  copyCookiesFromTo(cookieSink, response)
  return response
}
```

**Log out.** `auth.logout()` on that object clears the session through the same store.

```typescript app/api/coinlist/oauth/logout/route.ts theme={null}
import { NextResponse } from 'next/server'
import { coinListServer } from '@/lib/coinlist-server'

export async function POST() {
  const response = NextResponse.json({ ok: true })
  await coinListServer(response).auth.logout()
  return response
}
```

Two helpers above live in your session store: `createSessionCookiesStore`, which reads and writes the session cookie, and `copyCookiesFromTo`, which moves a refreshed cookie from the scratch response onto the real one. [Set up OAuth authentication](https://docs.passage.coinlist.co/sdk/oauth-authentication?utm_source=para_docs\&utm_medium=referral\&utm_content=oauth) has both verbatim, along with the read-only variant Next.js Server Components use.

<Note>
  Nothing about these three routes is Next-specific. Any backend that can hold a session and keep the client secret off the browser will do, whether that is Express, Fastify, a serverless function, or middleware mounted inside your dev server. The handlers port over unchanged, and only the request and response plumbing differs.
</Note>

**Handle the callback** at the URL you registered as `redirect_uri`. `useCompleteOAuth` validates the redirect, POSTs to your complete endpoint, then runs `coinlist.init()` so `getAccessToken` sees the new session.

```tsx app/oauth/coinlist/callback/page.tsx theme={null}
'use client'

import { useCompleteOAuth } from '@coinlist-co/react'
import { useRouter } from 'next/navigation'

export default function CoinListCallbackPage() {
  const router = useRouter()

  useCompleteOAuth({
    async postOAuthComplete({ code, codeVerifier }) {
      const res = await fetch('/api/coinlist/oauth/complete', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        credentials: 'include',
        body: JSON.stringify({ code, codeVerifier }),
      })
      return res.ok
    },
    onFailure: (reason) => router.replace(`/?error=${encodeURIComponent(reason)}`),
    onSuccess: () => router.replace('/'),
  })

  return <p>Completing sign-in…</p>
}
```

For the sign-in button itself, render `CoinListSignInCardContainer` anywhere inside `CoinListProvider`, or `CoinListSignInButton` if you want the bare control. It calls `coinlist.auth.startOAuth()` when the user clicks **Sign in with CoinList**.

### Step 3: Adapt the Para wallet to `EvmWallet`

This is the part that makes everything else work. `EvmWallet` is five members, and Para's viem account supplies all of them.

| Member | Signature | What the SDK does with it |
| - | - | - |
| `address` | `EvmWalletAddress` | Reads balances and allowances, and names the sender. |
| `signMessage` | `(message: string) => Promise<Hex>` | Ownership challenges and allowlist authorization. |
| `writeContract` | `(params: WriteContractParams) => Promise<Hash>` | ERC-20 `approve`, and any contract call the SDK encodes itself. |
| `broadcastRawTx` | `(params: BroadcastTxParams) => Promise<Hash>` | Sends backend-encoded calldata **verbatim**. Never re-encode it, because the contract verifies a signature over the exact arguments inside. |
| `awaitTx` | `(hash: Hash, chain: EthereumChain) => Promise<TransactionReceipt>` | Waits for confirmation before advancing a flow. |

There is also a smaller `EvmSigner`, just `address` and `signMessage`, which is enough if all you need is the requirements checklist binding a wallet to an offer option. Your users then never see a gas prompt. `EvmWallet extends EvmSigner`, so implementing the full interface satisfies both.

`useParaViemAccount` hands you a viem `LocalAccount`. Because it signs locally, there is **no chain to switch**. Where a wagmi adapter has to call `switchChain` before every action, the Para adapter just builds a client pointed at whichever chain the SDK asked for.

```typescript useParaEvmWallet.ts theme={null}
import { useMemo } from 'react'
import { useParaViemAccount } from '@getpara/react-sdk'
import type { EvmWallet } from '@coinlist-co/react'
import {
  EvmWalletAddress,
  type EthereumChain,
} from '@coinlist-co/react/universal'
import {
  type Chain,
  createPublicClient,
  createWalletClient,
  encodeFunctionData,
  http,
} from 'viem'
import { base, baseSepolia, mainnet, sepolia } from 'viem/chains'

// `Record<EthereumChain, Chain>` is total on purpose. The day the SDK models a
// new chain, this table fails to compile until you decide what it maps to.
const CHAINS: Record<EthereumChain, Chain> = {
  ethereum_mainnet: mainnet,
  ethereum_sepolia: sepolia,
  base_mainnet: base,
  base_sepolia: baseSepolia,
}

// Your own RPC endpoints, per chain. An undefined entry falls back to the
// chain's public RPC.
const RPC_URLS: Partial<Record<EthereumChain, string>> = {
  ethereum_sepolia: process.env.NEXT_PUBLIC_SEPOLIA_RPC_URL,
}

const rpcUrlFor = (chain: EthereumChain): string | undefined => RPC_URLS[chain]

/**
 * Adapts the Para embedded wallet to Passage's `EvmWallet` seam.
 *
 * Para exposes the embedded wallet as a viem `LocalAccount`, so there is no
 * chain to "switch" the way an injected wallet would need. The adapter just
 * builds a client pointed at whichever chain the SDK asks for on each call.
 *
 * Errors are deliberately NOT caught. The Passage SDK classifies whatever the
 * wallet throws into a typed WalletError (user_rejected, insufficient_funds,
 * contract_reverted, timeout), and translating them here would lose that.
 */
export function useParaEvmWallet(): {
  wallet: EvmWallet | null
  isLoading: boolean
} {
  const { viemAccount, isLoading } = useParaViemAccount()

  const wallet = useMemo(() => {
    if (!viemAccount) return null

    const clientsFor = (chain: EthereumChain) => {
      const viemChain = CHAINS[chain]
      if (!viemChain) throw new Error(`Unsupported chain: ${chain}`)
      const transport = http(rpcUrlFor(chain))
      return {
        wallet: createWalletClient({
          account: viemAccount,
          chain: viemChain,
          transport,
        }),
        public: createPublicClient({ chain: viemChain, transport }),
      }
    }

    return {
      address: EvmWalletAddress(viemAccount.address),

      signMessage: (message) => viemAccount.signMessage({ message }),

      async writeContract({ abi, address, functionName, args, value, chain }) {
        const { wallet } = clientsFor(chain)
        return wallet.sendTransaction({
          to: address,
          data: encodeFunctionData({ abi, functionName, args }),
          ...(value !== undefined ? { value } : {}),
        })
      },

      // Backend-encoded calldata goes out verbatim. Re-encoding would break
      // the signature the contract verifies over the exact arguments inside.
      async broadcastRawTx({ to, data, chain }) {
        const { wallet } = clientsFor(chain)
        return wallet.sendTransaction({ to, data })
      },

      awaitTx: (hash, chain) =>
        clientsFor(chain).public.waitForTransactionReceipt({ hash }),
    } satisfies EvmWallet
  }, [viemAccount])

  return { wallet, isLoading }
}
```

That is the entire Para integration. Everything from here on is the same code any Passage partner writes.

#### Let wallet errors propagate

**Do not catch and translate errors from Para or viem.** Let them throw. The SDK catches them and classifies them into a typed `WalletError`, so you never parse a wallet error string yourself:

| `WalletError` | Cause |
| - | - |
| `user_rejected` | The user dismissed the wallet prompt. |
| `insufficient_funds` | Not enough native token for gas, or not enough balance. |
| `contract_reverted` | The transaction was mined and reverted. |
| `timeout` | Confirmation did not arrive in time. |
| `unknown` | Anything the SDK could not classify. |

Flows surface it as the `cause` on the step that failed, so you can tell a user rejection apart from an infrastructure problem.

### Step 4: Hand the wallet to checkout

`EvmWallet` is what the SDK *drives*. `CheckoutWalletSelection` is what you *hand* `CheckoutContainer`: the wallets the buyer may spend from, plus the two lambdas that connect and disconnect an external one.

```typescript theme={null}
import type { CheckoutWalletSelection } from '@coinlist-co/react'

const wallets: CheckoutWalletSelection = {
  // The user's embedded wallets, in display order. Empty is a normal state.
  embedded: paraWallet ? [paraWallet] : [],
  // The connected external wallet, or null while none is connected.
  external: null,
  // A wallet you have already settled on, or null to let the user pick.
  preselected: paraWallet,
  // Opens your wallet connector. The SDK reads the outcome from `external` on
  // the next render rather than from a return value, so a redirect-based
  // connector works.
  connectExternal: () => openConnectModal(),
  disconnectExternal: () => disconnect(),
}
```

A Para-only app has no external wallets to offer, so `external` stays `null` and the two lambdas are never reached. The seam still asks for them, so supply a rejecting `connectExternal` and a no-op `disconnectExternal`.

#### Skipping the wallet step

Set `preselected` to a ready `EvmWallet` and a checkout that supports it opens on the amount, renumbering the cards that remain. That is for the page that already knows the answer, which in a Para app is every page. A sell page reached from a position knows which wallet holds it, and asking again would be a question with one possible answer.

The wallet does not have to appear in `embedded` or `external`, because you supply a ready `EvmWallet` either way. It is read once, at mount. The approval that follows the amount step is granted by one specific wallet, so the flow stays on the wallet it started with. The Ondo sell checkout honors it today.

### Step 5: Pin the little that is not on the offer

Most of what a flow needs travels with the offer you fetched, so there is little to configure and nothing to look up by hand:

| Value | Where it comes from |
| - | - |
| The offer id | `offer.id` |
| The Ondo spender, per chain | `swapSpender(offer.swapContracts, chain)` |
| Which chains an offer can settle on | `offer.swapContracts` |
| The assets an offer can be funded with | `offer.fundingAssets` |
| The Superstate swap contract | resolved from the chain by the SDK |
| The payment token's ERC-20 address | `TOKEN_REGISTRY`, by symbol and chain |

`swapContracts` is the important one. The spender is a property of the offer rather than a constant, because the address varies by environment as well as by chain: two environments can both run `ethereum_sepolia` and deploy a different contract on each. Reading it off the offer is what keeps one build working against both.

That leaves a chain to execute on, the payment token, and, for token sales only, the funding contract:

```typescript lib/constants.ts theme={null}
import {
  type EthereumChain,
  EvmContractAddress,
  TOKEN_REGISTRY,
  USDC_SYMBOL,
} from '@coinlist-co/react/universal'

// Where orders execute. It has to be a chain the offer settles on, which
// `offer.swapContracts` lists. Switch to 'ethereum_mainnet' when you go live.
export const CHAIN: EthereumChain = 'ethereum_sepolia'

// The stablecoin users pay with. TOKEN_REGISTRY knows the ERC-20 metadata
// and the per-chain address for USDC and USDT, so no address is hardcoded.
export const USDC = TOKEN_REGISTRY.erc20(USDC_SYMBOL)
export const USDC_ADDRESS = TOKEN_REGISTRY.contractAddress(USDC_SYMBOL, CHAIN)

// Token sales only: the contract that pulls the payment, meaning the
// `approve()` spender. This is the one address not carried on the offer, and
// Passage provides it during onboarding.
export const FUNDING_CONTRACT_ADDRESS = EvmContractAddress(
  'YOUR_FUNDING_CONTRACT_ADDRESS',
)
```

The examples also assume two values from earlier steps: `coinlist`, which comes from `useCoinList()`, and `wallet`, the `EvmWallet` returned by `useParaEvmWallet()` in Step 3.

### Step 6: Render the checkout

`CheckoutContainer` is the plug-and-play checkout. Hand it an offer and it renders whichever provider that offer belongs to. Superstate swap, Ondo buy, Ondo sell, CoinList token sale: you write one integration, not four.

```tsx theme={null}
import {
  CheckoutContainer,
  defaultCheckoutConfig,
  type CheckoutWalletSelection,
} from '@coinlist-co/react'
import { AssetSymbol, type OfferDetail } from '@coinlist-co/react/universal'
import { CHAIN } from './constants'

// Per-integration, not per-offer, except `side`, which is per-render.
const checkoutConfig = defaultCheckoutConfig({
  'ondo::swap': {
    symbol: (offer) => AssetSymbol(offer.asset.code),
    side: () => 'buy',
  },
  'coinlist::token_sale': {
    render: (offer) => <MyTokenSalePage offer={offer} />,
  },
})

export function Checkout({
  offer,
  wallets,
}: {
  offer: OfferDetail
  wallets: CheckoutWalletSelection
}) {
  return (
    <CheckoutContainer
      key={`${offer.id}:${CHAIN}:buy`}
      offer={offer}
      chain={CHAIN}
      wallets={wallets}
      config={checkoutConfig}
    />
  )
}
```

That is the whole integration. The container is **self-scoped**, so it renders fully styled with no style provider in the tree.

<Note>
  **One mount, one offer, one chain, one side.** `offer`, `chain` and Ondo's `side` are read as fixed for the lifetime of the mount, because each provider's viewmodel owns a step machine bound to them. An approval is granted per chain, for one specific token. To render a different one, remount with a `key` that covers all four, as above. React discards the old flow's state along with its component instance, and that is the only reset that cannot strand an in-flight transaction.
</Note>

`CheckoutConfig` is keyed by `OfferType`, and **every key is required**. That is not an oversight. A host that renders `CheckoutContainer` is saying it will handle whatever offer it is given, so when a new offer type ships, every host breaks at compile time rather than rendering a blank screen their users find first.

| Key | Field | What you supply |
| - | - | - |
| `ondo::swap` | `symbol: (offer) => AssetSymbol` | **Required.** Ondo's API symbol for the offer's asset, e.g. `AAPLon`. |
| | `side: () => OrderBookSide` | **Required.** `"buy"` invests in the asset, `"sell"` liquidates a holding of it. |
| | `onOrderConfirmed?: (order) => void` | Optional. Fired once an Ondo swap has mined, either way. |
| `superstate::swap` | `onOrderConfirmed?: (order) => void` | Optional. Superstate needs nothing required today. |
| `coinlist::token_sale` | `render: (offer) => ReactNode` | **Required.** Token sales currently require a custom UI implementation. |

`defaultCheckoutConfig({ ... })` fills in the entries that have a sensible default, so you only write the ones that do not.

<Note>
  **`symbol` must be total.** React hooks cannot be called conditionally, so Ondo's viewmodel runs for *every* offer, including Superstate and token-sale ones, where its result is handed to a disabled viewmodel and never read. Throwing on an offer you do not recognize would take down a checkout Ondo has no part in. Return anything.
</Note>

Before checkout, an offer may carry requirements such as identity verification, a tax document, or a wallet binding. `RequirementsChecklistContainer` runs all of them, including the ownership challenge your Para wallet signs through `EvmSigner`, and hands control back when the user is cleared to invest.

***

## Ondo Swap: buying

Ondo supplies **tokenized stocks**: `AAPLon`, `TSLAon` and the rest. An Ondo offer arrives with `type: "ondo::swap"`, and the buyer spends a stablecoin to receive the tokenized asset.

`symbol` refers to **Ondo's API symbol**, not the on-chain `symbol()` and not necessarily `offer.asset.code`. Ondo's symbol tracks the underlying ticker and may change following a rebrand, and the values can differ in test environments such as Sepolia, where a mock asset stands in. If your catalog agrees with Ondo, return `AssetSymbol(offer.asset.code)`. If it does not, map the exceptions.

### Placing an order takes two calls

This is the one structural thing to know about Ondo. An order is `prepareBuy` then `executeSwap` rather than a single call, and the split is where the buyer's decisions are:

1. **`prepareBuy`** approves the swap contract to spend the amount, then builds the transaction that spends it. The result is a **committed quote**: firm calldata with an `expiresAt`.
2. The buyer reviews firm numbers: what they pay, the fee, what they receive.
3. **`executeSwap`** broadcasts that calldata verbatim and waits for it to mine.

Bundling them behind one button would burn most of the transaction's expiration time on the approval and hand the buyer an expired quote. The approval deliberately comes **first**, because building first would spend an attestation on calldata that had already expired by the time the buyer could act on it.

<Note>
  `buildBuyTransaction` spends an attestation on every call. The two reads, `getTradingStatus` and `getQuote`, are free to poll while the user edits an order. Budget one build per order placed, plus one per refresh the user asks for.
</Note>

### Driving it yourself (L1)

```typescript theme={null}
const prepared = await coinlist.ondo.prepareBuy({
  wallet,                                   // your Para EvmWallet
  symbol: AssetSymbol('AAPLon'),            // Ondo's API symbol
  chain: CHAIN,
  swapContracts: offer.swapContracts,       // the spender, resolved per chain
  tokenAddress: USDC_ADDRESS,               // the ERC-20 being spent
  amount: parsed.amount,                    // BlockchainAmount, base units
  onProgress: (phase) => setBusy(phase),
})

if (prepared.type === 'error') {
  // prepared.error.step: "unsupported-chain" | "allowance-check"
  //   | "approval" | "approval-reverted" | "insufficient-allowance"
  //   | "build-transaction"
  return
}

const filled = await coinlist.ondo.executeSwap({
  wallet,
  transaction: prepared.transaction,
  chain: CHAIN,
  onProgress: (phase) => setBusy(phase),
})

if (filled.type === 'success') {
  // filled.txHash, filled.transaction
} else {
  // filled.error.step: "quote-expired" | "swap" | "swap-reverted"
}
```

Both flows are **total**: every failure comes back step-tagged rather than thrown, so you map each one to your own copy.

**`prepareBuy` phases**, in order. An allowance that already covers the order skips the approval phases:

`checking-allowance` → *(`resetting-allowance` → `confirming-allowance-reset`, only for stale non-zero allowances)* → **`approving`** *(wallet prompt)* → `confirming-approval` → `building-transaction`

**`executeSwap` phases**: **`broadcasting-swap`** *(wallet prompt)* → `confirming-swap`

Two error steps are worth calling out:

* **`insufficient-allowance`** is separated from the generic build failure because it has a remedy. Reaching it means the approval that just mined is not the one the backend sees, usually an RPC node a block behind. That is a **retry**, not a re-approval.
* **`quote-expired`** is a refusal to broadcast rather than a failed broadcast. It would revert on-chain and cost the buyer gas, and building a fresh transaction fixes it.

### Which contract the buyer approves

The ERC-20 spender comes off the offer rather than a constant in the SDK. `OfferDetail.swapContracts` lists every chain the offer can be swapped on with the CoinList contract each settles through, and `swapSpender` reads one out:

```typescript theme={null}
import { swapSpender } from '@coinlist-co/react/universal'

const spender = swapSpender(offer.swapContracts, chain) // EvmContractAddress | null
```

`null` means this offer cannot be swapped on that chain, which is an answer rather than an error. `CheckoutContainer` and both Ondo viewmodels check it before anything runs and render a flow-level `error` state with `reason: "unsupported-chain"`, so no wallet picker, balance read or price poll happens on a chain the order could never settle on.

***

## Ondo Swap: selling

Selling liquidates a holding of an Ondo tokenized stock and settles the proceeds in USDC. It runs on the same `ondo::swap` offer type, through the same contract, with the same two-call order. There is no separate sell container, because `side` is what picks the flow:

```tsx theme={null}
const config = defaultCheckoutConfig({
  'ondo::swap': {
    symbol: (offer) => AssetSymbol(offer.asset.code),
    side: () => side,
  },
  'coinlist::token_sale': { render: () => null },
})

return (
  <CheckoutContainer
    // The side belongs in the key. The flow holds an approval granted for
    // whichever coin that side spends.
    key={`${offer.id}:${CHAIN}:${side}`}
    offer={offer}
    chain={CHAIN}
    wallets={wallets}
    config={config}
  />
)
```

A host with a buy page and a sell page passes the same object with a different `side`, or closes the thunk over whatever selects the direction: a route, a tab, a toggle.

### What a sale approves

A purchase approves the **funding coin**, USDC, and receives the asset. A sale is the mirror. It approves the **asset** and receives USDC.

That matters because `TOKEN_REGISTRY` knows the stablecoins swaps are funded with, so on a sale both

* the **contract address** the balance is read on and the approval is granted for, and
* the **decimals** the typed amount is parsed at

come off the sell quote (`OndoQuote.assetAddress`, `OndoQuote.asset.decimals`). `useOndoSellCheckoutViewModel` does that resolution for you, and a host composing its own viewmodel reads the quote before it reads a balance.

```typescript theme={null}
// The quote is the only source of the asset's address and decimals.
const quote = await coinlist.ondo.getQuote({
  symbol: AssetSymbol('AAPLon'),
  side: 'sell',
  tokenAmount: amount,
})

const prepared = await coinlist.ondo.prepareSell({
  wallet,
  symbol: AssetSymbol('AAPLon'),
  chain: CHAIN,
  swapContracts: offer.swapContracts,
  tokenAddress: quote.assetAddress,         // the ASSET, not a stablecoin
  amount,                                   // BlockchainAmount, asset base units
  onProgress: (phase) => setBusy(phase),
})

// prepared.transaction.expected  = what the sale should return
// prepared.transaction.minimum   = the floor the calldata enforces
```

<Note>
  `expected` is what the sale should return. `minimum` is the floor the calldata enforces on chain. A fill below `minimum` reverts, so `minimum` rather than `expected` is what a seller is actually guaranteed. Show both.
</Note>

<Note>
  **Sell caps are independent of buy caps.** `getTradingStatus` takes a required `side` and reports `tradable` with size limits for that side only. A working buy does not imply a working sell, and the market can be open one way and closed the other.
</Note>

***

## Superstate Swap

Superstate supplies **tokenized equities**. A Superstate offer arrives with `type: "superstate::swap"` and settles directly on-chain. The user pays with a stablecoin and receives the tokenized equity, an ERC-20, in the same flow.

**Nothing is required in `CheckoutConfig`.** The swap contract is looked up from the chain, and the funding assets and issuer are the provider's own constants, so `defaultCheckoutConfig` fills the key in for you.

Three things distinguish Superstate from the other providers:

* **The wallet must be allowlisted.** Superstate only settles to addresses the issuer has approved. The user signs an ownership challenge and the SDK allowlists the address on the swap contract, once per wallet per offer. The same allowlists are what make permissioned DeFi pools and RWA lending markets reachable.
* **The fee is charged on top.** The wallet is debited `inputTokenAmount + fee`, so always show that sum as the total cost. (Ondo takes its fee off the deposit instead.)
* **Quotes are read quotes, polled every 15 seconds.** Nothing is committed until the swap itself, and slippage protection guards the on-chain minimum output. There is no expiring committed quote to race, as there is with Ondo.

The swap contract is the one Superstate value you resolve rather than read off the offer, and the SDK answers it from the chain:

```typescript theme={null}
import { superstateSwapContractAddress } from '@coinlist-co/react/universal'

// null means Superstate does not settle on this chain, which is an answer
// rather than an error. Render your unsupported-chain state and stop.
const swapContract = superstateSwapContractAddress(CHAIN)
```

### Allowlist the wallet

`coinlist.superstate.authorizeWallet` does both halves in one call. It proves the user controls the wallet by having them sign a challenge, through your Para adapter's `signMessage`, then registers the address against the offer and broadcasts an allowlist transaction if the contract requires one.

```tsx theme={null}
const result = await coinlist.superstate.authorizeWallet({
  wallet,
  offerId: offer.id,
  contractAddress: swapContract,
  chain: CHAIN,
  onProgress: (phase) => setBusy({ kind: 'authorize', phase }),
})

if (result.type === 'success') {
  setAuthorized(true)
} else {
  setError(result.error.step)
}
```

It is **idempotent**. If the wallet is already authorized it returns `success` immediately without prompting, so it is safe to call at the start of every session.

Phases, in order. Two of them open a wallet prompt:

`checking-authorization` → `requesting-challenge` → **`signing-message`** *(prompt)* → `submitting-signature` → **`broadcasting-transaction`** *(prompt, only when the contract needs an allowlist transaction)* → `awaiting-confirmation` → `verifying-authorization`

Error steps are tagged by what failed: `authorization-check`, `challenge-request`, `signing`, `allow-wallet`, `broadcast`, `not-authorized`. The two that carry a `cause` of type `WalletError` are `signing` and `broadcast`.

### Quote and execute

```tsx theme={null}
const { outputTokenState } = useSwapOutputToken({
  contractAddress: swapContract,
  chain: CHAIN,
  enabled: true,
})
const outputToken =
  outputTokenState.type === 'CONTENT' ? outputTokenState.outputToken : null

const parsed = parseBlockchainAmount(amountInput, USDC.decimals)
const hasAmount = parsed.valid && parsed.amount.raw > 0n

const { quote, isRefreshing } = useSwapQuote({
  contractAddress: swapContract,
  chain: CHAIN,
  inputAmount: hasAmount
    ? parsed.amount
    : BlockchainAmount({ raw: 0n, decimals: USDC.decimals }),
  inputTokenAddress: USDC_ADDRESS,
  outputTokenDecimals: outputToken?.decimals ?? null,
  enabled: authorized && hasAmount,
})

const total = BlockchainAmount.add(quote.inputTokenAmount, quote.fee)
const minReceived = computeSlip(quote.outputTokenAmount, DEFAULT_SLIPPAGE_BPS)
```

```tsx theme={null}
const result = await coinlist.superstate.execute({
  wallet,
  contractAddress: swapContract,
  chain: CHAIN,
  inputTokenAddress: USDC_ADDRESS,
  quote,
  slippageBps: DEFAULT_SLIPPAGE_BPS,
  onProgress: (phase) => setBusy({ kind: 'swap', phase }),
})

if (result.type === 'success') {
  // result: swapTxHash, inputAmount, fee, outputAmount, outputAmountConfirmed,
  //   pricePerShare, recipientAddress
} else {
  // result.error.step: "status-check" | "swap-stopped" | "allowance-check"
  //   | "approval" | "approval-reverted" | "swap" | "swap-reverted"
}
```

<Note>
  **Read `outputAmountConfirmed` before you show `outputAmount` to a user.** It is `true` when the amount is decoded from the on-chain `Swapped` event, and `false` when that event would not decode and the quote's estimate stands in. The swap mined either way, so the order is settled. Label an estimate as one. `pricePerShare` derives from the same number.
</Note>

Phases: `checking-status` → `checking-allowance` → *(`resetting-allowance` → `confirming-allowance-reset`, only for stale non-zero allowances)* → **`approving`** *(prompt)* → `confirming-approval` → **`swapping`** *(prompt)* → `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 prompt, so the user never signs a transaction that would revert.
</Note>

***

## CoinList Token Sale

CoinList is one of the providers in the Passage catalog, and it runs its own **token sales**. An offer arrives with `type: "coinlist::token_sale"`, and the user commits a stablecoin to buy into it. The participation settles later on the sale's own schedule, so nothing leaves the wallet during the flow. The user signs an ERC-20 `approve` and that is all.

The SDK ships the data layer for token sales and hands the presentation back to you through the required `render` slot, so your sale page looks like the rest of your app. A host that never lists token-sale offers returns `null`.

The flow drives **any** wallet through the same `EvmWallet` interface. It only ever calls `address`, `writeContract`, and `awaitTx`, because there is no message to sign, so the Para adapter above works unchanged.

```tsx theme={null}
const parsed = parseBlockchainAmount(amountInput, USDC.decimals)
if (!parsed.valid || parsed.amount.raw <= 0n) return

const result = await coinlist.tokenSale.execute({
  wallet,
  offerId: offerDetail.id,
  offerOptionId: option.id,          // the OfferOption the user picked
  assetId: fundingAsset.id,          // funding asset from offerDetail.fundingAssets
  paymentTokenAddress: USDC_ADDRESS,
  fundingContractAddress: FUNDING_CONTRACT_ADDRESS,
  chain: CHAIN,
  amount: parsed.amount,
  onProgress: (phase) => setBusy(phase),
})

if (result.type === 'success') {
  // result: participation, approvalTxHash
  const explorerUrl = txExplorerUrl(CHAIN, result.approvalTxHash)
} else {
  // result.error.step: "allowance-check" | "allowance-reset"
  //   | "allowance-reset-reverted" | "approval" | "approval-reverted"
  //   | "participation"
}
```

Phases: `checking-allowance` → *(`resetting-allowance` → `confirming-allowance-reset`, only for stale non-zero allowances)* → **`approving`** *(prompt)* → `confirming-approval` → `recording-participation`

The **funding contract address**, the `approve()` spender, is provided by Passage during onboarding. Keep it in a constant.

<Note>
  Passage settles the funding move on its own contracts, on its own schedule, after on-chain verification. The user only signs an allowance up to the amount they want to invest, and nothing leaves their wallet until Passage later pulls it. The pattern is cancelable, since the user can revoke the allowance, and it lets Passage batch transfers.
</Note>

When recording fails after the approval mined (`error.step === "participation"`), the error carries the `approvalTxHash`, so retry `coinlist.tokenSale.createParticipation` with that hash rather than asking the user to approve again.

Afterwards, `useParticipations(offerId)` lists what the user has taken part in. Participations move through their statuses asynchronously and the SDK does not push, so re-fetch on session refresh or after a user action. Omit the `offerId` to list every participation for the signed-in user.

## Conclusions

The whole Para-specific surface of a Passage integration is the one `useParaEvmWallet` hook in Step 3. Because Para exposes the embedded wallet as a viem `LocalAccount`, that adapter is shorter than the wagmi one Passage documents. There is no chain to switch and no connector state to track, just a client built per call against whichever chain the SDK names.

Everything above that seam is the same code any Passage partner writes. Implement `EvmWallet` once and every flow works: Ondo buys and sells, Superstate swaps with their allowlist step, token-sale approvals, and whatever providers ship next. Start at `CheckoutContainer`, and drop to the hooks or the client only for the screens where you want your own markup.

Full reference: the [Passage SDK docs](https://docs.passage.coinlist.co/sdk/quickstart?utm_source=para_docs\&utm_medium=referral\&utm_content=quickstart), in particular [Wallets: the EvmWallet seam](https://docs.passage.coinlist.co/sdk/wallets?utm_source=para_docs\&utm_medium=referral\&utm_content=wallets), [Building the Checkout flow](https://docs.passage.coinlist.co/sdk/checkout?utm_source=para_docs\&utm_medium=referral\&utm_content=checkout), and [Errors and edge cases](https://docs.passage.coinlist.co/sdk/errors?utm_source=para_docs\&utm_medium=referral\&utm_content=errors).
