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

# Ondo Swap

> Buy and sell tokenized stocks with Ondo. One config field switches direction, what each side approves, and the two-call order both share.

Ondo supplies **tokenized stocks** - `AAPLon`, `TSLAon` and the rest. An Ondo offer arrives with `type: "ondo::swap"` and runs both ways: a **buy** spends USDC to receive the tokenized asset, a **sell** delivers the asset and settles the proceeds in USDC.

<Note>
  **Recommended:** render [`CheckoutContainer`](/sdk/checkout). It detects `ondo::swap`, reads the direction from your config, and renders the whole flow. This page covers what is specific to Ondo, and the levels below the component.
</Note>

Both directions are the same offer, the same contract, and the same two-call order. `offer.type` is `ondo::swap` whichever way the trade runs, because the side is a property of your page rather than of the catalog: the same asset is buyable and sellable at once.

## What Ondo needs from you

One config entry, with two required fields. There is no separate sell container - `side` is what picks the flow:

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

import {
  CheckoutContainer,
  defaultCheckoutConfig,
  type CheckoutWalletSelection,
} from "@coinlist-co/react";
import {
  AssetSymbol,
  type OfferDetail,
  type OrderBookSide,
} from "@coinlist-co/react/universal";

export function OndoCheckout({
  offer,
  wallets,
  side,
}: {
  offer: OfferDetail;
  wallets: CheckoutWalletSelection;
  side: OrderBookSide;
}) {
  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}:ethereum_mainnet:${side}`}
      offer={offer}
      chain="ethereum_mainnet"
      wallets={wallets}
      config={config}
    />
  );
}
```

That is the whole integration. 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.

`side` takes no offer because no offer can answer it: `offer.type` is `ondo::swap` both ways. See [Why `side` is a thunk](/sdk/checkout#why-side-is-a-thunk).

<Warning>
  **`side` is read once, at mount.** The flow holds a committed quote and an approval granted for one specific token, so a resolver that starts answering differently mid-flow is ignored rather than obeyed. Remount with a `key` covering the offer, the chain and the side, as above.
</Warning>

`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. The values can also differ in test environments, such as Sepolia, where a mock asset may stand in for the underlying asset.

If your catalog uses the same symbols as Ondo, you can return `AssetSymbol(offer.asset.code)` directly. If the values differ, map those exceptions explicitly. Your mapping must cover all offers, including those not provided by Ondo, since the same logic runs across the full catalog. See [the warning on the checkout page](/sdk/checkout#why-symbol-takes-the-offer) for additional context.

The config also accepts an optional `onOrderConfirmed(order)`, fired once a swap has mined, either way. The confirmation dialog shows regardless; add it only if you want to navigate, refresh a portfolio, or log.

<Note>
  **Quotes are priced against Ondo production whatever chain you pass**, because Ondo runs no sandbox. On a testnet the price is real and the money is not.
</Note>

## Placing an order takes two calls

This is the one structural thing to know about Ondo, and it is the same both ways. An order is prepare then execute, not one call, and the split is where the trader's decisions are:

1. **`prepareBuy` / `prepareSell`** approves the swap contract to move the amount, then builds the transaction that moves it. The result is a **committed quote**: firm calldata with an `expiresAt`, valid for about a minute.
2. The trader reviews firm numbers - what goes, the fee, what arrives.
3. **`executeSwap`** broadcasts that calldata verbatim and waits for it to mine. It is one method for both directions, because broadcasting reads calldata and a deadline and knows about neither.

Bundling them behind one button would burn most of the transaction's \~60-second life on the approval and hand the trader an expired quote. The approval deliberately comes **first**: building first would spend an attestation on calldata that had already expired by the time they could act on it.

<Warning>
  The builders spend an attestation on every call. The two reads - `getTradingStatus` and `getQuote` - are free to poll while the user sizes an order. Budget one build per order placed, plus one per refresh the user asks for.
</Warning>

<Note>
  **Buy and sell caps are independent.** `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>

## What the two directions do not share

Only the transport is common. Beyond it, the amounts mean different things:

|                                                  | Buy                                           | Sell                                       |
| ------------------------------------------------ | --------------------------------------------- | ------------------------------------------ |
| `amount` you pass                                | The **funding coin**, USDC, in its base units | The **asset**, in its base units           |
| What gets approved                               | USDC on `chain`                               | The **asset** on `chain`, not a stablecoin |
| Where the token's address and decimals come from | `TOKEN_REGISTRY`                              | The sell quote, and nowhere else           |
| What the response commits to                     | An exact quantity                             | A range: expected, with a floor beneath it |

The same integer means a different size on each side, so a size carried across a toggle is wrong by whatever the price is.

### Buying

The quantity is attested and exact, with no floor beneath it. The fee is **taken off the deposit rather than added on top**, so the approval never has to cover more than `payInputAmount`.

<Note>
  **No CoinList fee is applied to a read quote, and no read discloses one.** Ondo prices exactly the amount passed, so sizing a quote against a user's approval without subtracting the fee first overstates what they receive.
</Note>

### Selling

A sale is the mirror of a purchase: it approves the **asset** and receives USDC.

That matters because the SDK ships no registry entry for a provider's asset. `TOKEN_REGISTRY` knows the stablecoins swaps are funded with and nothing else, so on a sale:

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

both come off the sell quote (`OndoQuote.assetAddress`, `OndoQuote.asset.decimals`) and nowhere else. `useOndoSellCheckoutViewModel` does that resolution for you; a host composing its own viewmodel has to read the quote before it can read a balance.

The **settlement coin is USDC**, and it is display-only: the scale of every amount on a built sale comes off the response's `receive_output_decimals`, which is the only authority on how the proceeds are counted.

A built sale carries two figures rather than one. `expected` is what the sale should return; `minimum` is the floor the calldata enforces on chain. A fill below `minimum` reverts, so that - not `expected` - is what a seller is actually guaranteed. **Show both.**

### Skipping the wallet step

A sell page is usually reached from a position, and a position already names the wallet that holds it. Set [`CheckoutWalletSelection.preselected`](/sdk/wallets#skipping-the-wallet-step) and the checkout opens on the amount, renumbering the two cards that remain:

```ts theme={null}
const wallets: CheckoutWalletSelection = {
  embedded,
  external,
  preselected: walletHoldingThePosition, // an EvmWallet, or null to let the user pick
  connectExternal,
  disconnectExternal,
};
```

The wallet does not have to appear in `embedded` or `external` - you supply a ready `EvmWallet` either way. Like `side`, it is read once at mount.

<Note>
  **The Ondo sell checkout is the only flow that honors `preselected` today.** The field sits on the wallet seam rather than in Ondo's config because nothing about it is one provider's, but a provider that has not adopted it ignores it rather than half-implementing it.
</Note>

<AccordionGroup>
  <Accordion title="Build your own UI with hooks (L2)">
    `useOndoBuyCheckoutViewModel` and `useOndoSellCheckoutViewModel` each wrap a whole flow - trading status, quotes, the wallet step, the approval, the review and the broadcast - and return `{ state, onEvent }`. They take the same options.

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

    import { useOndoSellCheckoutViewModel } from "@coinlist-co/react";
    import { AssetSymbol } from "@coinlist-co/react/universal";

    export function OndoSellCheckout({ offer, wallets }) {
      const { state, onEvent } = useOndoSellCheckoutViewModel({
        offer,
        ondoSymbol: AssetSymbol(offer.asset.code),
        chain: "ethereum_mainnet",
        wallets,
      });

      switch (state.type) {
        case "error":
          // state.reason: "unsupported-chain"
          return <MyUnsupportedChainNotice />;
        case "content":
          // state.wallet is null when `preselected` skipped the wallet step.
          // state.amount, state.review, state.sidebar, state.orderConfirmed
          return <MySellFlow state={state} onEvent={onEvent} />;
        default: {
          const exhaustive: never = state;
          return exhaustive;
        }
      }
    }
    ```

    `OndoBuyCheckoutUiState` and `OndoSellCheckoutUiState` are both discriminated unions with a flow-level `error` arm: an offer that names no CoinList swap contract on the chain you passed has no checkout to render, and the viewmodel discovers that before any step runs, so nothing is fetched and no timer starts. `switch` exhaustively and assign the `default` to `const exhaustive: never = state` so a new step is a compile error rather than a blank screen.

    Inside the sell flow's `content` arm, `wallet` is `null` exactly when the host preselected a wallet. That `null` is the whole skip - it is what you render nothing for, and the single fact both remaining step numbers derive from.

    The smaller hooks they compose are exported too, so you can assemble your own viewmodel instead. Four are shared across directions; the rest are per-side, because what a screen reads off them forks:

    | Hook                           | Returns                                                                | `enabled`  |
    | ------------------------------ | ---------------------------------------------------------------------- | ---------- |
    | `useOndoTradingStatus`         | Whether that side's market is open, and its caps. Takes `side`.        | yes        |
    | `useOndoPrice`                 | A pollable read quote. Takes `side` and a size.                        | yes        |
    | `useOndoWalletSelectViewModel` | The wallet step.                                                       | not needed |
    | `useOndoSidebarViewModel`      | The order summary sidebar.                                             | not needed |
    | `useOndoBuyTransaction`        | The committed purchase from `prepareBuy`, with its expiry.             | yes        |
    | `useOndoSellTransaction`       | The committed sale from `prepareSell`, with its expiry. Never polls.   | yes        |
    | `useErc20Balance`              | The seller's balance of the asset, keyed by the address off the quote. | yes        |
    | `useOndoBuyAmountViewModel`    | The amount step, buy side. Takes `swapContracts`.                      | not needed |
    | `useOndoSellAmountViewModel`   | The amount step, sell side.                                            | not needed |
    | `useOndoBuyReviewViewModel`    | The review and confirm step, buy side.                                 | not needed |
    | `useOndoSellReviewViewModel`   | The review and confirm step, sell side.                                | not needed |

    `useErc20Balance` is the address-keyed counterpart to `useErc20TokenBalances`: a token whose contract address arrives at runtime has no registry symbol to look up, which is exactly the sell case. A `tokenAddress` of `null` fetches nothing, which is the normal state before the first quote lands.

    Every hook that fetches or polls takes `enabled`, and all of them must be called unconditionally. See [SDK structure](/sdk/structure#hooks) for the conventions they share.
  </Accordion>

  <Accordion title="Drive it yourself with the client (L1)">
    `coinlist.ondo` is a plain namespace with no React. The reads and the two builders exist on both the browser client and `CoinListServer`; the wallet-driven halves are client-only, because a backend has no wallet to sign with.

    | Method                         | Kind        | Notes                                                                                        |
    | ------------------------------ | ----------- | -------------------------------------------------------------------------------------------- |
    | `getTradingStatus(params)`     | read        | Free to poll. Requires `side`. Returns `tradable` with size caps, or `not-tradable`.         |
    | `getQuote(params)`             | read        | Free to poll. Requires `side`. A sell quote carries `assetAddress` and the asset's decimals. |
    | `buildBuyTransaction(params)`  | write       | Spends an attestation. `POST /v1/ondo/swap/buy`.                                             |
    | `buildSellTransaction(params)` | write       | Spends an attestation. `POST /v1/ondo/swap/sell`.                                            |
    | `prepareBuy(params)`           | client only | Approves the funding coin, then builds. Step-tagged result.                                  |
    | `prepareSell(params)`          | client only | Approves the asset, then builds. Step-tagged result.                                         |
    | `executeSwap(params)`          | client only | Broadcasts and confirms, either direction. Step-tagged result.                               |

    A purchase reads its token from the registry. A sale has to read the quote first, because that is the only place the asset's address and decimals are published:

    ```ts theme={null}
    // Selling. 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,                                   // your EvmWallet
      symbol: AssetSymbol("AAPLon"),            // Ondo's API symbol
      chain: "ethereum_mainnet",
      swapContracts: offer.swapContracts,       // the spender, resolved per chain
      tokenAddress: quote.assetAddress,         // the ASSET, not a stablecoin
      amount,                                   // BlockchainAmount, asset 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;
    }

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

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

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

    `prepareBuy` is the same call with `tokenAddress` set to the funding coin and `amount` in its base units, and it needs no quote first.

    Both flows are **total**: every failure comes back step-tagged rather than thrown, so you map each one to your own copy. `prepareBuy` and `prepareSell` return the same `OndoSwapPreparationError` - every arm names a *remedy*, and an unsupported chain or a refused approval is fixed the same way whichever token was being approved.

    **Preparation 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 popup)* → `confirming-approval` → `building-transaction`

    **`executeSwap` phases**: **`broadcasting-swap`** *(wallet popup)* → `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 trader gas, and it is fixed by building a fresh transaction.

    `executeSwap` broadcasts the calldata **verbatim**. Never re-encode it: the contract verifies a signature over the exact arguments inside.
  </Accordion>
</AccordionGroup>

## Which contract the trader approves

The ERC-20 spender comes off the offer, not from a constant in the SDK, and it works the same way on both sides. `OfferDetail.swapContracts` lists every chain the offer can be swapped on with the CoinList contract each settles through, and `swapSpender` reads one out:

```ts 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 - an answer, not 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.

<Note>
  The address varies by **environment** as well as by chain, which is why one constant could not express it: beta and production both run `ethereum_sepolia` and deploy a different contract on each. `ondoSwapContractAddress(chain)` was removed in v0.12.0 for that reason.
</Note>

## Next steps

<CardGroup cols={2}>
  <Card title="Building the Checkout flow" icon="cart-shopping" href="/sdk/checkout">
    The recommended path: one container for every provider, and both directions.
  </Card>

  <Card title="Wallets" icon="wallet" href="/sdk/wallets">
    The `EvmWallet` seam, and `preselected` for skipping the wallet step.
  </Card>

  <Card title="Superstate Swap" icon="arrows-rotate" href="/sdk/swap-flow">
    The other swap provider, and what it needs from you.
  </Card>

  <Card title="Errors and edge cases" icon="triangle-exclamation" href="/sdk/errors">
    Every error shape the flows return, and how to handle it.
  </Card>
</CardGroup>
