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:- 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.
- A wallet. Para’s embedded wallet is where the assets are delivered. You adapt it once to the
EvmWalletinterface. - A checkout.
CheckoutContainerreads an offer’stypeand renders the right provider’s flow: Ondo buy, Ondo sell, Superstate swap, or your own token-sale UI.
What You Need
- Node.js 18 or later, and a package manager.
- A Para API key and a completed Para React setup, so the user has an embedded EVM wallet.
- Passage OAuth credentials: a
client_id, a registeredredirect_uri, and aclient_secret. Request them by filling out this form. - 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
typefield, 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
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.
app/providers.tsx
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.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.lib/coinlist-server.ts
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.
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.fetch in application code.
app/api/coinlist/oauth/complete/route.ts
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.
app/api/coinlist/oauth/access-token/route.ts
auth.logout() on that object clears the session through the same store.
app/api/coinlist/oauth/logout/route.ts
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 has both verbatim, along with the read-only variant Next.js Server Components use.
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.
redirect_uri. useCompleteOAuth validates the redirect, POSTs to your complete endpoint, then runs coinlist.init() so getAccessToken sees the new session.
app/oauth/coinlist/callback/page.tsx
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.
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.
useParaEvmWallet.ts
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 typedWalletError, so you never parse a wallet error string yourself:
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.
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
Setpreselected 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: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:
lib/constants.ts
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.
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.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.
defaultCheckoutConfig({ ... }) fills in the entries that have a sensible default, so you only write the ones that do not.
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.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 isprepareBuy then executeSwap rather than a single call, and the split is where the buyer’s decisions are:
prepareBuyapproves the swap contract to spend the amount, then builds the transaction that spends it. The result is a committed quote: firm calldata with anexpiresAt.- The buyer reviews firm numbers: what they pay, the fee, what they receive.
executeSwapbroadcasts that calldata verbatim and waits for it to mine.
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.Driving it yourself (L1)
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-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.quote-expiredis 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:
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 sameondo::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:
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 becauseTOKEN_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
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.
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.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.Superstate Swap
Superstate supplies tokenized equities. A Superstate offer arrives withtype: "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.
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.
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
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.checking-status → checking-allowance → (resetting-allowance → confirming-allowance-reset, only for stale non-zero allowances) → approving (prompt) → confirming-approval → swapping (prompt) → confirming-swap
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.CoinList Token Sale
CoinList is one of the providers in the Passage catalog, and it runs its own token sales. An offer arrives withtype: "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.
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.
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.
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 oneuseParaEvmWallet 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, in particular Wallets: the EvmWallet seam, Building the Checkout flow, and Errors and edge cases.