Skip to main content
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.
Recommended: render CheckoutContainer. 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.
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:
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.
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.
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 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.
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.

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

What the two directions do not share

Only the transport is common. Beyond it, the amounts mean different things: 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.
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.

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 and the checkout opens on the amount, renumbering the two cards that remain:
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.
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.
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.
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: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 for the conventions they share.
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.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:
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-transactionexecuteSwap phases: broadcasting-swap (wallet popup) → confirming-swapTwo 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.

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

Next steps

Building the Checkout flow

The recommended path: one container for every provider, and both directions.

Wallets

The EvmWallet seam, and preselected for skipping the wallet step.

Superstate Swap

The other swap provider, and what it needs from you.

Errors and edge cases

Every error shape the flows return, and how to handle it.