Skip to main content
This changelog covers the @passage-cl/react SDK. Up to v0.12.1 it was published as @coinlist-co/react.
November 06, 2026 - Wallet sign-in for users with no CoinList account, AuthState becomes an object, and access tokens carry signInMethod
A user with no CoinList account can now sign in with their wallet, then buy and sell Ondo and buy, sell, and claim Centrifuge. Every host has to migrate getState() checks and any code that builds an access token, even if it never turns wallet sign-in on.
This is a 0.x minor with breaking changes. useCheckoutSessionViewModel and CheckoutSessionUiState are @alpha, outside the compatibility contract. There are no new peer dependencies.

Breaking Changes

AuthState is an object

passage.auth.getState() and passage.init() return an object, and a logged-in state says how the user signed in.Migration: compare type.
In plain JavaScript the old check is always false, with no error, so a logged-in user reads as logged out. TypeScript reports it as TS2367.

OAuthAccessToken requires signInMethod

signInMethod is { method: "coinlist" } for a CoinList login, or { method: "wallet", wallet: { address, chain } } for a wallet sign-in. A getAccessToken or getSession that builds its own token stops compiling, even on a host with only CoinList logins.Migration: update all four places. Reads fail to compile, but writes do not, because they copy fields by hand. A host that fixes only the reads with a fallback compiles cleanly and turns every wallet session into a CoinList one.
Upgrading signs no one out: at runtime, a token without the field reads as a CoinList login and, if you set a logger, logs a warning. See OAuth authentication.

ClientCredentialsOAuth is no longer an OAuthAccessToken

An app-level token has no user behind it, so it has no signInMethod, and it no longer assigns to OAuthAccessToken (TS2741). The ClientCredentialsOAuth constructor takes value and expiresAt only.Migration: type the variable as ClientCredentialsOAuth, and keep app tokens out of anything typed OAuthAccessToken.

Added

Wallet sign-in

A wallet session is for one address on one network, for a user with no CoinList account. Wallet sign-in needs React for now, because only PassageProvider can turn it on. Sign in with wallet has the full setup.On your server:In the browser:
  • walletSignIn on PassageProvider takes { challenge, complete, signOut, connection }: three calls to your backend, and your app’s WalletConnection (connecting, disconnected, or connected with an address and chain).
  • useWalletSignIn() returns { state, signIn }. signIn(wallet) resolves to a WalletSignInResult: signed-in, canceled, or failed with the step that failed (challenge, signature, or complete). A call while one is pending starts nothing and resolves with the pending one’s result.
  • passage.auth.signInWithWallet({ wallet }) is the same sign-in without the hook. Both throw WalletSignInConfigRequiredError without the walletSignIn prop.
  • usePassage().authState is passage.auth.getState() as React state, so a component re-renders on a sign-in or sign-out.
Any wallet change, network change, or disconnect ends a wallet session like a logout, and so does a checkout on another network. The SDK never navigates and never switches networks. Without the walletSignIn prop, wallet sign-in is off and the SDK does not follow your wallet connection.
To turn on wallet sign-in:
  • Block cross-site requests on your challenge and complete endpoints. Otherwise another site can sign the user into an attacker’s wallet session.
  • Keep signInMethod in your session store and access-token endpoint. Without it, a wallet session reads as a CoinList login and is never signed out when its wallet leaves.
  • Give your OAuth client the revoke grant type. Without it, auth.logout swallows the failed revoke, so the user reads as signed out while the token stays valid until it expires. This affects a CoinList logout too, and existing clients may not have it, so ask your account manager to check.
  • Map a wallet library’s “reconnecting” to connecting, or every reload signs the user out. Pass disconnected for a wallet on a network the SDK does not support.
  • Read offers on your server. The browser SDK refuses offers.* on a wallet session, so useOffers and useOfferDetails cannot load for one.

What a wallet session can call

The right-hand column throws NotAuthenticatedError on a wallet session, except wallets.connect, which resolves with the error code not_authenticated. A wallet session builds only for its own wallet and chain, and CoinList answers 403 to any other. On a server, offers.* called with an app-level token still works during a wallet session, and sends the app token.

Checkout on a wallet session

CheckoutContainer handles this for Ondo and Centrifuge, with no new props. A user signed in with their wallet sees:A Centrifuge sell names no option, so it runs no requirements check. These cards have no button, so render your own sign-in or connect action near the checkout. Superstate and the token sale do not run this check, so don’t render them on a wallet session: it can’t allowlist a Superstate wallet or record a token sale participation.On a wallet session, ClaimContainer’s Centrifuge rows read and claim only the session’s wallet. Its rows on other networks still show, but fail to claim. The footer still shows whichever wallet is connected.

Other additions

  • signInMethodFor(requirements) from /universal returns "coinlist" for any requirement and "wallet" for none, so a server can choose the page’s sign-in button before any user is known.
  • useOfferRequirements({ offerId, data?, enabled? }) reads an offer’s requirements without statuses, so a wallet session can read them too.
  • useCheckoutSessionViewModel (@alpha) gives a checkout with its own UI the same session handling as CheckoutContainer.
  • OndoSwapRefusal and a build-refused arm on OndoSwapPreparationError, for an order CoinList refuses because its requirements are not met. An exhaustive switch on the error gets a compile error until it handles the new arm.
  • SignInMethod, SessionWallet, SiweMessage, WalletSignInChallenge, WalletSignInChallengeParams, and WalletSignInGrant are new types from /universal.

Changed

These apply to every host, with or without wallet sign-in.
  • Ondo and Centrifuge checkouts show a skeleton until PassageProvider is ready.
  • An Ondo requirements_not_met now says “Complete the requirements for this offer before placing an order.” instead of asking the user to try again.
  • A wallet that rejects with a plain 4001 now reads as canceled rather than failed.
November 02, 2026 - Checkouts require optionId, ClaimContainer shows several offers in one card, and Centrifuge offers carry their underlying fund
Every checkout now needs the option the user is buying, so a second option on an offer can never break orders. ClaimContainer takes a list of offers and shows them in one “Your orders” card, and Centrifuge vault offers name the fund they invest in.
This is a 0.x minor with breaking changes. Required optionId and the new useCoinListTokenSaleParticipations argument touch the @public surface. The claim card, its viewmodels and Centrifuge are @alpha, outside the compatibility contract.

Breaking Changes

optionId is required on every checkout

CheckoutContainer and the checkout viewmodels require optionId. The SDK no longer picks an option for you.It is required for every offer type, even where it is not sent: the Superstate swap and a Centrifuge redemption (side: "sell") still ignore it. unknown-option now means only that the offer does not list the option you named. Nothing changes on the wire.The viewmodels that now require it: useOndoBuyCheckoutViewModel, useOndoSellCheckoutViewModel, useCentrifugeBuyCheckoutViewModel and useCoinListTokenSaleCheckoutViewModel.Migration: pass the option the user picked, and put it in the key. If you want a default, choose it yourself:
See Checkout.

ClaimContainer takes offers

The offer prop is now offers: AsyncOfferDetail | AsyncOfferDetail[]. One offer still works as before.Migration: rename the prop.

One claim viewmodel

useClaimViewModel (@alpha) replaces the two per-provider claim viewmodels and their types.useClaimViewModel takes the same inputs as ClaimContainer: { offers, wallets, walletLabel?, config?, enabled? }. Pass onClaimed through config["centrifuge::vault"]. There is no pollInterval option, and no data seed for token-sale participations.ClaimUiState’s CONTENT arm holds rows: ClaimRowUi[], each tagged with its offer type, plus empty: ClaimEmptyUi for the empty-state copy.Migration:

useCentrifugeClaimPositions reads several offers

The @alpha option vaults: CentrifugeVault[] is now vaultsByOffer: CentrifugeVault[][]: one list per offer, each with one vault per chain. Wrap a single offer’s vaults in a list: vaultsByOffer: [vaults]. The state is ERROR when any offer has no read that answered.

Added

Several offers in one claim card

ClaimContainer renders any mix of Centrifuge vault and CoinList token-sale offers in one “Your orders” card, in the order you pass them.
  • The card has one header, one empty state, and one wallet footer. The footer shows only when a vault offer is on the card.
  • It loads until every offer has answered. If any offer fails, it shows one error, and Retry refetches every offer. An error shows even while another offer is still loading.
  • Rows come in this order: claimable vault positions, pending vault positions, then token-sale participations, newest first.
  • With one offer and no orders, the card shows that provider’s copy. With two or more, it shows “No orders yet.” A card of vault offers only renders nothing when it has no rows and externalAdapter is null.
  • An empty list renders nothing.
  • Each provider reads all of its offers on one poll. A claim collects one row, on that row’s offer.
config and enabled apply to every offer. See Centrifuge vaults.

CentrifugeVaultOfferDetail.underlyingFund

Centrifuge vault offers carry underlyingFund: string | null, the fund the share token invests in. It is null until it is recorded for the offer. The Centrifuge checkout sidebar now shows it, on buy and sell.

Changed

  • useCoinListTokenSaleParticipations takes offers?: OfferId | readonly OfferId[] as its first argument (was offerId?: OfferId). Existing calls still compile. A list is read offer by offer on one poll, and the hook settles once every offer has answered. Any failure fails the whole read. A new array with the same ids does not refetch. See Invest flow.
  • CentrifugeVaultClaimed gains offerId, so onClaimed tells you which offer was claimed.
  • CentrifugeVaultLegId now includes the vault address, so two vaults on one chain no longer share an id. Treat it as opaque.
October 29, 2026 - Centrifuge claims wait for the host's wallets, Ondo hides its quote line while placing, and CoinList terms links
The Centrifuge claim card no longer flashes “No orders found” while the host is still restoring its wallets. The Ondo review hides its quote countdown while an order is being placed, and the Centrifuge and Ondo checkouts link to CoinList’s terms.
Centrifuge is @alpha, outside the compatibility contract. The @public surface only grows: WalletSelection gains an optional status, and OndoQuoteStatusUi gains a placing arm.

Added

WalletSelection.status

WalletSelection takes an optional status: 'loading' | 'ready', which defaults to 'ready'. Pass 'loading' until every embedded wallet the user has is listed, not as soon as your wallet library says it is ready: a library can be ready before it has restored the user’s wallets.
The Centrifuge claim card reads it (ClaimContainer, and useCentrifugeClaimPositions as the @alpha walletsStatus option):
  • While 'loading', before a first answer, the card shows its loading state. It sends no request and starts no poll.
  • It shows “No orders found for this asset.” only once every wallet it asked about has answered.
  • When a wallet is added, the card shows loading in that same render instead of the earlier empty answer. Rows it already shows stay visible while the new wallet is read.
  • Going from 'ready' back to 'loading' keeps the orders and the connect footer the card last showed until 'ready' returns.
Omitting status keeps the old behaviour: with no wallets, the card says “No orders found for this asset.” See Centrifuge vaults.

Changed

  • The Ondo quote line (“Quote expires in N seconds” and “Refresh quote”) is hidden while the place-order button is loading, on both buy and sell, and comes back once it stops. OndoQuoteStatusUi gains { type: 'placing' }, which useOndoReviewExecution returns exactly while an order is being placed. An exhaustive switch over OndoQuoteStatusUi needs a case for it. See Ondo swap.
  • Terms links in the review step now point at CoinList: https://coinlist.co/terms/centrifuge for Centrifuge (was https://centrifuge.io/terms) and https://coinlist.co/terms/ondo for Ondo (was https://docs.ondo.finance/legal/terms-of-service).

Fixed

  • coinlistBaseUrl with a trailing slash or a path. auth.startOAuth() and requirements.handle join coinlistBaseUrl to the page path with exactly one slash. Before, a trailing slash gave a // in the sign-in redirect, and requirements.handle dropped any path after the origin.
October 27, 2026 - Checkouts follow a disconnected wallet, the Centrifuge deposit fee, and Offer drops category
Every checkout now notices when the host stops supplying the wallet the user confirmed, and sends them back to the wallet step. A wallet that has disconnected gets its own WalletError arm and reconnect copy. A Centrifuge deposit builds its order on the amount step, so the review shows the fee the API charges.
Centrifuge is @alpha, outside the compatibility contract. In the @public surface, category is removed from offers and WalletError gains an arm. WalletDisconnectedError and WalletSelection.find are new.

Breaking Changes

category is removed from offers

OfferDetailBase.category is gone from every OfferDetail. The API no longer sends it.Migration: delete every read of offer.category. To group offers, switch on offer.type.

WalletError gains disconnected

WalletError gains { type: 'disconnected' }: the wallet is no longer connected, or no longer on the account the SDK was handed, so nothing was sent. Retrying through the same wallet fails the same way, so the user has to reconnect or choose another wallet. The SDK classifies these as disconnected:
  • an EIP-1193 error with code 4100 anywhere in the cause chain;
  • wagmi’s ConnectorNotConnectedError;
  • the new WalletDisconnectedError.
Migration: an exhaustive switch over WalletError needs the case.

Added

WalletDisconnectedError

Throw WalletDisconnectedError from your EvmWallet when the account the SDK was handed is gone, for example after the user switched accounts. It is exported from @passage-cl/react/universal.
WalletSelection.find(selection, address) returns the EvmWallet a selection currently holds for an address, or null. See Wallets and Errors.

Centrifuge deposit fee

CentrifugeVaultBuyTransaction gains fee, a BlockchainAmount in the funding token. It is taken out of the deposit, not added on top.useCentrifugeBuyTransaction (@alpha) builds a deposit request on its own: pass request (a BuildCentrifugeVaultBuyParams, or null), enabled and an optional data seed; it returns { state, refetch }. See Centrifuge vaults.

Changed

  • Checkouts follow the wallet the host supplies now. The token sale, Ondo buy and sell, Superstate and Centrifuge checkouts hold the confirmed wallet by its address and look it up in the current wallets on every render, so each step sends through the wallet you supply now. When you stop supplying that address, the checkout reopens the wallet step with “Your wallet disconnected - reconnect or choose another.” A send already in flight settles first. A checkout that skipped its wallet step for a preselected wallet (every provider but Superstate, which keeps it) has no step to return to, so there the user sees the disconnected copy only if a send fails. The Centrifuge claim card looks up its wallet the same way.
  • A Centrifuge deposit is built on the amount step. Confirming the amount approves the funding token and requests the deposit (POST /v1/centrifuge/vault/invest), so the review opens with the built request and its fee, and Place only sends it. An approval or request failure shows on the amount button. The review shows the API’s fee, 0.00 USDC included, and a total equal to the amount. A request that expires before Place is rebuilt once, and so is one the vault refuses as request-expired at Place. A failed rebuild shows an error with Retry. A redemption is unchanged.
  • Centrifuge buy review types (@alpha): CentrifugeBuyCheckoutUiState.review is a CentrifugeBuyReviewUiState, which adds { type: 'loading' } and { type: 'error'; reason: CentrifugeBuyReviewErrorReason } ('build-failed' | 'expired') to CentrifugeReviewUiState. CentrifugeBuyCheckoutUiEvent takes CentrifugeBuyReviewUiEvent, which adds { type: 'ON_RETRY' }.

Fixed

  • The Centrifuge deposit’s confirmation dialog shows the token’s icon. Before, it showed none.
October 26, 2026 - Orders name the offer option, Centrifuge claims go through the API, and dark-theme token logos
Ondo and Centrifuge orders now name the offer option the user chose, so the API runs that option’s requirements and the checkout shows that option’s minimum. Centrifuge claims are built by the API again, now that it serves them. The requirements checklist takes a label for its Continue button, and the checkout shows a token’s dark logo in the dark theme.
Centrifuge is @alpha, outside the compatibility contract. The required offerOptionId on Ondo orders is the one break in the @public surface.

Breaking Changes

Ondo orders require offerOptionId

passage.ondo.prepareBuy, prepareSell, buildBuyTransaction and buildSellTransaction require offerOptionId. The SDK sends it to the API as offer_option_id, and the API runs that option’s requirements. An offer can have several options, each with its own requirements and minimum. Before, an order on such an offer that named no option was refused only after the user had paid gas for the approval.The request you pass to useOndoBuyTransaction and useOndoSellTransaction (BuildOndoBuyParams, BuildOndoSellParams) requires it too.The Centrifuge equivalents, passage.centrifuge.prepareBuy, buy and buildBuyTransaction, require it as well. A Centrifuge redemption sends no option id.Migration: pass the id of the option whose requirements you showed the user.

Centrifuge claims are built by the API

passage.centrifuge.claim asks the API for the claim transaction, has the wallet send it, and waits for it to confirm. Before, the SDK encoded the call to the vault proxy itself. The API claims everything claimable, read on-chain when it signs, so no amount is sent.CentrifugeClaimError also gains { step: 'build-refused', reason }, where reason is a CentrifugeClaimRefusal ('vault-refused' or 'no-proxy-on-chain'), { step: 'build-transaction' } and { step: 'claim-expired' }.Migration:

Added

optionId for Ondo and Centrifuge checkouts

CheckoutContainer’s optionId prop now applies to Ondo buy and sell and to a Centrifuge deposit. The checkout sends the option’s id with the order. An Ondo buy and a Centrifuge deposit check the amount against that option’s minimum; an Ondo sell has no minimum.
  • With no optionId, the checkout uses the offer’s only option.
  • An offer with several options and no optionId, an id the offer does not show, or an offer with no options replaces the checkout with “Option not available” (unknown-option). No request is sent.
  • Superstate and a Centrifuge redemption ignore it. The token sale still buys its first option when none is named.
  • The checkout reads optionId once, at mount. To switch options, remount it with a key.
useOndoBuyCheckoutViewModel, useOndoSellCheckoutViewModel and useCentrifugeBuyCheckoutViewModel take the same optionId option. See Checkout.

Centrifuge claim transactions

It returns a CentrifugeClaimTransaction: { tx, expiresAt, direction, claimAmount }. claimAmount is in the funding token for a deposit and in shares for a redemption.

Requirements checklist continueLabel

RequirementsChecklistContainer and useRequirementsChecklistViewModel take an optional continueLabel, which defaults to "Continue". Pass continueLabel="Buy" to say where the button goes. RequirementsChecklistUiState.continueAction gains label. See Requirements.

USDZ

StablecoinSymbol gains 'USDZ', a test dollar anyone can mint on Ethereum Sepolia. USDZ, USDZ_SYMBOL and TOKEN_REGISTRY.usdzAddress(chain) are new. A Centrifuge vault on ethereum_sepolia takes USDZ, and a token sale that lists USDZ can be paid in it there. Centrifuge on every other chain, and Ondo and Superstate everywhere, still take USDC. Code with an exhaustive switch over StablecoinSymbol needs the case.

Dark-theme token logos

The checkout shows a token’s dark logo in the dark theme when the token registry has one. CheckoutAssetUi gains iconUrlDark.

Changed

  • CheckoutBlockedReason is now 'unsupported-chain' | 'unknown-option' | TBlocked, so OndoCheckoutErrorReason and CentrifugeCheckoutErrorReason gain 'unknown-option'. The token sale already had it. An exhaustive switch over either reason needs a case for it.
  • The Centrifuge claim dialog shows three steps: “Preparing your claim”, “Confirm in your wallet” and “Confirming on-chain”. Wallet failures use the same messages as the checkout. Retry is offered once when the vault refuses the claim, and never for a claim that may still confirm, for nothing to claim, or for a chain with no proxy.
  • The Centrifuge claim card reads “No orders found for this asset.” and “Invested with an external wallet? Connect it to view your orders.” Claimable rows explain the ~ estimate in a tooltip.
  • A blocked checkout logs checkout blocked once per reason: at error for a configuration problem (unsupported-chain, unknown-option, no-price, no-payable-asset), at warn for market state (not-tradable, not-orderable). Superstate’s load-failed screen logs no such line.
  • At debug, every transaction logs when it is sent and how it ends, and a reverted transaction is replayed to log the contract’s revert reason. Below debug the replay makes no request. The debug line for an HttpError includes the response body and request id.

Fixed

  • A transaction built by the API (Ondo swaps, Centrifuge requests, the Superstate allow-list) that reverts during gas estimation is now a contract_reverted WalletError with its reason. Before, it was unknown.
  • A stale Centrifuge request now reports request-expired, as v0.14.0 described. Before, the check never matched and the checkout showed a generic error.
  • Retrying a failed Centrifuge claim uses the wallet the host supplies now for that address. Before, it reused the adapter from the first attempt and failed again after the wallet changed account or chain. If the address is no longer connected, Retry closes the dialog.
October 21, 2026 - The Centrifuge sell checkout, on-chain Centrifuge claims, and the coinlist_account requirement
This release completes the Centrifuge vault journey. CheckoutContainer now mounts a redemption checkout for side="sell", and the SDK encodes claims on-chain itself instead of asking the API for calldata. The vault status reports each direction separately, and the API’s refusals arrive as typed reasons. The requirements checklist knows the new coinlist_account requirement.
Centrifuge is @alpha, outside the compatibility contract, so its breaking changes ship in a minor release. Nothing outside the Centrifuge types breaks.

Breaking Changes

Centrifuge claims are encoded by the SDK

passage.centrifuge.createClaim and passage.centrifuge.createClaimProceeds are removed, with CentrifugeVaultClaimTransaction and CentrifugeVaultClaimProceedsTransaction. Their endpoints, POST /v1/centrifuge/vault/claim and /claim-proceeds, do not exist on the API. A claim is the vault proxy’s own unauthenticated call, so passage.centrifuge.claim now calls the proxy’s deposit or redeem from the wallet with the maximum amount, which claims everything claimable. No API request and no approval come first.CentrifugeClaimSettlement is { assets, shares }, read from the Deposited or Redeemed event in the receipt. It is null only when the receipt cannot be decoded.Migration:

Centrifuge vault status reports each direction

CentrifugeVaultStatus is now { chain, symbol, deposit, redeem, asOf }. deposit and redeem are each a CentrifugeDirectionState: 'active', 'paused' (it can come back) or 'stopped' (permanent for that proxy on that chain).Migration:

Centrifuge checkout and claim types

Migration: a config["centrifuge::vault"].onOrderPlaced handler now runs for both directions. Read order.side before order.transaction.

Added

Centrifuge sell checkout

<CheckoutContainer side="sell"> for a centrifuge::vault offer now mounts a redemption checkout. Before, it rendered an empty container.
  • The steps are wallet, amount and review. A preselected wallet skips the wallet step, as on the buy side.
  • The amount is a number of the vault’s share tokens, bounded by the wallet’s balance. The offer’s dollar minimum applies to deposits only.
  • Placing the order approves the vault proxy for the shares when the allowance is short, builds the request and broadcasts it. A redemption pays nothing yet: the fund fulfils it later, and the user claims the USDC in ClaimContainer.
  • The checkout blocks with “Redeeming is paused right now” when the vault’s redeem direction is paused or stopped.
  • config["centrifuge::vault"].onOrderPlaced fires after the redemption request mines, with order.side === "sell".
  • To build your own UI, use useCentrifugeSellCheckoutViewModel (@alpha) with CentrifugeSellCheckoutUiState and CentrifugeSellCheckoutUiEvent.
passage.centrifuge.placeRequest now broadcasts either a buy or a redeem transaction.

Typed Centrifuge refusals

When the API refuses to build a Centrifuge request, prepareBuy, prepareSell, buy and sell return { step: 'build-refused', reason } instead of build-transaction. reason is a CentrifugeRequestRefusal: 'requirements-not-met', 'insufficient-balance', 'direction-paused', 'direction-stopped', 'vault-refused' or 'no-proxy-on-chain'. The checkout shows its own message for each. See Errors.ApproveThenBuildError and ApproveThenBuildResult take this reason as an optional type parameter that defaults to never, so the Ondo error types are unchanged.

Claim estimates

Each claimable row and the claim dialog show a ~ estimate of what the claim pays, at the vault’s spot price. After the claim mines, the dialog shows the exact amount from the receipt. CentrifugeDepositPosition gains estimatedShares, CentrifugeRedeemPosition gains estimatedAssets, and CentrifugeClaimRowUi gains estimate.

coinlist_account requirement

RequirementType gains 'coinlist_account'. The checklist shows it as “CoinList Account”, “Sign in with a CoinList account.” The user meets it by signing in, so its action opens no page. Code with an exhaustive switch over RequirementType needs the case. See Requirements.

Changed

  • The Centrifuge buy checkout blocks only when the vault’s deposit direction is paused or stopped. Before, it also blocked on an unlinked, halted or unpermissioned vault, or a stale price.
  • The Centrifuge claim card no longer polls the vault status. Each row leads with what the claim pays, in that token’s icon, and the claim dialog is titled with it (“Claiming ~964.12 deJTRSY”).
  • The Centrifuge order-placed dialog shows the amount invested or redeemed, the receiving wallet, and a notice to claim once the order is processed.

Fixed

  • Centrifuge claims work. Before, both claim directions failed with a 404.
  • A Centrifuge request whose signature went stale, because it expired or a claim moved the wallet’s nonce, now reports request-expired, so placing it again builds a fresh one.
October 19, 2026 - The SDK moves to @passage-cl/react, a smaller public API, the token-sale and Centrifuge checkouts, ClaimContainer, and session expiry handling
The SDK has moved to a new package: @passage-cl/react. v0.13.0 and every later version are published only under the new name. @coinlist-co/react stops at v0.12.1, gets no further updates, and will be deprecated on npm. To upgrade, uninstall the old package, install the new one and update every import path.
This release renames the SDK to Passage, in its package name and in its exported names. There are no deprecated aliases, so every app has to migrate. It also shrinks the public API to root Containers, their viewmodels, data hooks and namespaces, and the package now ships as ESM only. CheckoutContainer takes side as a prop and needs no config. It mounts the SDK’s own CoinList token-sale checkout and a Centrifuge vault checkout, and honors a preselected wallet in every flow. The new ClaimContainer shows the second leg of an asynchronous offer. Session renewal is single-flight, and the new onSessionExpired tells you when a session ends.

Breaking Changes

The package is now @passage-cl/react

The SDK is published as @passage-cl/react. The /universal and /server entry points keep their subpaths. See the install commands and the table above.Migration: replace @coinlist-co/react with @passage-cl/react in package.json and in every import, then apply the renames below.

Exported names are rebranded to Passage

Every export that named the SDK after CoinList is renamed. The old names are gone.The field on the hook result is renamed too: const { coinlist } = useCoinList() becomes const { passage } = usePassage(). The members of the client and server are unchanged, so coinlist.offers.list() becomes passage.offers.list(). The provider-missing error now reads usePassage() must be used within PassageProvider. Wrap your app with <PassageProvider config={...}>.Names that mean CoinList the company keep the CoinList name. These include CoinListSignInButton, CoinListSignInCardContainer, the CoinListTokenSale* checkout and participation names, passage.tokenSale, the coinlist::* offer types, ClientConfig.coinlistBaseUrl, and the coinlist.* browser storage keys.Migration: a find-and-replace over your source, in this order:
Then rename the destructured coinlist field of usePassage() to passage.

CSS class names and selectors are rebranded

The --color-* design tokens keep their names. Only the selector they are defined on changed.Migration: a stylesheet that overrides tokens on .clco-sdk-root must target .pcl-sdk-root instead.
The name on every log record from pinoClientLogger and pinoServerLogger is now @passage-cl/react. Update any log filter that matches on the old name.

ESM only, Node engines and bounded React peers

  • The package ships ES modules only. The CommonJS builds, main, module and the require export conditions are removed. Bundlers and Next.js consume ESM natively. A plain-Node require() works only through Node’s built-in require(esm).
  • engines.node is now ^20.19.0 || >=22.12.0.
  • The react and react-dom peer ranges are now ^18.0.0 || ^19.0.0, instead of >=18.0.0. viem ^2 is unchanged.

Universal names are exported from /universal only

Each name now has exactly one entry point. These names were exported from both @coinlist-co/react and /universal, and are now exported only from @passage-cl/react/universal:HttpError, HttpResponse, EvmWallet, EvmSigner, ConnectWallet, BroadcastTxParams, WriteContractParams, WalletError, RedactedWalletError, RpcFailureReason, KycToken, KycLevelName, Logger, LogLevel, ProductionLogLevel, LogScope, LogValue, LogCause, LogBinding, LogBindings, SafeEvent, SafeFields, DebugEvent, UnredactedFields, RequestId, FrontlineEventId, PinoLoggerOptions.Migration:

The public API is Containers, viewmodels, data hooks and namespaces

The public surface is now: each feature’s root Container, the viewmodel behind it, the data hooks it is built from, and the namespaces. Views, sub-screens, step viewmodels and the standalone flow functions are no longer exported. To build your own UI, use the product’s top-level viewmodel, whose state still carries each step’s UI types.These are removed with no replacement: classifyWalletError (flows return an already classified WalletError), offerAssetIconUrl, offerIconUrl, stablecoinIconUrl, tokenLogoUrl, tokenLogoSrcSet, USDC_ICON_URL, USDT_ICON_URL, formatMonthYear, formatCountdown, formatSaleCardCountdown, useCountdown, the Ondo and Superstate constants and ABIs (ONDO_*, DEFAULT_ONDO_AMOUNT_TO_COMPUTE_PRICE, DEFAULT_ONDO_SELL_TOKENS_TO_COMPUTE_PRICE, ondoQuoteOfferDeadline, DEFAULT_AMOUNT_TO_COMPUTE_PRICE, SUPERSTATE_ISSUER_NAME, SUPERSTATE_SUPPORTED_INPUT_ASSETS, SUPERSTATE_SWAP_CONTRACT_ADDRESS_SEPOLIA (call superstateSwapContractAddress(chain) instead), SWAP_POLL_INTERVAL_MS, SUPERSTATE_SWAP_ABI, ERC20_ABI), the step enums OndoBuyStep, OndoSellStep and SuperstateSwapStep, ondoLoadErrorReason, ERC20_BALANCE_POLL_INTERVAL_MS, quoteIsStale, quoteOverAsks, decodeSwappedOutputAmount, BPS_DENOM, applyBps, QueryParamValue, QueryParamValues, User, UserId, UserEmail, PaginatedResponseDto, ServerNamespaceContext, ServerAuthConfig, and the client auth internals (OAUTH_STATE_KEY, OAUTH_CODE_VERIFIER_KEY, ClientAuthConfig, ClientAuthSession).The *NamespaceImpl classes and the fromDto companions are no longer part of the published types. Reach every namespace through PassageClient or PassageServer.The *Params types of the client flow methods (PrepareOndoBuyParams, ExecuteSwapParams, AuthorizeWalletParams and the rest) no longer carry the namespaces the SDK injects. A call on passage.* passes the same object as before. Only code that builds these types with ondo, erc20, swap, wallets or tokenSale fields breaks. ExecuteOndoSwapParams loses now.OauthClientResult and OauthClientErrorReason are renamed OAuthClientResult and OAuthClientErrorReason.
Everything tagged @alpha in the published types is public but outside the compatibility contract, and may still change in a minor release. That covers the new Centrifuge, claim and token-sale checkout surfaces.

CheckoutContainer takes side as a prop, and config is optional

Only offer, chain and wallets are required. side is read once at mount: Ondo mounts its buy or sell checkout, a Centrifuge vault mounts its buy checkout for "buy" and an empty container for "sell", and Superstate and the token sale ignore it. To change the side, remount with a key that covers the offer, the chain and the side. A sell page that forgets side now renders the buy checkout instead of failing to compile.Migration:
See Checkout.

CheckoutWalletSelection takes an externalAdapter

CheckoutWalletSelection is now the shared WalletSelection plus preselected. The two callbacks move into one adapter, and null hides the external-wallet row for a host with embedded wallets only.Migration:
A rejected disconnect is now logged and the selection is kept. Before, it was an unhandled promise rejection.In the wallet step’s UI state, externalWallet is now the three-arm CheckoutExternalWalletUi (hidden, disconnected, or connected with canDisconnect), and canDisconnect moves inside it. See Wallets.

Checkout viewmodel state

  • OndoWalletSelectUiState and OndoWalletSelectUiEvent are replaced by CheckoutWalletSelectUiState and CheckoutWalletSelectUiEvent. The event has the same shape. The state’s externalWallet changes as described above.
  • OndoBuyCheckoutUiState.wallet is now nullable. It is null when a preselected wallet skipped the wallet step.
  • OndoBuyQuoteStatusUi and OndoSellQuoteStatusUi merge into OndoQuoteStatusUi, with the same shape.
  • Superstate’s amount step uses the shared CheckoutAmountUiState and CheckoutAmountUiEvent. SuperstateAmountUiState and SuperstateAmountUiEvent are removed, confirmEnabled becomes confirmCta: CheckoutCtaUi, and the confirm event ON_PREVIEW_ORDER is renamed ON_CONFIRM_AMOUNT.
  • The Superstate order dialog state drops issuer.

Data hooks return a state union

These hooks now return a discriminated union instead of loose fields. Each state is DISABLED | LOADING | ERROR { reason: LoadErrorReason } | CONTENT.useSwapQuote now reaches ERROR on a first failure instead of staying in its loading state. A failed refresh behind a quote it already has stays CONTENT. useErc20TokenBalances and useSwapOutputToken report LOADING as soon as their request changes, instead of showing the previous request’s answer.The Ondo load states (LoadOndoPriceState, LoadOndoTradingStatusState, LoadOndoBuyTransactionState, LoadOndoSellTransactionState) gain a DISABLED arm. A hook switched off with nothing in hand reports DISABLED instead of LOADING. An exhaustive switch over these states needs the new case.

useParticipations is renamed useCoinListTokenSaleParticipations

Its behavior changes too:
  • It polls passage.tokenSale.list(offerId) every 15 seconds while the page is visible, and reads again when the page becomes visible. pollInterval?: Seconds sets the interval.
  • It returns refetch().
  • A data seed skips only the first read. Before, a seed stopped all fetching.
  • A switched-off hook with nothing in hand reports DISABLED instead of LOADING.
  • A failed poll keeps the list it already has. A not-authenticated failure drops the list and reports ERROR.
Migration: apply the renames, and add a DISABLED case to any exhaustive switch over the state.

Flow errors separate “not sent” from “sent but not confirmed”

A transaction that was broadcast but whose receipt wait failed used to come back as an ordinary failure, without its hash, so the UI let the user try again and could fill the order twice. It now has its own error arm, carrying the txHash:The swap and broadcast steps now mean only that the wallet never sent the transaction. The built-in review steps disable their button until the user leaves the step, and show “Your order was sent but has not confirmed yet. Check your wallet before placing another.” (or the swap and authorization equivalents).Migration: handle the new arms wherever you switch on these errors, and do not retry them.

passage.tokenSale.execute takes the offer

The flow reads the funding contract, the payment token and the asset id from the CoinListTokenSaleOfferDetail. It refuses an unsupported-chain, an unsupported-asset or an unknown-option before it prompts the wallet. Pass approval: { type: "mined", txHash } to retry a participation whose approval already mined, without a second wallet prompt.CoinListTokenSaleOfferDetail gains fundingContracts: OfferFundingContract[], read from the required funding_contracts field of the offer response.

Token-sale and Superstate types

  • The Blockchain and WalletAddress newtypes are removed. CreateParticipationParams takes chain: EthereumChain and walletAddress: EvmWalletAddress. Participation.chain is now Chain | null, and null stands for a chain the SDK does not model. Participation.walletAddress is EvmWalletAddress | null.
  • AllowWalletResponse’s broadcast arm nests the transaction: { action: "broadcast_transaction"; tx: Tx } instead of { action; to; data }.
  • CoinListSignInButtonProps is its own interface, and its props no longer accept null. Omit a prop instead.

Session handling (server)

  • ServerConfig.accessTokenExpiryBufferSeconds is now a Seconds, not a number. Build it with Seconds.of(120) from @passage-cl/react/universal. The default is still 60 seconds.
  • passage.auth.getAccessToken() on the server now throws the underlying error when the access token has expired and the refresh could not get an answer: a 5xx, a network failure or invalid_client. It used to return null and clear the session. It still returns null when there is no session or the session was rejected. Other methods that need a session throw the same error in that case.
  • CompleteOAuthParams is removed. passage.auth.completeOAuth(grant) takes an AuthorizationGrant ({ code, codeVerifier }) from @passage-cl/react/universal, with the same fields.
  • ServerAuthNamespaceImpl is no longer exported.
Migration: an endpoint that serves the browser’s getAccessToken should map a null to “no session” (for example a 204) and a throw to a failure (for example a 503). An endpoint that maps every throw to “no session” signs your users out during an outage.
See OAuth authentication.

Session handling (browser)

A throw from your ClientConfig.getAccessToken now leaves the auth state as it was, and passage.init() rejects with that error. Before, a throw became "logged-out". PassageProvider catches it, so usePassage().isReady can be true while passage.auth.getState() is still "unknown". Resolve null for “no session” and throw for “could not find out”.

An empty base URL throws

createPassageClient and createPassageServer throw a ValidationError when baseUrl or tokensBaseUrl is an empty or whitespace-only string, for example an environment variable set to nothing. Before, the browser fell back to production for an empty baseUrl, and the server built relative URLs. Omit the field to use the default. In React, the error throws from PassageProvider’s render.

Added

CoinList token-sale checkout in CheckoutContainer

A coinlist::token_sale offer now mounts the SDK’s own checkout: a wallet step, an amount in USD paid in USDC or USDT, and a review with the estimated tokens, the price per token and the receiving wallet. The amount is checked against the option’s minimum, and the checkout is blocked outside the sale window. Placing the order runs passage.tokenSale.execute.
  • optionId?: OfferOptionId on CheckoutContainer picks the sale option. It defaults to the first option and is read once at mount.
  • config["coinlist::token_sale"].onOrderPlaced is called after an order is placed.
  • An option with no fixed price, such as an auction, renders “Not available yet” (no-price).
  • useCoinListTokenSaleCheckoutViewModel and its UI types are public (@alpha). TokenSaleApproval is public and inside the compatibility contract.

Centrifuge vaults

centrifuge::vault is a new offer type (@alpha). Vault offers now appear in offer listings. Before, the SDK dropped them from a list and threw on passage.offers.get for one. CentrifugeVaultOfferDetail carries vaultContracts, and TokenRole gains "vault".
  • CheckoutContainer mounts a Centrifuge buy checkout, which places a deposit request, for side: "buy". It is USDC only and blocks with unsupported-chain or not-orderable while the vault is paused. side: "sell" renders an empty container until the redeem flow ships. config["centrifuge::vault"].onOrderPlaced fires after the request mines.
  • passage.centrifuge exists on both PassageClient and PassageServer:
  • Hooks: useCentrifugeVaultStatus, useCentrifugeVaultStatuses, useCentrifugeClaimPositions, useCentrifugeClaim, useCentrifugeBuyCheckoutViewModel and useCentrifugeVaultClaimViewModel.
  • 'CENTRIFUGE' is a new LogScope. A logger that switches over LogScope needs the case.
Code with an exhaustive switch over OfferType, Offer["type"], OfferDetail["type"] or TokenRole needs a centrifuge::vault (or vault) case.

ClaimContainer

ClaimContainer (@alpha) is a drop-in card, titled “Your orders”, for the second leg of an asynchronous offer. Narrow the offer with AsyncOfferDetail.is(offer) first.
  • For a Centrifuge vault, it reads every wallet’s pending and claimable positions on-chain, and offers a Claim button on each claimable row. config["centrifuge::vault"].onClaimed fires after a claim.
  • For a CoinList token sale, it lists your participations in that sale across your account and polls every 15 seconds, so an order placed on the same page appears by itself. There is nothing to claim: the sale delivers tokens and refunds by itself.
  • It takes wallets: WalletSelection, an optional walletLabel resolver, config, enabled and className.
  • To build your own claim UI, use useCentrifugeVaultClaimViewModel or useCoinListTokenSaleClaimViewModel (@alpha), with their UI state and event types.

Wallet preselection in every checkout

A preselected wallet (wallets.preselected) now applies to every provider. Ondo buy and sell, Centrifuge and the token sale skip the wallet step and open on the amount, and the remaining cards renumber. Superstate keeps its wallet step, because that step allow-lists the wallet on-chain, but opens with that wallet’s row selected. preselected is read once at mount: put it in the key if it can change. Before, only Ondo sell honored it.

onSessionExpired

Config.onSessionExpired?: () => Promise<void> tells you when the SDK has given up on a session, on both the client and the server.
  • In the browser, it fires after a 401 when your getAccessToken twice answers null or the very token the API just refused, one second apart, and the user had been logged in.
  • On the server, it fires when the same refresh token is rejected twice, 5 to 60 seconds apart, or when an expired session has no refresh token. The SessionStore is cleared first.
  • It never fires on logout, a 5xx or a network failure. It fires at most once per session within a 60-second window, counted per JavaScript module instance on the server (Next.js middleware and route handlers count separately), so make the handler idempotent. The SDK does not await it, and a rejection is logged and dropped.

Ondo market closed

When an Ondo market is paused or closed, the API answers a quote with 409 not_tradable. The Ondo checkout now shows “Trading is closed right now” until the trading status reports tradable again. isOndoNotTradableError(error) (/universal) recognizes the error, isNotTradable(state) reads the trading-status state, and OndoLoadErrorReason and OndoCheckoutErrorReason gain "not-tradable".

Namespace methods

  • passage.wallets.connect({ wallet, offerId, offerOptionId }) binds a host wallet to an offer option. It replaces connectExternalWalletFlow.
  • passage.superstate.getQuote(params) returns a SwapQuote, on both the client and the server.

Other additions

  • LoadErrorReason ("not-authenticated" | "generic-error") is the shared reason on the ERROR state of the ERC-20, Superstate quote, Ondo, participation and Centrifuge hooks. On these hooks an HTTP 401, including one from the JSON-RPC proxy, now reports not-authenticated instead of generic-error. useOffers, useOfferDetails and useRequirements keep their own reason types.
  • EpochMillis, Millis and Seconds time types in /universal.
  • WalletSelection, ExternalWalletAdapter and WalletLabelResolver in /universal.
  • formatShareAmount(amount, locale), and a minFractionDigits option on formatAmount.
  • CheckoutExternalWalletUi, CheckoutWalletSelectUiState, CheckoutAmountUiState, CheckoutBlockedReason, OndoQuoteStatusUi and the Load*State types named above.
  • ./package.json is exported.
  • The published API is recorded in API reports, and the typed surface outside @alpha is the compatibility contract.

Changed

  • Single-flight session renewal. On the server, one refresh runs per refresh token per process, shared across PassageServer instances. A first invalid_grant no longer clears the session, and a 5xx or network failure never clears it. In the browser, concurrent requests share one getAccessToken call. A 401 now forces a refresh even when the token looks valid locally. /oauth/token is never retried automatically.
  • A session with an invalid expiresAt now throws ValidationError instead of being served as fresh forever.
  • Superstate checkout copy now matches the Ondo and Centrifuge wording. The amount button reads “Confirm amount”, approval and swap errors use the shared copy, and the review disclaimer names CoinList’s Terms of Service and Privacy Policy as well as Superstate’s Terms.
  • Amount validation order is the same in every checkout: parse, zero, minimum, balance, cap. Ondo buy now reports the minimum before the balance.
  • Amount fields show the token symbol after the number and accept only digits and one decimal point. The “Powered by CoinList” tag is removed from the sign-in card.
  • formatAmount trims trailing zeros past the minimum after rounding, and Ondo share amounts show at most six decimals.
  • The Superstate review keeps its in-flight state when the user leaves mid-swap, so a second swap cannot start while the first is still running.

Fixed

  • passage.auth.logout() on the server clears the session whatever the revoke failure, including a network error, unless strict is set. With strict, it rethrows and keeps the session.
  • In the browser, passage.auth.logout() wins over a getAccessToken answer already in flight, so a late answer can no longer sign the user back in.
  • passage.ondo.executeSwap keeps the txHash when the receipt wait fails (see the new swap-unconfirmed arm).
  • The Ondo transaction hooks no longer get stuck loading when the request changes during a build.
  • A nullable field missing from a response now reads as null instead of undefined.
  • A 401 renewed into a persistent 5xx makes 4 attempts instead of 6.
  • instanceof HttpError holds whichever entry point the error came from.
  • Long fund names wrap in the order-confirmation dialogs.
September 28, 2026 - Offers as a union on type, on-chain reads over JSON-RPC, token-registry artwork in the offers grid, and enabled on every data hook
This is a patch release by number, but it breaks compile-time contracts. Offer and OfferDetail become unions on type, so a field that only one kind of offer has can only be read after you narrow. The SDK now reads contract state through JSON-RPC instead of dedicated CoinList endpoints, which changes the return types of the ERC-20 and Superstate reads. OffersGridContainer’s data prop and the requirements checklist’s logged-out handling also change shape.The release also adds Config.rpc for routing on-chain reads to your own node, token-registry names and artwork on the offer cards, the security reference fields on OfferDetail, and enabled on every data hook that was missing it.

Breaking Changes

Offer and OfferDetail are unions on type

Both types are now discriminated unions with one variant per OfferType. Each variant carries only the fields that mean something for it:An Ondo swap trades continuously and runs no sale window, so it has no tagline, bannerUrl or startsAt. Previously it carried a blank string and a 1970 date. Swap offers publish without artwork more often than not, so their logoUrl is nullable. The fields every offer shares are OfferBase and OfferDetailBase, and the sale-window trio is OfferEditorial.Migration: narrow on type before reading a variant field. A field every offer shares (id, slug, tokens, endsAt, and on the detail name, asset and the rest) reads as before.
Code that builds an Offer or OfferDetail by hand, such as fixtures, mocks or preloaded server data, has to build a specific variant.The checkout surface narrows with it. Each provider’s viewmodel takes its own variant, and so do the CheckoutConfig callbacks:An inline callback such as symbol: (offer) => AssetSymbol(offer.asset.code) compiles unchanged. A callback you annotated with OfferDetail needs the narrower type.The offer-card UI models follow the same change. On OfferSaleCardUi, tagline and startsAt are now nullable (null for an Ondo swap). On both OfferSaleCardUi and OfferAssetCardUi, logoUrl: string is replaced by logo: OfferLogoUi | null. See “Token-registry artwork on the offer cards” under Added.
A page from coinlist.offers.list() or listPage() now drops any offer whose type this SDK version does not know, instead of failing the whole page. coinlist.offers.get(id) still throws ValidationError for an unknown type, because a single offer has nothing beside it to keep. isOfferType(value) is exported if you receive offer types from somewhere else.

OffersGridContainer data is an object

The grid now loads the token registry alongside the offers, so its server-seed prop takes both.The change applies to both OffersGridContainer and useOffersGridViewModel. Each half is independent: seed only offers and the client still fetches the registry.Migration:

On-chain reads return plain values

coinlist.erc20 and the read methods of coinlist.superstate now call the contract through a viem PublicClient instead of a CoinList endpoint that wrapped each value in a DTO. The single-field wrappers are gone:TokenAllowance, TokenBalance and SwapAuthorization are removed.Migration: drop the property access.
The logged-out error changes. These reads no longer throw NotAuthenticatedError. By default they go through CoinList’s authenticated JSON-RPC proxy, so a logged-out read fails with the proxy’s 401. viem wraps whatever a transport throws, so that 401 reaches you as a viem error wrapping the SDK’s HttpError. A chain you point at your own node through Config.rpc (see Added) needs no CoinList session at all. coinlist.superstate.allowWallet is a backend write and still throws NotAuthenticatedError.

Superstate SwapStatus is a union, and paused is not stopped

SwapStatus was { stopped: Uint256; swapLevel: Uint256 }. It is now:
A paused contract may still be swapping. pausedLevels is a bitmask over operations, and only the SWAP_LEVEL bit halts swaps. Ask isSwapAvailable(status) rather than comparing state to "active".SwapExecutionError gains a swap-paused arm, and swap-stopped now means the swap is permanently closed:Migration: add a swap-paused case wherever you switch on result.error.step from coinlist.superstate.execute.
SUPERSTATE_SWAP_CONTRACT_ADDRESS_SEPOLIA now points at a different contract. The old Sepolia deployment cannot serve the contract’s current ABI. If you hard-coded the previous address instead of importing the constant, switch to the constant.

The requirements checklist no longer renders a sign-in card

unauthenticatedState is removed from RequirementsChecklistContainer and RequirementsChecklistView, and RequirementsChecklistUiState loses its not-authenticated arm. A logged-out session now renders the checklist’s error state and fetches nothing. The loading and error arms now carry the title (and loading also the description), so the header stays in place while the rows load or fail.Migration: gate on auth before mounting the checklist, and render the sign-in card yourself.
A host that drives useRequirementsChecklistViewModel behind its own markup drops its not-authenticated case. useRequirements itself still reports reason: "not-authenticated".

ONDO_QUOTE_EXPIRY_THRESHOLD_MS is replaced

The review step now offers a committed quote until the earlier of Ondo’s deadline and 15 seconds after it was built, instead of until 5 seconds before Ondo’s deadline.Migration: only for hosts building their own Ondo review step. Count down to ondoQuoteOfferDeadline(transaction.expiresAt) instead of expiresAt minus the old threshold.

Added

Route on-chain reads to your own node

Config takes an optional rpc: RpcConfig, which maps each EthereumChain to an RPC URL or a viem Transport:
A chain you leave out falls back to CoinList’s proxy (POST /v1/chains/{chain}/rpc) on its own, so overriding mainnet does not opt Sepolia out. The SDK always builds the PublicClient itself, so the chain definition, Multicall batching and retry policy stay the same whatever transport you pass. createCoinListServer takes the same option.Alongside it:
  • viemChain(chain) returns the viem Chain definition the SDK uses for an EthereumChain.
  • isSwapAvailable, SWAP_LEVEL and PauseFlags, for the Superstate status above.
  • Logging: a new RPC LogScope, and an rpc arm on LogCause with reason: RpcFailureReason (reverted, node-rejected, transport, malformed-response, unknown). Each reason names a remedy, not the viem error class. Nothing is read off the thrown error above debug.

Token-registry artwork on the offer cards

OffersGridContainer now loads the public token registry alongside the offers. Each card takes its name, ticker and logo from the registry when it lists the offer’s token, and falls back to the offer’s own fields otherwise. The grid waits for both loads, so a card paints once with its final artwork. If the registry fails, the cards fall back to the offer’s own fields instead of failing the grid.OfferSaleCardUi.fromOffer / fromDetail and OfferAssetCardUi.fromOffer / fromDetail take an optional second argument, the offer’s TokenMetadata | null. Registry values win when both exist.

Security reference fields on OfferDetail

OfferDetailBase gains five nullable fields describing the underlying security:The Ondo checkout sidebar now fills its asset-detail rows from these fields. A null field hides its row.

enabled on the remaining data hooks

These now take enabled (default true). When it is false they send no request, and a seeded or already-fetched CONTENT state survives:
  • useOffers, useOfferDetails, useRequirements, useParticipations
  • useOffersGridViewModel and OffersGridContainer
  • useRequirementsChecklistViewModel and RequirementsChecklistContainer

Requirements checklist states

RequirementsChecklistItemUi gains optional statusMessages (pending, completed and rejected sentences per requirement type), and the content state gains an optional blockedMessage for an unsupported jurisdiction. When blockedMessage is set, a neutral banner replaces the description, rows and Continue. The View also renders an explicit empty state when an option has no requirements.

Changed

  • CheckoutContainer mounts only the matching provider’s container. It used to call every provider’s viewmodel on every render and disable the ones that did not match. config["ondo::swap"].symbol and side now run only when an ondo::swap offer is showing, so they no longer have to handle Superstate and token-sale offers. enabled is forwarded to the mounted container as-is.
  • Ondo quotes are offered for at most 15 seconds. The review step’s countdown runs to the earlier of Ondo’s deadline and 15 seconds after the quote was built, then asks for a fresh one.
  • Requirements checklist design and copy. The footer shows an “N/M completed” counter, and Continue stays disabled until every requirement is met. The default labels are now Identity Verification (was KYC Verification) and Tax Documentation (was Document), with new descriptions for KYC, accreditation and tax documents. A tax document whose status is in_progress stays a required step until the user signs it. The loading, error and unsupported-jurisdiction states were redesigned.

Fixed

  • Superstate’s confirmed output amount. The Swapped event ABI declared two non-indexed words where the contract emits three. As a result, decodeSwappedOutputAmount read the protocol fee as the amount received, and executeSwap reported that fee as the confirmed outputAmount. It now decodes the amount received.
  • Amounts with a leading or trailing decimal point. parseBlockchainAmount now accepts .25 (read as 0.25) and 25. (read as 25). A bare . is still rejected.
  • Checkout loading skeletons. The price skeletons in AssetSummaryRow and the Ondo and Superstate sidebars no longer stack flush against each other while loading.
September 17, 2026 - The /universal entry point, the Ondo sell flow, structured logging, and Base support
Three things change every call site: the /shared subpath is now /universal, Ondo’s one-directional swap API forks into buy and sell, and the Ondo swap contract is read off the offer instead of a constant the SDK held.The release also adds a complete Ondo sell checkout, an optional structured logger on Config, and Base mainnet and Base Sepolia in the chain model.

Breaking Changes

@coinlist-co/react/shared is now @coinlist-co/react/universal

The subpath is renamed. Nothing it exports is renamed with it, and the entry point gained exports rather than losing any.shared described where the code sat in the repository; universal describes what it is - the half of the SDK that runs unchanged in a browser and on a backend. The old name also collided with the SDK’s internal shared/ directories, which mean something else entirely.Migration: find and replace the import path. There is no compatibility alias - the old subpath is gone from exports, so a missed one is a resolution error at build time rather than a silent fallback.
The other two entry points, @coinlist-co/react and @coinlist-co/react/server, are untouched.
/universal now also exports EvmWallet and the wallet-address constructors, WalletError, Logger and the rest of the logging types, and swapSpender. A host that was reaching for those through @coinlist-co/react can keep doing so; both barrels name them.

Ondo forks into buy and sell

Ondo ships two products on one ondo::swap offer type. Preparing an order now depends on which one, so the single method became two; broadcasting stays one, because it reads calldata and a deadline and knows about neither direction.A purchase approves the funding coin and a sale approves the asset, and the two builders are two endpoints - POST /v1/ondo/swap/buy and POST /v1/ondo/swap/sell - whose amount is denominated in a different token on each. A single method taking a side would have re-merged exactly that distinction.Migration: pick the method that matches the direction. The parameters are otherwise unchanged apart from the required swapContracts below.
The React surface forked with it. Every buy screen’s component and viewmodel is now OndoBuy*, the new sell screens are OndoSell*, and the two steps both products share kept their unqualified names:

The Ondo swap contract is read off the offer

ondoSwapContractAddress(chain) is removed, and OfferDetail gains a required swapContracts: OfferSwapContract[].One constant can only name one deployment, and beta and production run different contracts on the same chain - so whichever one it named, the other approved a spender that could not fill the order. The address now arrives per environment on the offer.Migration: pass offer.swapContracts to prepareBuy, prepareSell and useOndoBuyAmountViewModel; read a single address with swapSpender(offer.swapContracts, chain). Code that constructs an OfferDetail by hand - fixtures, mocks, preloaded server data - adds the field, with [] meaning “cannot be swapped anywhere”.
OndoSwapExecutionError loses its spender-mismatch arm as a result: the backend compares the built transaction’s destination to the contract it serves on the offer, so a third comparison would check one source against itself.
ondoSwapContractAddress is deleted rather than deprecated on purpose. A constant that keeps returning a stale address fails silently against the wrong contract, where a removed export is a compile error.

CheckoutConfig for Ondo requires side

config["ondo::swap"] takes a new required side: () => OrderBookSide.A thunk rather than a resolver taking the offer, because no offer can answer it: offer.type is ondo::swap both ways, and the direction is a property of the page. This is the one config entry that is per-render rather than per-integration, so a host with a buy page and a sell page closes the thunk over whatever selects the direction.
Migration: add side. It is required with no default because the wire parameter behind it defaults to buy silently, and an unstated direction should be a compile error rather than a purchase.
side is read once, at mount. The flow holds an approval granted for whichever coin that side spends, so a resolver that starts answering differently mid-flow is ignored. Remount with a key covering the offer, the chain and the side.

The Ondo checkout state is a union

OndoBuyCheckoutUiState - and the new OndoSellCheckoutUiState - are now discriminated unions rather than product types:
A chain with no Ondo contract on it is known before any step runs, and the old shape had to render a wallet picker leading nowhere and a confident 0 USDC balance before refusing on Confirm. OndoCheckoutErrorReason is 'unsupported-chain' today.Migration: only for hosts driving useOndoBuyCheckoutViewModel behind their own markup. switch on state.type first, then read the steps off the content arm.

OfferCard is now two components

One card cannot be both a live-sale tile and a browse row. OfferSaleCard is the dark sale card with the countdown; OfferAssetCard is the light catalog row. Each takes its own UI model - OfferSaleCardUi or OfferAssetCardUi, both with fromOffer and fromDetail companions - rather than an Offer.Migration: pick the card that matches the surface and map the offer through its UI model.

CheckoutAssetUi.fromOffer takes token metadata

Checkout asset icons now resolve from the public token registry first and fall back to the offer’s own artwork, so CheckoutAssetUi.fromOffer takes the offer’s TokenMetadata | null alongside it. This affects hosts composing their own checkout viewmodel from the step-level hooks; pass null to keep the previous behavior.

Added

The Ondo sell flow

A complete sell checkout, mirroring buy. CheckoutContainer renders it when config["ondo::swap"].side() returns "sell" - no other host change is required.A sale sells the asset and settles into USDC, so the approval is granted on the asset’s contract. The SDK ships no registry entry for a provider’s asset, so its address and decimals come off the sell quote and nowhere else.See Ondo Swap for the whole flow.

CheckoutWalletSelection.preselected

A host that already knows the wallet can skip the wallet step: set preselected to a ready EvmWallet and the checkout opens on the amount, renumbering the cards that remain. A sell page reached from a position knows which wallet holds it, and asking again would be a question with one answer.It lives on the wallet seam rather than in a provider’s config because nothing about it is one provider’s - but only the Ondo sell checkout honors it today, so do not assume a skip from the field being set.

Structured logging

The SDK can now report what it is doing. Config takes an optional logger, and with none the SDK logs nothing at all - no console fallback, at any level, on any codepath.
  • Logger (from @coinlist-co/react/universal) is the port: debug, info, warn, error, and level(). Implement it over your own sink in four methods.
  • Two shipped implementations, one per environment: pinoClientLogger from @coinlist-co/react and pinoServerLogger from @coinlist-co/react/server.
  • Every line is structured: a constant msg naming what happened, a scope (HTTP, OFFERS, ONDO, HOOKS, …), and a bag of fields carrying every value that varies. Field names follow OpenTelemetry where a stable key exists.
  • Every event is a lambda, checked against level() before it runs, so an expensive debug line costs nothing at info and nothing at all with no logger supplied.
'debug' is unredacted. It reports request and response bodies, full URLs, headers and operation parameters verbatim - bearer tokens, KYC answers, tax-document fields, wallet signatures. info, warn and error are redacted by construction: those levels accept scalar fields only, so a body or a DTO cannot be put on one. Built with isDev: false, 'debug' does not typecheck. The SDK’s advice is to leave logger undefined in production entirely.
pino ^10 is a new runtime dependency and does not tree-shake: every integration pays roughly 3 kB gzipped whether or not it logs.

Base mainnet and Base Sepolia

EthereumChain gains base_mainnet (8453) and base_sepolia (84532), with chain ids, network names, explorers, and Base USDC and USDT in the token registry. Adding a chain is a deliberate compile error until every lookup table has an entry, so a host switching on EthereumChain exhaustively will need two more arms.No provider has a swap contract deployed on Base, so an Ondo or Superstate checkout there renders the unsupported-chain state.

Smaller additions

  • coinlist.tokens.list(chain?) fetches the registry’s complete snapshot in a single request - every chain with no argument, one chain with it. Prefer it over get() in a loop when rendering a catalog. Unlike get, a missing snapshot throws rather than answering [].
  • Offer.tokens and OfferDetail.tokens, the (chain, address) entries an offer’s assets live at, keyed by role.
  • OffersAssetGrid, a responsive browse grid of OfferAssetCard leaves. Pure - no fetch, no provider - and distinct from OffersGridView, which lays out sale cards.
  • useErc20Balance, the address-keyed counterpart to useErc20TokenBalances, for a token whose contract address arrives at runtime and has no registry symbol to look up.
  • useCheckoutOfferTokenMetadata, which keys the registry lookup by the offer’s token entry for the checkout chain.
  • OndoCheckoutError and OndoCheckoutErrorReason, the flow-level error surface both Ondo products share.
  • Release candidates on npm. Every merge to main now publishes <next-patch>-rc.<short-sha> under the rc dist-tag, so a fix can be tried before it is tagged: npm install @coinlist-co/react@rc.

Changed

  • Checkout asset logos resolve from the token registry first, taking the vector logo or the smallest raster variant covering the render width, and falling back to Offer.logoUrl and then a neutral placeholder. Every absence - loading, error, disabled, unlisted, no token entry - falls back rather than rendering a broken image.
  • An Ondo checkout on a chain with no contract now fails at the flow level, before any step runs. It previously displayed a 0 balance for a token the flow would never spend and refused on Confirm, as though the amount were the problem.
  • useTokenMetadata moved from the tokens feature to the shared hooks, now that checkout is its second consumer. The import path is unchanged: it is still exported from @coinlist-co/react.
  • lucide-react is now ^1 (from ^0.577). It is a bundled dependency, not a peer, so no host action is needed.

Fixed

  • An offer token on a chain the SDK does not model no longer blanks the offers list. One Base token took down the whole catalog: Offer.fromDto runs inside the paginated response’s map, so one unparseable item threw away the page. Unknown chains are now skipped, costing that token its logo.
  • The Ondo amount step no longer strands the user when a quote expires while an error is showing. The refresh action is reachable from that state.
  • The Ondo amount input reads USDC with a max affordance, matching the Superstate checkout.

Removed

  • identity_verified, proof_of_address and source_of_funds are gone from RequirementType. They were left over from an abandoned modular-KYC effort and the backend no longer returns them, so a switch over requirement types needs three fewer arms.
August 25, 2026 - Namespaced client and server surfaces, the Ondo buy flow, a drop-in CheckoutContainer, and token metadata
This is the largest release since the SDK went out, and almost every call site changes. It finishes the namespace migration v0.10.0 started, splits every component with behavior into a Container / View pair, adds a second swap provider (Ondo) with a complete checkout UI, and introduces CheckoutContainer so a host can render any offer type without knowing which provider is behind it.Nothing below changes behavior. Parameters and return types are unchanged unless a row says otherwise.

Breaking Changes

Every remaining flat method moved into a namespace

CoinListClient and CoinListServer now expose nothing but namespaces. init() is the single exception on the client, because it is lifecycle rather than domain; CoinListServer has no top-level member at all.ClientServerMigration: insert the namespace before the method name, and pass a single object to any method that used to take more than one argument.

coinlist.swap is now coinlist.superstate

A second swap provider shipped in this release, so a namespace called swap could only ever mean one of them. Provider namespaces are named after the provider; CoinList’s own namespaces stay named after what they do.Migration: replace coinlist.swap. with coinlist.superstate., rename executeSwap to execute, and take the ownership challenge from coinlist.wallets. authorizeWallet keeps its name, its parameters, its phases, and its error steps.

Token-sale and ERC-20 methods dropped their stuttering prefixes

coinlist.tokenSale.createParticipation(params) is unchanged.

Offer.type values changed

OfferType was 'sale' | 'swap'. It is now 'coinlist::token_sale' | 'superstate::swap' | 'ondo::swap', so an offer names the provider that settles it rather than the shape of the transaction. Two providers now issue swap offers, and 'swap' could not tell them apart.Migration: switch on the new values exhaustively rather than testing for one. A default branch that assumes “everything else is a token sale” now catches Ondo offers it cannot render.
If you render CheckoutContainer (below), you do not switch at all: it dispatches on offer.type for you, and a future offer type becomes a compile error in your CheckoutConfig rather than a blank screen.

Components split into Container and View

Every component that has behavior is now a Container (owns the props, calls the viewmodel) and a View (pure, driven by a state object). Both halves are exported, along with the viewmodel between them, so you can keep the SDK’s logic and supply your own markup.Migration: append Container to the component name. Props are unchanged.
OfferCard, RequirementItem and ConnectedWalletList are pure already and keep their names.

useSwapTokenBalances is now useErc20TokenBalances

Reading ERC-20 balances is not specific to a swap - the token sale and the Ondo buy flow need the same thing - so the hook moved out of the swap feature.Migration: rename the import and the call. The options (address, chain, assets, pollIntervalMs, enabled) and the returned { balances, isLoading } are unchanged. The poll-interval constant is now ERC20_BALANCE_POLL_INTERVAL_MS.

Added

Ondo buy flow

Ondo is the SDK’s second swap provider. coinlist.ondo covers the reads and the two halves of placing an order.The wallet-driven half is client-only, and is deliberately two calls rather than one:
  • coinlist.ondo.prepareSwap({ wallet, symbol, chain, tokenAddress, amount, onProgress? }) submits the ERC-20 approval, then builds the transaction.
  • coinlist.ondo.executeSwap({ wallet, transaction, chain, now?, onProgress? }) broadcasts it.
Built calldata is only good for about a minute and an approval takes most of one to mine. Doing both behind a single button would hand the user an expired quote, so the SDK splits the flow where the user’s decision is. Both calls are total: every failure comes back as { type: "error", error: { step } } rather than throwing.
The reads are free to poll while the user sizes an order; prepareSwap is not, because building a transaction spends an attestation. No CoinList fee is applied to a read quote - buildSwapTransaction takes the gross amount and Ondo prices the remainder after the fee comes off.Ondo runs no sandbox, so the read params take no chain: every environment prices against Ondo production on Ethereum mainnet. buildSwapTransaction is the exception, because it names a real contract on a real chain.Also new: ondoSwapContractAddress(chain), ONDO_POLL_INTERVAL_MS, ONDO_QUOTE_EXPIRY_THRESHOLD_MS, ONDO_SUPPORTED_INPUT_ASSETS, and DEFAULT_ONDO_AMOUNT_TO_COMPUTE_PRICE.

CheckoutContainer, a drop-in checkout for any offer type

CheckoutContainer switches on offer.type and renders the matching provider’s checkout, so a host renders one component for every offer in its catalog.
CheckoutConfig is keyed by OfferType with every key required, so a new offer type in the catalog breaks the build rather than rendering nothing. defaultCheckoutConfig fills in the entries that have a sensible default.wallets is a CheckoutWalletSelection: the embedded wallets, the connected external one, and the two lambdas that connect and disconnect it. The SDK ships no wallet stack, so this is where you plug in Privy, AppKit, or your own.
coinlist::token_sale is a host render slot until the SDK ships that flow. The Ondo symbol resolver must be total: React hooks cannot be called conditionally, so every provider’s viewmodel runs for every offer and the resolver is called for Superstate and token-sale offers too, where its answer is discarded. Return anything for an offer you do not recognize rather than throwing.

Superstate swap checkout UI

The Superstate swap UI moved into the SDK from the reference app: SuperstateSwapCheckoutContainer and SuperstateSwapCheckoutView, the step views (SuperstateWalletView, SuperstateAmountView, SuperstateReviewView, SuperstateOrderConfirmed, SuperstateSidebarView, SlippagePicker), and the viewmodels behind them.Each product exposes a single god viewmodel wrapping the whole flow - useSuperstateSwapCheckoutViewModel(), useOndoBuyCheckoutViewModel() - plus the smaller hooks it is composed from, so you can write your own viewmodel against the same pieces.

Token metadata from the CoinList registry

coinlist.tokens resolves display metadata - name, symbol, decimals, and light and dark logos - keyed by (chain, address) rather than by symbol, which can collide.It is public and unauthenticated: unlike every other namespace, its methods never require a logged-in user, and it works on the server with a read-only session store. useTokenMetadata({ chain, address, enabled }) is the hook form.OfferDetail gained a tokens field carrying the (chain, address) pairs to look up. TokenLogo is VECTOR (one SVG) or RASTER (webp variants at 32-512px); pick the smallest variant that covers your render size.
OfferToken.chain is a Chain, which spans Solana as well as EVM, while coinlist.tokens.get takes an EthereumChain. Narrow before you look a token up - an offer may list a token on a chain the registry routes do not serve.

A global locale on CoinListProvider

A BCP-47 tag applied to number, date and currency formatting in every SDK component in the tree. Defaults to 'en-US' - a fixed constant, never navigator.language, so server and client render the same markup. A plain string, so a Next.js server layout can pass params.locale straight through.It does not reach embedded third-party flows: the Sumsub UI language stays on RequirementsChecklistContainer#identityVerificationOptions.locale.

HttpError and apiErrorCode

HttpError is now exported from both @coinlist-co/react and @coinlist-co/react/shared; apiErrorCode from @coinlist-co/react/shared. HttpError is what every API call rejects with when the backend refuses a request, so without it there is no way to catch a 4xx by type. apiErrorCode(error) reads the code out of the response body, which is what tells two 422s apart.

Smaller additions

  • SolanaChain ('solana_mainnet' | 'solana_devnet') and Chain, the union of it with EthereumChain, both with validating constructors that throw ValidationError on an unknown value. Solana offers are not executable yet - this is the type surface an offer’s tokens are parsed against, ahead of the SolanaWallet seam.
  • EvmSigner, the signing-only half of EvmWallet (address + signMessage). The external-wallet ownership proof needs nothing more, so a host can satisfy that flow without implementing on-chain capabilities.
  • tokensBaseUrl on ClientConfig and ServerConfig, to point coinlist.tokens at a non-production registry.
  • CoinListSignInButton, the button on its own for hosts that do not want the card.
  • VerificationOverlay, and every requirements viewmodel and .types module.
  • useOndoPrice, useOndoTradingStatus, useOndoSwapTransaction for composing your own Ondo viewmodel.
  • Formatting and presentation helpers: useCountdown, and the asset-icon, date and duration formatters.

Changed

  • Requirements, offers and wallets are the same namespace on both sides. coinlist.offers, coinlist.requirements and coinlist.wallets expose identical method names on CoinListClient and CoinListServer; the server adds an optional trailing clientCreds on the reads that can run without a user session, and the client adds requirements.handle.
  • Ondo reads live under /v1/ondo/swap/* and take a required side. Ondo reports its limits per side, so a trading status fetched without one answers 422 rather than guessing buy.
  • getQuote refuses a request sized by both tokenAmount and notionalValue, throwing ValidationError. TypeScript accepts an object carrying both, and the previous behavior silently dropped notionalValue.
  • Participation, SwapQuote and the other provider types moved modules inside the package. They are still exported under the same names from @coinlist-co/react/shared, so only deep imports break - and deep imports were removed in v0.5.0.

Removed

  • coinlist.swap.requestWalletOwnershipChallenge - use coinlist.wallets.createOwnershipChallenge, which is the same call and is shared by the checklist and the allow-list flow.
July 27, 2026 — Fix token-sale participation amount sent in the wrong units

Fixed

Token-sale participations recorded the amount in the wrong units

coinlist.tokenSale.executeTokenSale recorded the participation with the raw base-unit amount (e.g. "25000000" for 25 USDC) instead of a decimal token amount ("25"). The participations API rescales the amount by the asset’s decimals to check it against the on-chain approval allowance, so the base-unit value overflowed the approved allowance — the backend rejected every participation and funds failed to commit. executeTokenSale now records the decimal amount, so participations go through as expected.If you drive the lower-level coinlist.tokenSale.createParticipation yourself, pass amount as a decimal token amount (e.g. "100" for 100 USDC), not raw base units. This has always been what the backend expects; only the CreateParticipationParams.amount doc comment was wrong, and it is now corrected.
July 24, 2026 — Token-sale flow, erc20/tokenSale namespaces, and stricter offer models

Breaking Changes

Participation methods moved to the tokenSale namespace

The flat participation methods on CoinListClient and CoinListServer now live under coinlist.tokenSale, mirroring how swaps sit under coinlist.swap.Migration: insert the tokenSale namespace before the method name. The parameters and return types are unchanged.

ERC-20 reads moved from swap to a new erc20 namespace

getTokenAllowance and getTokenBalance are generic ERC-20 reads shared across the swap and token-sale flows, so they moved off coinlist.swap onto coinlist.erc20.Migration: replace swap.getTokenAllowance with erc20.getTokenAllowance and swap.getTokenBalance with erc20.getTokenBalance. The endpoints and parameters are unchanged.

Offer.endsAt and OfferDetail.endsAt are now nullable

An offer can legitimately have no end date, so endsAt is now Date | null (was Date). fromDto maps a missing end date to null.Migration: handle the null case when reading endsAt.

Added

Token-sale flow

A new tokenSale namespace encapsulates the on-chain invest flow, mirroring executeSwap. On the client, coinlist.tokenSale.executeTokenSale() runs a sale end-to-end against a connected EvmWallet:
It reads the current allowance, submits an ERC-20 approve() for the sale amount (resetting a stale non-zero allowance to 0 first for USDT-style tokens), waits for it to mine, and records the participation with CoinList. Wallet connection and chain switching remain the host’s responsibility. See CoinList Token Sale for the end-to-end integration.
An approve() is submitted even when the existing allowance already covers the amount: the backend requires a fresh approval transaction hash on every participation and verifies it on-chain before confirming.
Failures are returned as typed, step-tagged errors rather than thrown. The reset-to-zero and the main approval are reported distinctly (allowance-reset / allowance-reset-reverted versus approval / approval-reverted) so you can tell which of the two wallet prompts the user rejected, and the approval hash is surfaced on a backend failure so recording can be retried without re-approving on-chain. New types: ExecuteTokenSaleParams, TokenSaleExecutionPhase, TokenSaleExecutionError, TokenSaleExecutionResult, and the shared Erc20ApprovalError (reused across the swap and token-sale flows).

erc20 namespace

coinlist.erc20 exposes the generic ERC-20 reads shared across on-chain flows:

Offer type

Offer and OfferDetail now expose a type field (OfferType, one of 'sale' or 'swap') so you can distinguish token-sale offers from swap offers. OfferType is exported from @coinlist-co/react/shared.

Changed

Stricter offer models

Fields the backend now guarantees as non-null are typed as required (string instead of string | null):
  • Offer: tagline, bannerUrl, logoUrl.
  • OfferDetail: tagline, bannerUrl, logoUrl, category.
OfferDetail.about stays nullable. OfferCard now hides its “Ends” row when an offer has no end date.
July 20, 2026 — Swap namespace, external-wallet connect, and KYC/tax/PII flows
This release folds in the 0.7.0 and 0.8.0 development versions.

Added

Swap namespace

client.swap and server.swap wrap the swap, token, and wallet endpoints:On the client, the namespace also drives a caller-supplied wallet:
  • authorizeWallet({ wallet, offerId, contractAddress, chain, onProgress }) — proves wallet ownership and allow-lists it for a swap offer, with typed progress phases and a success / error result. Idempotent: returns success immediately if the wallet is already authorized.
  • executeSwap({ wallet, contractAddress, chain, inputTokenAddress, quote, slippageBps, onProgress }) — runs the full on-chain swap: status check, ERC-20 approval (with USDT-style allowance reset), swap submission, and confirmation. The confirmed output is decoded from the Swapped event.
viem ^2 is a new peer dependency.

Swap hooks

  • useSwapOutputToken — reads the swap contract’s output token (the tokenized equity).
  • useSwapQuote — polls a live quote (15s default), skipping until the output-token decimals and a valid amount are known.
  • useSwapTokenBalances — polls the user’s balance for one or more input assets.

Wallet abstraction

EvmSigner / EvmWallet interfaces plus typed WalletError classification — bring your own wallet stack (wagmi, viem, Privy, …). New phase and error types: WalletAuthorizationPhase, WalletAuthorizationError, SwapExecutionPhase, SwapExecutionError, SwapExecutionResult.See Superstate Swap for the end-to-end integration.

External wallet connect

Prove and bind an external wallet to an offer option:
  • createWalletOwnershipChallenge(params) → POST /v1/wallet-ownership
  • connectExternalWallet(offerId, params) → POST /v1/offers/{offer_id}/addresses
  • listOptionAddresses(offerId, offerOptionId) → GET /v1/offers/{offer_id}/addresses
  • removeOptionAddress(offerId, addressId) → DELETE /v1/offers/{offer_id}/addresses/{id}
UI: ConnectWalletModal and ConnectedWalletList (list, change, and remove bound wallets), with the useConnectWallet and useOptionAddresses hooks.

Identity, KYC, and tax documents

  • createKycToken(levelName?, reset?) → POST /v1/kyc-token (Sumsub)
  • fetchPii() → GET /v1/pii, pre-fill data for tax forms
  • submitDocument(documentType, fields) → POST /v1/documents/{document_type}/submission
  • Components IdentityVerification and TaxDocumentModal; hooks useKycToken and useTaxDocument.
  • handleRequirement now resolves the kyc_approved, identity_verified, proof_of_address, source_of_funds, accreditation, external_wallet, whitelisted_wallet, document, and jurisdiction requirement types.
identity_verified, proof_of_address, and source_of_funds have since been removed from the API. The SDK still handles them, but offers no longer return them, so you do not need to write branches for those three.

App-level authentication

CoinListServer.clientCredentialsOAuth() performs the OAuth 2.0 client_credentials grant for app-level access without a user session. The server offer reads (fetchOffers, fetchOffersPage, fetchOfferDetails, fetchOfferRequirements) accept an optional clientCreds argument to use it.

Shared (@coinlist-co/react/shared)

  • BlockchainAmount (with add / sub), parseBlockchainAmount, formatAmount, computeSlip, and the Bps newtype.
  • Constants and helpers: DEFAULT_SLIPPAGE_BPS, SLIPPAGE_OPTIONS_BPS, SWAP_POLL_INTERVAL_MS, SUPERSTATE_SWAP_CONTRACT_ADDRESS_SEPOLIA, TOKEN_REGISTRY, USDC_SYMBOL, txExplorerUrl.
  • Swap, KYC, PII, document-submission, and wallet-ownership types.

Changed

CoinListProvider is safe to mount app-wide

The provider is now an alias of CoinListContextProvider and renders no DOM or styles. Components self-scope their styling through CoinListStyleScope, so mounting the provider high in your tree no longer affects the rest of the app.
July 2, 2026 — Required approvalTransactionHash, stricter wallet type, CSS isolation

Breaking Changes

approvalTransactionHash is required on createParticipation

CreateParticipationParams now requires approvalTransactionHash — the hash of the ERC-20 allowance transaction that precedes the participation. Submit the approval on-chain first, then pass its hash.

WalletAddress narrowed to `0x${string}`

WalletAddress is now the template-literal type `0x${string}` instead of string. Values that aren’t 0x-prefixed literals need validation or a cast at the boundary.

Added

CSS isolation

SDK styles are isolated behind a prefixed Tailwind build with scoped provider injection, and context is separated from styling so the context functions can be used without pulling in SDK styles. This work completes in v0.9.0, where CoinListProvider becomes safe to mount app-wide.

coinlist.co support

Added first-class support for the coinlist.co environment.
May 7, 2026 — Read-only SessionStore support

Breaking Changes

SessionStore.setSession is now optional

setSession has been changed from a required method to an optional one (setSession?). This formalizes read-only store mode for execution contexts — such as Next.js Server Components — that can read cookies but cannot write them.Previously the only workaround was a no-op setSession: async () => {}, which silently discarded refreshed sessions after consuming the refresh token over the network, effectively causing a silent logout. Omitting setSession is now the explicit, safe contract: the SDK skips token refresh entirely, making no network calls and consuming no refresh tokens.Migration: if you currently pass a no-op setSession, remove it. If your store is writable, no change is needed.

Added

WritableSessionStoreRequiredError

New error class exported from @coinlist-co/react/server. completeOAuth() and logout() throw this immediately when called on a read-only store (no setSession), before any network call is made.

Fixed

No unnecessary network retries on expired tokens in read-only mode

Previously, when a request returned a 401 and the store was read-only, the SDK would retry the request with the same expired token — wasting a round-trip that always failed. The SDK now detects the read-only store and surfaces the 401 immediately without retrying.
May 5, 2026 — API consistency, new /shared entry point, and Base* component removal

Breaking Changes

Client methods renamed: fetchAll* → fetch*

fetchAllOffers and fetchAllParticipations have been renamed on both CoinListClient and CoinListServer to drop the redundant All prefix.Migration: do a global find-and-replace in your codebase:

Hooks renamed: useCoinList* → use*

The CoinList infix has been dropped from all hook names and their associated option/result/reason types.Migration: rename the hook calls and any imported types. Example:

SSR prop renamed: serverOffers / serverData → data

The SSR pre-fetch prop has been unified to data across all hooks and components that accept server-side data. The RequirementsServerData type is also renamed to RequirementsData.Migration: rename the prop/option to data wherever you pass pre-fetched server results.

LoadRequirementsState CONTENT shape: requirementsByOptionId → requirements

If you read the CONTENT state returned by useRequirements (or previously useCoinListRequirements) directly, the field name has changed.

Base* components removed

BaseOffersGrid, BaseOffersGridProps, BaseRequirementsChecklist, and BaseRequirementsChecklistProps have been removed. The connected components (OffersGrid, RequirementsChecklist) now accept all the same customization props directly — including the optional data prop for pre-fetched server data — so there is no longer a need for a separate base variant.Migration: replace BaseOffersGrid and BaseRequirementsChecklist usage with the main components and pass the same props directly.

Import paths restructured — new @coinlist-co/react/shared entry point

Sub-path exports (/client, /client/hooks, /client/components, /client/core) have been removed. The package now exposes three canonical entry points:Domain types that were previously re-exported from both @coinlist-co/react and @coinlist-co/react/server are now the sole responsibility of @coinlist-co/react/shared. They remain re-exported from the client and server entry points as well, so most imports will continue to work without changes. However, if you were importing from the now-deleted sub-paths, update your imports:

OffersGridProps type renamed from Props

The exported type for OffersGrid props was the generic name Props. It is now exported as OffersGridProps.
April 30, 2026 — SSR data fetching, useParticipations hook, and requirement action defaults

Added

Server (@coinlist-co/react/server)

  • CoinListServer now exposes the full data-fetching surface previously only available on CoinListClient: fetchAllOffers(), fetchOffersPage(), fetchOfferDetails(), fetchAllParticipations(), fetchParticipationsPage(), fetchParticipation(), createParticipation(), fetchOfferRequirements(), and fetchRequirementStatuses(). Use these in Next.js Route Handlers and Server Components without shipping any client bundle.
  • @coinlist-co/react/server now re-exports all shared types — Offer, OfferDetail, Participation, Requirement, RequirementStatusInfo, pagination helpers, NotAuthenticatedError, and related types — so you no longer need to import them from the client entry.

Client (@coinlist-co/react, @coinlist-co/react/client)

  • useParticipations(offerId?) hook — loads all participations for the authenticated user with a LOADING / CONTENT / ERROR state machine, optionally filtered by offer. New exported types: UseParticipationsResult, LoadParticipationsState, LoadParticipationsReason.
  • handleRequirement(requirement) on CoinListClient — opens the corresponding CoinList page for completing a requirement in a new tab (/verify-identity, /wallet, etc.). No-op for jurisdiction requirements.
  • contactSupport() on CoinListClient — opens the CoinList support ticket page in a new tab.

Changed

Components

  • RequirementsChecklist: onRequirementAction and onContactSupport props have been renamed to onRequirementActionOverride and onContactSupportOverride. Both now default to CoinListClient#handleRequirement and CoinListClient#contactSupport respectively, so most integrations can omit them entirely. Pass null to disable the corresponding button/link.
  • RequirementItem: onAction and onContactSupport now accept null (in addition to undefined) to suppress rendering of action buttons.

Removed

Client (@coinlist-co/react, @coinlist-co/react/client)

  • sandbox option removed from ClientConfig. Sandbox offers are no longer toggled via the SDK config.
  • StaticRequirementsChecklistProps type removed. Use RequirementsChecklist with the connected API or BaseRequirementsChecklist for fully custom rendering.
April 23, 2026 — Requirements API, sandbox mode, and participations filtering

Added

Client (@coinlist-co/react, @coinlist-co/react/client)

  • fetchOfferRequirements(offerId) on CoinListClient — returns requirements grouped by option ID (Record<OfferOptionId, Requirement[]>).
  • fetchRequirementStatuses(offerId) on CoinListClient — returns the authenticated user’s status for each requirement.
  • useCoinListRequirements(offerId) hook for loading requirements and statuses with a LOADING / CONTENT / ERROR state machine.
  • sandbox option on ClientConfig — when true, passes sandbox=true to offers API calls so sandbox offers are included.
  • New exported types: Requirement, RequirementId, RequirementType, RequirementStatusValue, RequirementStatusInfo, ParticipationsPaginationParams.
  • New exported enums/constants: RequirementVariant, RequirementStatus, ChecklistStatus.
  • New hook types: UseCoinListRequirementsResult, LoadRequirementsState, LoadRequirementsReason.
  • fetchAllParticipations(offerId?) and fetchParticipationsPage(params) now accept an optional offerId to fetch participations for a specific offer. fetchParticipationsPage takes the new ParticipationsPaginationParams type (superset of PaginationParams) which carries the offerId field.

Fixed

Components

  • Fixed missing dark mode CSS variables in Next.js projects — prebuilt styles now include the full set of design-system tokens so components render correctly under dark class or prefers-color-scheme: dark.
April 13, 2026 — Participations API

Added

Client (@coinlist-co/react, @coinlist-co/react/client)

  • fetchAllParticipations() on CoinListClient for fetching all participations across pages.
  • fetchParticipationsPage() on CoinListClient for paginated participation fetching.
  • fetchParticipation() on CoinListClient for fetching a single participation by id.
  • createParticipation() on CoinListClient for creating a new participation.
  • New exported types: Participation, ParticipationId, ParticipationStatus, CreateParticipationParams, Blockchain, WalletAddress.
April 8, 2026 — Support UI component customization

Added

Client (@coinlist-co/react, @coinlist-co/react/client)

  • Added support for passing className and containerClassName to <OffersGrid />, <OfferCard />, and <CoinListSignInCard />.
April 7, 2026 — Publish missing exports

Fixed

Client (@coinlist-co/react, @coinlist-co/react/client)

  • Re-export offer, offer detail, and pagination types from the package root and client core entry so you can type against Offer, OfferDetail, pagination params, and related helpers without reaching into internal modules.
  • Export BaseCoinListSignInCard and BaseCoinListSignInCardProps from @coinlist-co/react/client for custom sign-in layouts built on the same primitives as CoinListSignInCard.
April 2026 — Offers UI and OAuth helpers

Added

Client (@coinlist-co/react/client)

  • fetchAllOffers(), fetchOffersPage(), and fetchOfferDetails() on CoinListClient for fetching offers data.
  • <OffersGrid /> — batteries-included component that loads offers and displays them in a responsive grid, with <OfferCard /> for each tile.
  • useCoinListOffers() hook for loading offers when you want full control over layout instead of the grid.
  • useCoinListOfferDetails() hook for a single offer’s details.
  • useCompleteCoinListOAuth() hook to run the OAuth redirect callback once on mount.
  • Optional authorizationPageUrl on client config so you can point startOAuth() at a non-production authorization page.
March 2026 — Initial release

Added

Client (@coinlist-co/react/client)

  • CoinListProvider and useCoinList() hook for React context-based SDK initialization.
  • createCoinListClient(config) factory for manual or non-React usage.
  • OAuth 2.0 with PKCE via startOAuth() and completeOAuth().
  • getAuthState() and logout().

Server (@coinlist-co/react/server)

  • createCoinListServer(config) factory for BFF / Next.js API routes.
  • completeOAuth(), accessToken() (auto-refreshing), and logout().

Components

  • CoinListSignInCard and CoinListSignInButton for OAuth sign-in flows.
  • RequirementsChecklist / RequirementItem — eligibility checklist with accordion UI.
  • PoweredByCoinList attribution badge.
  • Prebuilt CSS — no tailwindcss peer dependency required.

Infrastructure

  • Subpath exports: @coinlist-co/react, @coinlist-co/react/client, @coinlist-co/react/server.
  • Requires React 18+.