AAPLon, TSLAon and the rest - and tokenized portfolios, baskets of holdings in one token. A tokenized security arrives with type: "ondo::swap", a portfolio with type: "ondo::portfolio", and both run both ways: a buy spends the offer’s funding token to receive the tokenized asset, a sell delivers the asset and settles the proceeds in that funding token. The funding token is USDC unless the offer names USDT or USDZ (see Which coin the order is in).
Recommended: render
CheckoutContainer. It detects ondo::swap and ondo::portfolio, reads the direction from its side prop, and renders the whole flow. This page covers what is specific to Ondo, and the levels below the component.offer.type stays ondo::swap (or ondo::portfolio) 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
Nothing beyond the offer, a chain, your wallets, a direction and the option the user is ordering (optionId, required even on an offer with one option - see Which option the order is for). There is no separate sell container, and no Ondo config is required - the side prop is what picks the flow:
side defaults to "buy", so a buy page can leave it out and a sell page passes side="sell". A page with a buy/sell toggle passes whatever the toggle selects, and puts it in the key.
side is a prop rather than something the offer says because no offer can answer it: offer.type is the same both ways. See side: buy or sell.
The SDK derives Ondo’s API symbol (for example AAPLon) from offer.asset.code. That is the key of every Ondo request - the trading status, the price and both builds - and there is no setting to override it.
config["ondo::swap"] accepts one optional field, 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.
A portfolio reads its own entry, config["ondo::portfolio"], with the same shape. The checkout reads config[offer.type], so a callback set only under ondo::swap never fires for a portfolio order. Set both entries to get it for every Ondo order:
Which coin the order is in
An Ondo order is denominated in the offer’s funding token onchain: the entry in offer.tokens with role: "funding" on that chain. A buy spends it, so the amount step shows its balance and the approval is granted for it. A sell settles into it, so the review step and the confirmation show the proceeds in it.
The SDK matches that address against the stablecoins it knows (USDC, USDT and USDZ) on that chain, ignoring case. When the offer lists no funding token on chain, or lists an address that is not one of those stablecoins, the order falls back to USDC.
Which option the order is for
Every Ondo order, buy or sell, names one of the offer’s options (offer.options). The SDK sends that option’s id with the order, and frontline runs that option’s requirements before it builds the transaction. An offer can have several options, each with its own requirements and minimum, so the order has to say which one the user was shown.
CheckoutContainer reads the option from its optionId prop, and the prop is required, even on an offer with one option. The SDK never picks an option for you, because a default would break silently the day the offer gains a second one. If your page auto-selects an option, pick it yourself and pass its id.
An optionId the offer does not list (which includes any id on an offer with no options) replaces the flow with an “Option not available” screen (reason: "unknown-option") and logs checkout blocked at error. The checkout sends no request, so the user never pays gas for an approval that frontline would then refuse.
On a buy, the amount step also checks the amount against that option’s minimum purchase (minimumPurchaseUsd). A sell has no minimum.
side, optionId is read once, at mount: the amount step checked that option’s minimum, and the order goes out under that option. To switch options, put the option in the key, as above.
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.
Portfolios
An Ondo portfolio (ondo::portfolio) trades exactly like a tokenized security: the same swap contracts, the same API symbol taken from offer.asset.code, the same required optionId, and the same two-call order. CheckoutContainer mounts the same Ondo buy or sell checkout for it. What differs is the data the offer carries and a few rows on screen.
OndoPortfolioOfferDetail (@alpha) leaves the security facts of a single stock (isin, ticker, instrumentType, listingVenue) null and carries a portfolio snapshot instead (OndoPortfolioSnapshot, @alpha): modelProvider, category, inceptionDate, netAssetsUsd, dividendYield, dividendYieldAfterWithholding, serviceFeeRate (after any waiver), serviceFeeRateBeforeWaiver, serviceFeeWaiverFraction, rebalanceFrequency, reconstitutionType, lastRebalancedAt, asOf and constituents. Rates and weights are decimal-fraction strings, so "0.0009" is 0.09%. Every field is null, and constituents is empty, until the first sync from Ondo. OndoOfferDetail is the union of OndoSwapOfferDetail and OndoPortfolioOfferDetail.
In the checkout, a portfolio differs from a security in two places:
- The review step, buy and sell, labels the price row Price per token instead of Price per share.
- The sidebar shows no price beside the asset. Its rows are Asset type (“Tokenized Portfolio”), Model provider, Annual service fee and Network. The security rows are never shown for a portfolio, even if the payload carries them. At L2,
OndoSidebarUiState.portfolioholds these facts as anOndoPortfolioFactsUi(@alpha), already formatted, and isnullon a security.
Show a portfolio’s details
PortfolioOfferDetails renders the read-only column of a portfolio offer page: the header, the asset details, the about text and links, the five largest holdings with a View all holdings link to Ondo’s page for the portfolio, and a fixed Ondo Global Markets disclaimer. It renders a PortfolioOfferDetailsUi and does not map the offer itself, so you call PortfolioOfferDetailsUi.fromDetail(offer, locale, tokenMetadata?) and lay the component out beside your own requirements card:
null or empty are left out. The holdings section is left out while the portfolio reports no holdings, and the about section while it has neither about text nor links.
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:prepareBuy/prepareSellapproves the swap contract to move the amount, then builds the transaction that moves it. The result is a committed quote: firm calldata with anexpiresAt, valid for about a minute.- The trader reviews firm numbers - what goes, the fee, what arrives.
executeSwapbroadcasts 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.
CheckoutContainer the split maps onto the steps:
- Confirming the amount runs
prepareBuyorprepareSell: the approval prompts, then the build. A failure shows beside the amount step’s button. If the user has not met the option’s requirements, it reads “Complete the requirements for this offer before placing an order.” If the approval has not reached the build yet, it reads “Your approval has not landed yet. Please try again in a moment.” A blocked wallet and any other failed build read “Could not get a quote for that amount. Please try again.” - The review step offers the committed quote for at most 15 seconds, or until its
expiresAtif that comes sooner, with a countdown above the button. Once it runs out, Place order is disabled until the user picks Refresh quote, which builds a new transaction and spends another attestation. Nothing refreshes on a timer. - Place order runs
executeSwap. While the order is placing, the quote line is hidden: the order goes out against the numbers already signed, so there is nothing more to say about the quote until it settles.
https://coinlist.co/terms/ondo. The link is fixed, because the disclaimer names Ondo’s terms specifically.
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.When the market is closed
Ondo assets trade only while the market is open, and Ondo pauses briefly at every US session change (04:00 and 16:00 ET). While the trading status for the page’s side reportsnot-tradable, the checkout replaces the flow with a “Trading is closed right now” screen and pauses the price poll. The trading-status poll keeps running underneath, so the checkout comes back by itself once the market opens. There is no retry button, because a retry before then fails the same way.
The screen appears only while the user is still on the step the flow opened on and no order dialog is showing. A market that closes later in the flow is reported by the step it interrupts.
At L1, passage.ondo.getQuote rejects a quote for a closed market with an HttpError (status 409, API code not_tradable). isOndoNotTradableError(error) from @passage-cl/react/universal recognizes it, so you can tell “wait for the market” apart from a failed request.
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 thanspendInputAmount.
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 the offer’s funding token. 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
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 the funding token (see Which coin the order is in), 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. SetCheckoutWalletSelection.preselected and the checkout opens on the amount, renumbering the two cards that remain:
embedded or external - you supply a ready EvmWallet either way. Like side, it is read once at mount, so add wallets.preselected?.address to the key if it can change on one page.
Both directions honor preselected, so a buy page that already knows the wallet, such as a host with a single embedded wallet, can skip the step too. After an order, the flow resets to the amount step rather than to a wallet step it never had.
When the wallet disconnects
The checkout holds the confirmed wallet by its address and looks it up in thewallets you pass on every render. A fresh EvmWallet object at the same address is picked up without any reset.
When you stop supplying a wallet at that address (the user disconnected it, or the external wallet switched accounts), the checkout reopens the wallet step with “Your wallet disconnected - reconnect or choose another.” It waits until no step is busy: an approval or a swap the wallet is sending settles first, and after swap-unconfirmed the review step keeps its warning until the user leaves it.
A flow opened on preselected has no wallet step to go back to. Its next send fails and shows the same disconnected copy. Your EvmWallet reports a changed or disconnected account by throwing WalletDisconnectedError from @passage-cl/react/universal. See Let wallet errors propagate.
When the user signed in with their wallet
A user who signs in with their wallet rather than a CoinList login has a wallet session, which acts for one wallet on one chain.CheckoutContainer settles the session before it mounts either direction of the flow, and shows a skeleton until PassageProvider is ready. Then the first row that matches applies:
Neither card has a button, so your page renders its own way forward.
The checkout applies these rows on every render. A new wallet session remounts it, with no
key from you, and a wallets.external that stops being the session’s wallet replaces it with the card.
If the option’s requirements cannot be read, the checkout skips that row, and CoinList decides when the order is built: confirming the amount shows the requirements message above.
A wallet session cannot read offers in the browser, because useOffers and useOfferDetails need a CoinList user. Read the offer on your server and pass it in.
Build your own UI with hooks (L2)
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: offer (an OndoOfferDetail, so a tokenized security or a portfolio), chain, wallets, optionId, enabled? and onOrderConfirmed?. optionId works as the CheckoutContainer prop does: it is required, even on an offer with one option, and it is read once.OndoBuyCheckoutUiState and OndoSellCheckoutUiState are both discriminated unions with a flow-level error arm, whose reason is one of:unsupported-chain: the offer names no CoinList swap contract on the chain you passed. The viewmodel discovers that before any step runs, so nothing is fetched and no timer starts.unknown-option:optionIdnames an option the offer does not show. The viewmodel settles this at mount too, so nothing is fetched, and only a newoptionId(a remount) clears it.not-tradable: the market is closed for this side, and the user is still on the step the flow opened on. The trading-status poll keeps running, so the state returns tocontentonce the market opens.
switch exhaustively and assign the default to const exhaustive: never = state so a new arm is a compile error rather than a blank screen.These viewmodels do not check the session, which CheckoutContainer does for you (see When the user signed in with their wallet). To run the same check over your own UI, call useCheckoutSessionViewModel (alpha) with offer, optionId, chain, and wallets, plus enabled: false while your UI is paused. Mount your viewmodel once its state.type is open, passing state.wallets and keying it by state.session.Inside the content arm of either flow, 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 review step’s quote is an OndoQuoteStatusUi, the same type on both sides: valid with a countdown label, refreshing, expired, refresh-failed with a message, or placing. The viewmodel reports placing exactly while the place-order button is loading. Render no quote line for it, and give it a case in any exhaustive switch.The step viewmodels are not exported. The data hooks the god viewmodels are built from are, so you can assemble your own viewmodel instead:Each returns a
state union of DISABLED, LOADING, ERROR and CONTENT. DISABLED means the hook is switched off with no answer in hand. On the Ondo hooks, a seed or an earlier answer survives being switched off. useErc20Balance drops its balance and reports DISABLED whenever it is switched off. An Ondo ERROR carries an OndoLoadErrorReason: not-authenticated, generic-error or not-tradable.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.Drive it yourself with the client (L1)
Drive it yourself with the client (L1)
passage.ondo is a plain namespace with no React. The reads and the two builders exist on both the browser client and PassageServer; the wallet-driven halves are client-only, because a backend has no wallet to sign with. The reads and builders take a CoinList login or a wallet session, and throw NotAuthenticatedError with neither. A wallet session builds only for its own wallet and chain: CoinList answers any other with a 403 HttpError, which prepareBuy and prepareSell report as build-transaction.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 token and amount in its base units, and it needs no quote first. Resolve the funding token the way the checkout does: the offer’s funding token on the chain if it is a stablecoin the registry knows, otherwise USDC.offerOptionId is required on all four order methods - prepareBuy, prepareSell, buildBuyTransaction and buildSellTransaction - and the SDK sends it to frontline as offer_option_id. Take it from offer.options: it must be the id of the option whose requirements the user was shown. Frontline runs that option’s requirements before it builds. The field is required even on an offer with one option, so that you cannot forget it on an offer with several, where frontline refuses to pick one for you. prepareBuy and prepareSell send it with the build, which comes after the approval: frontline refuses an id that the offer does not list, so the prepare methods report build-transaction after the user has already paid gas for the approval. Check the id against offer.options before you call them.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 (wallet popup) → confirming-allowance-reset, only for stale non-zero allowances) → approving (wallet popup) → confirming-approval → building-transactionexecuteSwap phases: broadcasting-swap (wallet popup) → confirming-swapFour error steps are worth calling out:insufficient-allowanceis 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.build-refusedmeans CoinList declined to build for a reason the user can act on, so retrying fails the same way until they do. Itsreasonis anOndoSwapRefusal, whose one value isrequirements-not-met: the user has not completed the requirements of the optionofferOptionIdnames. It arrives after the approval, which stays mined. A blocked wallet has no error code, so it staysbuild-transaction. Called directly,buildBuyTransactionandbuildSellTransactionreject with anHttpErrorinstead (status 422, API coderequirements_not_met).quote-expiredis 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.swap-unconfirmedmeans the swap was sent but waiting for its receipt failed. It may still mine, so placing the order again could fill it twice. The error carries thetxHash: look it up before doing anything else.swap, by contrast, means the wallet never sent it, so placing again is safe. Itscauseis aWalletError: a swap that reverts at gas estimation, before the wallet sends it, is{ type: "contract_reverted", reason }, with the contract’s revert reason where it gave one. The SDK’s review step disables its button afterswap-unconfirmeduntil the user leaves the step, with “Your order was sent but has not confirmed yet. Check your wallet before placing another.”
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 and for portfolios.swapContracts on an OndoSwapOfferDetail or OndoPortfolioOfferDetail 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.