# Create a document signing submission for the authenticated partner and user Source: https://docs.passage.coinlist.co/api-reference/documentsubmissions/create-a-document-signing-submission-for-the-authenticated-partner-and-user /api-reference/openapi.json post /v1/documents/{document_type}/submission Creates a signing submission for the given document type (e.g. a W-8BEN tax certification) for the current entity. The signing link is emailed to the user. Re-posting acts as a resend/re-sign where the document state allows it. The submitted PII pre-fills the form and is forwarded to Passport, never persisted by Frontline. # Generate a Sumsub KYC access token Source: https://docs.passage.coinlist.co/api-reference/kyc/generate-a-sumsub-kyc-access-token /api-reference/openapi.json post /v1/kyc-token Generates a short-lived Sumsub WebSDK access token for the current user so an identity verification flow can be started. Requires a user-scoped access token (authorization code grant); partner-only tokens (client credentials grant) are rejected. # Introspect token Source: https://docs.passage.coinlist.co/api-reference/oauth/introspect-token /api-reference/openapi.json post /oauth/introspect Returns metadata about an access token, including whether it is active. # Issue OAuth access token Source: https://docs.passage.coinlist.co/api-reference/oauth/issue-oauth-access-token /api-reference/openapi.json post /oauth/token Issues access tokens for `authorization_code`, `client_credentials`, and `refresh_token` grants using an `application/x-www-form-urlencoded` request body. For the `authorization_code` grant, the partner exchanges the code obtained from `GET /oauth/authorize` along with a PKCE `code_verifier`. # Revoke token Source: https://docs.passage.coinlist.co/api-reference/oauth/revoke-token /api-reference/openapi.json post /oauth/revoke Revokes an access or refresh token. # Allow a wallet address for an offer Source: https://docs.passage.coinlist.co/api-reference/offers/allow-a-wallet-address-for-an-offer /api-reference/openapi.json post /v1/offers/{offer_id}/allow-wallet # Connect a proven external wallet to an offer option Source: https://docs.passage.coinlist.co/api-reference/offers/connect-a-proven-external-wallet-to-an-offer-option /api-reference/openapi.json post /v1/offers/{offer_id}/addresses For options without a whitelisted-wallet requirement, submitting when a wallet is already connected updates the existing binding in place, keeping its id. Options with a whitelisted-wallet requirement accumulate wallets up to the requirement's max_wallets and then reject with `max_wallets_reached`. # Get an offer for the authenticated partner Source: https://docs.passage.coinlist.co/api-reference/offers/get-an-offer-for-the-authenticated-partner /api-reference/openapi.json get /v1/offers/{id} # List offers for the authenticated partner Source: https://docs.passage.coinlist.co/api-reference/offers/list-offers-for-the-authenticated-partner /api-reference/openapi.json get /v1/offers # List the user's connected wallets for an offer option Source: https://docs.passage.coinlist.co/api-reference/offers/list-the-users-connected-wallets-for-an-offer-option /api-reference/openapi.json get /v1/offers/{offer_id}/addresses # Remove a connected external wallet from an offer option Source: https://docs.passage.coinlist.co/api-reference/offers/remove-a-connected-external-wallet-from-an-offer-option /api-reference/openapi.json delete /v1/offers/{offer_id}/addresses/{id} Returns the removed binding. Rejected with `offer_ended` once the offer has ended; bindings on an offer that has not yet started remain deletable. # Encode an Ondo swap for broadcast Source: https://docs.passage.coinlist.co/api-reference/ondo/encode-an-ondo-swap-for-broadcast /api-reference/openapi.json post /v1/ondo/swap/transaction Encodes the buy the user just confirmed and returns calldata their wallet can sign and broadcast, plus the amounts it commits to. This is the commit step: it consumes an attestation and the calldata expires, so call it once per confirmation and never poll it. Approve the swap contract for the full amount first; an allowance short of it is rejected here rather than reverting on-chain. The addresses are resolved from the offer, so the request names the asset, not the tokens. # Get whether an Ondo asset is currently tradable Source: https://docs.passage.coinlist.co/api-reference/ondo/get-whether-an-ondo-asset-is-currently-tradable /api-reference/openapi.json get /v1/ondo/swap/trading-status Reports whether an Ondo asset can be traded right now on the given side, and the gross caps if so. Ondo reports its trading limits per side, so a buy and a sell of the same asset can differ. Free to poll and consumes no attestation. # Quote an Ondo asset Source: https://docs.passage.coinlist.co/api-reference/ondo/quote-an-ondo-asset /api-reference/openapi.json get /v1/ondo/swap/quote Prices a buy or sell of an Ondo asset and returns the quantity, the per-token USD price and the decimals the quantity is denominated in. Free to poll while the user edits an order; it consumes no attestation. Takes no chain parameter, since Ondo runs no testnet and every environment prices against Ethereum mainnet. # Create a participation for the authenticated partner and user Source: https://docs.passage.coinlist.co/api-reference/participations/create-a-participation-for-the-authenticated-partner-and-user /api-reference/openapi.json post /v1/participations # Get a participation for the authenticated partner and user Source: https://docs.passage.coinlist.co/api-reference/participations/get-a-participation-for-the-authenticated-partner-and-user /api-reference/openapi.json get /v1/participations/{id} # List participations for the authenticated partner and user Source: https://docs.passage.coinlist.co/api-reference/participations/list-participations-for-the-authenticated-partner-and-user /api-reference/openapi.json get /v1/participations ## Filtering `filters` takes Flop filter parameters serialized as `filters[0][field]`, `filters[0][op]`, and `filters[0][value]`, with one index per filter. The playground cannot build that shape, so use the code samples. Filterable fields: `offer_id`, `offer_option_id`, `user_id`, `status`, `user_email`. # Get PII for the authenticated partner and user Source: https://docs.passage.coinlist.co/api-reference/pii/get-pii-for-the-authenticated-partner-and-user /api-reference/openapi.json get /v1/pii Returns the user's PII (full legal name, country of citizenship, tax ID, permanent address) to pre-fill tax forms such as the W-8BEN. Fields that are unavailable are null. # List requirement statuses for all options of an offer Source: https://docs.passage.coinlist.co/api-reference/requirements/list-requirement-statuses-for-all-options-of-an-offer /api-reference/openapi.json get /v1/offers/{offer_id}/requirements/statuses # List requirements for all options of an offer Source: https://docs.passage.coinlist.co/api-reference/requirements/list-requirements-for-all-options-of-an-offer /api-reference/openapi.json get /v1/offers/{offer_id}/requirements # Get the swap contract's output token metadata Source: https://docs.passage.coinlist.co/api-reference/swap/get-the-swap-contracts-output-token-metadata /api-reference/openapi.json get /v1/swap/output-token Resolves outputToken() on the Superstate swap contract and returns the ERC-20 name, symbol and decimals of that token. # Get the swap contract's status Source: https://docs.passage.coinlist.co/api-reference/swap/get-the-swap-contracts-status /api-reference/openapi.json get /v1/swap/status Reads stopped() and SWAP_LEVEL() on the Superstate swap contract so clients can determine whether swaps are currently allowed. # Preview a swap Source: https://docs.passage.coinlist.co/api-reference/swap/preview-a-swap /api-reference/openapi.json get /v1/swap/preview Reads preview(inputToken, amount) on the Superstate swap contract and returns the quote: the input amount to pay, the fee charged on it and the output amount to receive. # Get the on-chain ERC-20 allowance for a token, owner and spender Source: https://docs.passage.coinlist.co/api-reference/token/get-the-on-chain-erc-20-allowance-for-a-token-owner-and-spender /api-reference/openapi.json get /v1/token/allowance Reads allowance(owner, spender) on the ERC-20 token contract. Can be used to determine whether an approval transaction is needed before a swap or token sale. # Get the on-chain ERC-20 balance of an address Source: https://docs.passage.coinlist.co/api-reference/token/get-the-on-chain-erc-20-balance-of-an-address /api-reference/openapi.json get /v1/token/balance Reads balanceOf(owner) on the ERC-20 token contract. Used to display the balance of the connected wallet the user will pay with. # Request a wallet ownership challenge Source: https://docs.passage.coinlist.co/api-reference/wallet-ownership/request-a-wallet-ownership-challenge /api-reference/openapi.json post /v1/wallet-ownership # Check whether a wallet is authorized on a swap contract Source: https://docs.passage.coinlist.co/api-reference/wallet/check-whether-a-wallet-is-authorized-on-a-swap-contract /api-reference/openapi.json get /v1/wallet/authorized Reads authorized(address) on the Superstate swap contract to determine whether the given wallet is allowed to swap. # Asset catalog Source: https://docs.passage.coinlist.co/assets/catalog What is in the Passage catalog today, and a plain-language primer on each asset class. ## The catalog at a glance | Category | Examples | Issuing partners | | - | - | - | | [Equities](/assets/equities) | Tokenized US stocks, equity index and sector funds | Superstate (Opening Bell), Ondo | | [Fixed income (yield)](/assets/fixed-income) | US Treasury funds, corporate credit, CLOs | Ondo, WisdomTree (coming soon), Centrifuge (coming soon) | | [Commodities](/assets/commodities) | Gold, silver, oil, natural gas | Ondo | | [Token sales](/use-cases/token-sales) | New token offerings | CoinList | Ondo alone contributes 443 assets across the three asset classes, and the catalog grows every time Passage connects to another tokenization platform. 391 assets 31 assets 21 assets See [Asset issuers](/assets/partners) for what each partner issues and what ownership of their tokens actually means. ## Understanding the asset classes These primers are written for readers without a finance background. ### What is a stock? A stock is an ownership share in a company. If you own a share of Apple, you own a piece of Apple. Your share entitles you to the economic upside of the company, dividends, and other rights like voting. When the company becomes more valuable, your share generally does too, and some companies pay owners a portion of profits as dividends. Stocks can trade on public exchanges, where prices move constantly based on what buyers and sellers think the company is worth. There are also private shares that don't trade on major exchanges, but are purchased directly from the company or existing investors. ### What is a tokenized stock? A tokenized stock is a blockchain token that represents a share. Instead of holding shares in a brokerage account, your users hold a token in their own wallet. The token's value tracks the underlying stock, and it moves and settles like any other digital asset, including outside US market hours and without a registered brokerage relationship. One thing worth knowing is that not all tokenized stocks are created equally. Some represent direct, real ownership of the underlying share. Others are wrapped exposure, which tracks the price but carries different rights. See [Asset issuers](/assets/partners) for how this works issuer by issuer. ### What is a fund? A fund represents a basket of assets managed by an asset manager, for example WisdomTree. Funds can be managed actively, meaning the asset manager makes discretionary decisions on how to manage the portfolio, or passively, meaning the fund follows an existing asset allocation strategy or index. Some funds may be publicly traded like ETFs, while others may be private and not actively traded. There are many types of funds that represent exposure across all asset classes. A large share of the funds in the catalog are index funds, which track a basket of assets rather than a single one. The S\&P 500 tracks 500 of the largest US companies, so one S\&P 500 fund gives a user diversified exposure to the broad US stock market rather than a bet on any one company. Index funds sit in the asset class they track, so an S\&P 500 fund is listed under [Equities](/assets/equities) and a Treasury bond fund under [Fixed income](/assets/fixed-income). ### What is a yield product? A yield product covers a broad category of different types of financial assets. A yield product typically pays a return over time. The most common example is a US Treasury, which is a loan to the US government that pays interest and returns the principal at maturity. Because the US government backs them, US Treasuries are widely treated as one of the lowest-risk investments in the world, and their interest rate is often used as the baseline "risk-free" rate. Tokenized Treasury products let users hold that exposure as a token that accrues yield. Corporate debt works the same way, except the borrowers are companies rather than the government. That usually means a higher yield in exchange for more risk. ### What is a commodity? A commodity is a physical good that trades on a market, like gold, silver, oil, or natural gas. Prices move with global supply and demand rather than with the performance of any one company, which is why commodities are often held as a hedge against inflation or as diversification away from stocks. Users rarely take delivery of the physical good. Instead they hold a fund that owns it on their behalf, such as a gold trust that stores bullion in a vault, or a fund that tracks the commodity through futures contracts. Tokenized commodity products deliver that exposure as a single token. Passage also groups commodity-linked equity funds here, such as copper and uranium miner funds, because users buy them for exposure to the underlying commodity. Every tokenized stock and equity fund in the catalog, with ticker and issuer. # Commodities Source: https://docs.passage.coinlist.co/assets/commodities Every tokenized metals, energy, and commodity product in the Passage catalog, with ticker and issuer. Commodities are physical goods such as gold, silver, oil, and natural gas. The tokens below give exposure to those goods, either through funds holding the physical commodity or through futures and commodity-linked equities. See [Asset catalog](/assets/catalog) for a primer on the asset class, and [Asset issuers](/assets/partners) for what ownership of an Ondo token actually means. The catalog below reflects Ondo's published assets as of July 22, 2026. For current availability, retrieve the [latest catalog](https://github.com/ondoprotocol/ondo-global-markets-token-list) at runtime. ## Assets 21 tokenized commodity assets. The type column describes the instrument itself: **Company** for shares in a single company, **ETF**, **Fund**, or **Trust** for pooled vehicles that hold a basket on your behalf, and **Preferred** or **Token** where neither applies. | Asset | Ticker | Type | Issued by | | - | - | - | - | | abrdn Physical Palladium Shares ETF | `PALLon` | ETF | Ondo | | abrdn Physical Platinum Shares ETF | `PPLTon` | ETF | Ondo | | abrdn Physical Precious Metals Basket Shares ETF | `GLTRon` | ETF | Ondo | | Breakwave Tanker Shipping ETF | `BWETon` | ETF | Ondo | | First Trust Global Tactical Commodity Strategy Fund | `FTGCon` | Fund | Ondo | | Franklin Responsibly Sourced Gold ETF | `FGDLon` | ETF | Ondo | | Global X Copper Miners ETF | `COPXon` | ETF | Ondo | | Global X Silver Miners ETF | `SILon` | ETF | Ondo | | Global X Uranium ETF | `URAon` | ETF | Ondo | | Invesco DB Commodity Index Tracking Fund | `DBCon` | Fund | Ondo | | Invesco Optimum Yld Dvsfd Cmd Str No K-1 ETF | `PDBCon` | ETF | Ondo | | iShares Gold Trust | `IAUon` | Trust | Ondo | | iShares Silver Trust | `SLVon` | Trust | Ondo | | SPDR Gold Shares | `GLDon` | ETF | Ondo | | Sprott Nickel Miners ETF | `NIKLon` | ETF | Ondo | | Sprott Uranium Miners ETF | `URNMon` | ETF | Ondo | | United States Oil Fund | `USOon` | Fund | Ondo | | US Brent Oil Fund | `BNOon` | Fund | Ondo | | US Copper Index Fund | `CPERon` | Fund | Ondo | | US Natural Gas Fund | `UNGon` | Fund | Ondo | | VanEck Rare Earth and Strategic Metals ETF | `REMXon` | ETF | Ondo | Ownership models and asset lifecycles, issuer by issuer. # Equities Source: https://docs.passage.coinlist.co/assets/equities Every tokenized stock and equity fund in the Passage catalog, with ticker and issuer. Equities are ownership shares in a company. Each token below tracks a listed share or an equity fund, and settles onchain like any other digital asset. See [Asset catalog](/assets/catalog) for a primer on the asset class, and [Asset issuers](/assets/partners) for what ownership of an Ondo token actually means. The catalog below reflects Ondo's published assets as of July 22, 2026. For current availability, retrieve the [latest catalog](https://github.com/ondoprotocol/ondo-global-markets-token-list) at runtime. ## Assets 391 tokenized equity assets: 326 single-company shares and 65 funds tracking equity indexes, sectors, regions, and themes. The type column describes the instrument itself: **Company** for shares in a single company, **ETF**, **Fund**, or **Trust** for pooled vehicles that hold a basket on your behalf, and **Preferred** or **Token** where neither applies. | Asset | Ticker | Type | Issued by | | - | - | - | - | | AAON | `AAONon` | Company | Ondo | | Abbott | `ABTon` | Company | Ondo | | AbbVie | `ABBVon` | Company | Ondo | | Accenture | `ACNon` | Company | Ondo | | ACM Research | `ACMRon` | Company | Ondo | | Adobe | `ADBEon` | Company | Ondo | | Aehr Test Systems | `AEHRon` | Company | Ondo | | Airbnb | `ABNBon` | Company | Ondo | | Albemarle | `ALBon` | Company | Ondo | | Alibaba | `BABAon` | Company | Ondo | | Alpha and Omega Semiconductor | `AOSLon` | Company | Ondo | | Alphabet Class A | `GOOGLon` | Company | Ondo | | Amazon | `AMZNon` | Company | Ondo | | AMC Entertainment | `AMCon` | Company | Ondo | | AMD | `AMDon` | Company | Ondo | | American Airlines Group | `AALon` | Company | Ondo | | American Express | `AXPon` | Company | Ondo | | AMETEK | `AMEon` | Company | Ondo | | Amgen | `AMGNon` | Company | Ondo | | Amkor Technology | `AMKRon` | Company | Ondo | | Amphenol | `APHon` | Company | Ondo | | Analog Devices | `ADIon` | Company | Ondo | | Apollo Global Management | `APOon` | Company | Ondo | | Apple | `AAPLon` | Company | Ondo | | Applied Digital | `APLDon` | Company | Ondo | | Applied Materials | `AMATon` | Company | Ondo | | Applied Optoelectronics | `AAOIon` | Company | Ondo | | AppLovin | `APPon` | Company | Ondo | | Archer Aviation | `ACHRon` | Company | Ondo | | Arista Networks | `ANETon` | Company | Ondo | | Arm Holdings plc | `ARMon` | Company | Ondo | | Arqit Quantum | `ARQQon` | Company | Ondo | | Arteris | `AIPon` | Company | Ondo | | ASML Holding NV | `ASMLon` | Company | Ondo | | AST SpaceMobile | `ASTSon` | Company | Ondo | | Astera Labs | `ALABon` | Company | Ondo | | AT\&T | `Ton` | Company | Ondo | | Atkore | `ATKRon` | Company | Ondo | | Aurora Innovation | `AURon` | Company | Ondo | | Axcelis Technologies | `ACLSon` | Company | Ondo | | AXT | `AXTIon` | Company | Ondo | | B2Gold | `BTGon` | Company | Ondo | | Baidu | `BIDUon` | Company | Ondo | | Bank of America | `BACon` | Company | Ondo | | BigBear.ai Holdings | `BBAIon` | Company | Ondo | | Bilibili | `BILIon` | Company | Ondo | | Bitdeer Technologies | `BTDRon` | Company | Ondo | | BitGo Holdings | `BTGOon` | Company | Ondo | | BitMine Immersion Technologies | `BMNRon` | Company | Ondo | | Blackrock, Inc. | `BLKon` | Company | Ondo | | Block | `XYZon` | Company | Ondo | | Bloom Energy | `BEon` | Company | Ondo | | Boeing | `BAon` | Company | Ondo | | Broadcom | `AVGOon` | Company | Ondo | | Bullish | `BLSHon` | Company | Ondo | | Cameco | `CCJon` | Company | Ondo | | Camtek | `CAMTon` | Company | Ondo | | Capital One | `COFon` | Company | Ondo | | Capricor Therapeutics | `CAPRon` | Company | Ondo | | Carvana | `CVNAon` | Company | Ondo | | Caterpillar | `CATon` | Company | Ondo | | Celestica | `CLSon` | Company | Ondo | | Cerebras Systems | `CBRSon` | Company | Ondo | | CEVA | `CEVAon` | Company | Ondo | | Charles Schwab | `SCHWon` | Company | Ondo | | Chevron | `CVXon` | Company | Ondo | | Chipotle | `CMGon` | Company | Ondo | | Ciena | `CIENon` | Company | Ondo | | Cipher Mining | `CIFRon` | Company | Ondo | | Circle Internet Group | `CRCLon` | Company | Ondo | | Cisco Systems | `CSCOon` | Company | Ondo | | Citigroup | `Con` | Company | Ondo | | Cleveland-Cliffs | `CLFon` | Company | Ondo | | Cloudflare | `NETon` | Company | Ondo | | Coca-Cola | `KOon` | Company | Ondo | | Coherent | `COHRon` | Company | Ondo | | Cohu | `COHUon` | Company | Ondo | | Coinbase | `COINon` | Company | Ondo | | ConocoPhillips | `COPon` | Company | Ondo | | Constellation Energy | `CEGon` | Company | Ondo | | Core Scientific | `CORZon` | Company | Ondo | | CoreWeave | `CRWVon` | Company | Ondo | | Corning | `GLWon` | Company | Ondo | | Costco | `COSTon` | Company | Ondo | | Coupang | `CPNGon` | Company | Ondo | | Credo Technology Group Holding | `CRDOon` | Company | Ondo | | CrowdStrike | `CRWDon` | Company | Ondo | | D-Wave Quantum | `QBTSon` | Company | Ondo | | Deere | `DEon` | Company | Ondo | | Defiance Quantum ETF | `QTUMon` | ETF | Ondo | | Dell Technologies | `DELLon` | Company | Ondo | | Denison Mines | `DNNon` | Company | Ondo | | Digi Power X | `DGXXon` | Company | Ondo | | Direxion Daily Semi Bear 3X ETF | `SOXSon` | ETF | Ondo | | Direxion Daily Semi Bull 3X ETF | `SOXLon` | ETF | Ondo | | Disney | `DISon` | Company | Ondo | | DoorDash | `DASHon` | Company | Ondo | | Eaton | `ETNon` | Company | Ondo | | Eli Lilly | `LLYon` | Company | Ondo | | Emerson Electric | `EMRon` | Company | Ondo | | Enbridge | `ENBon` | Company | Ondo | | Energy Fuels | `UUUUon` | Company | Ondo | | Enlivex Therapeutics | `ENLVon` | Company | Ondo | | Enphase Energy | `ENPHon` | Company | Ondo | | Entegris | `ENTGon` | Company | Ondo | | EQT | `EQTon` | Company | Ondo | | Equinix | `EQIXon` | Company | Ondo | | Exodus Movement | `EXODon` | Company | Ondo | | Extreme Networks | `EXTRon` | Company | Ondo | | Exxon Mobil | `XOMon` | Company | Ondo | | Fabrinet | `FNon` | Company | Ondo | | Fidelity Solana Fund | `FSOLon` | Fund | Ondo | | Figma | `FIGon` | Company | Ondo | | Figure Technology Solutions | `FIGRon` | Company | Ondo | | First Majestic Silver | `AGon` | Company | Ondo | | First Trust NASDAQ Cybersecurity ETF | `CIBRon` | ETF | Ondo | | Flex | `FLEXon` | Company | Ondo | | Fluence Energy | `FLNCon` | Company | Ondo | | Ford Motor | `Fon` | Company | Ondo | | Forgent Power Solutions | `FPSon` | Company | Ondo | | FormFactor | `FORMon` | Company | Ondo | | Franklin Focused Growth ETF | `FFOGon` | ETF | Ondo | | Franklin Income Equity Focus ETF | `INCEon` | ETF | Ondo | | Franklin US Large Cap Multifactor Index ETF | `FLQLon` | ETF | Ondo | | Freeport-McMoRan | `FCXon` | Company | Ondo | | FuelCell Energy | `FCELon` | Company | Ondo | | Fundrise Innovation Fund | `VCXon` | Fund | Ondo | | Futu Holdings | `FUTUon` | Company | Ondo | | Galaxy Digital | `GLXYon` | Company | Ondo | | GameStop | `GMEon` | Company | Ondo | | GE Vernova | `GEVon` | Company | Ondo | | Gemini Space Station | `GEMIon` | Company | Ondo | | Generac Holdings | `GNRCon` | Company | Ondo | | General Dynamics | `GDon` | Company | Ondo | | General Electric | `GEon` | Company | Ondo | | Global X Artificial Intelligence & Technology ETF | `AIQon` | ETF | Ondo | | Global X Blockchain ETF | `BKCHon` | ETF | Ondo | | Global X Data Center & Digital Infrastructure ETF | `DTCRon` | ETF | Ondo | | Global X Defense Tech ETF | `SHLDon` | ETF | Ondo | | Global X Lithium & Battery Tech ETF | `LITon` | ETF | Ondo | | Global X MSCI Argentina ETF | `ARGTon` | ETF | Ondo | | Global X NASDAQ 100 Covered Call ETF | `QYLDon` | ETF | Ondo | | Global X Robotics & Artificial Intelligence ETF | `BOTZon` | ETF | Ondo | | Global X S\&P 500 Covered Call ETF | `XYLDon` | ETF | Ondo | | Global X Space Tech ETF | `ORBXon` | ETF | Ondo | | Global X US Infrastructure Development ETF | `PAVEon` | ETF | Ondo | | GlobalFoundries | `GFSon` | Company | Ondo | | Goldman Sachs | `GSon` | Company | Ondo | | Grab Holdings | `GRABon` | Company | Ondo | | Grindr | `GRNDon` | Company | Ondo | | Halliburton | `HALon` | Company | Ondo | | Harmonic | `HLITon` | Company | Ondo | | Hesai Group | `HSAIon` | Company | Ondo | | Hewlett Packard Enterprise | `HPEon` | Company | Ondo | | Himax Technologies | `HIMXon` | Company | Ondo | | Hims & Hers Health | `HIMSon` | Company | Ondo | | HIVE Digital Technologies | `HIVEon` | Company | Ondo | | Home Depot | `HDon` | Company | Ondo | | Hubbell | `HUBBon` | Company | Ondo | | Huntington Ingalls Industries | `HIIon` | Company | Ondo | | Hut 8 | `HUTon` | Company | Ondo | | Hyperliquid Strategies | `PURRon` | Company | Ondo | | IBM | `IBMon` | Company | Ondo | | Ichor Holdings | `ICHRon` | Company | Ondo | | Innodata | `INODon` | Company | Ondo | | Intel | `INTCon` | Company | Ondo | | International Seaways | `INSWon` | Company | Ondo | | Intuit | `INTUon` | Company | Ondo | | Intuitive Machines | `LUNRon` | Company | Ondo | | Intuitive Surgical | `ISRGon` | Company | Ondo | | Invesco PHLX Semiconductor ETF | `SOXQon` | ETF | Ondo | | Invesco QQQ | `QQQon` | ETF | Ondo | | IonQ | `IONQon` | Company | Ondo | | IREN | `IRENon` | Company | Ondo | | Iridium Communications | `IRDMon` | Company | Ondo | | iShares A.I. Innovation and Tech Active ETF | `BAIon` | ETF | Ondo | | iShares Bitcoin Trust | `IBITon` | Trust | Ondo | | iShares China Large-Cap ETF | `FXIon` | ETF | Ondo | | iShares Core MSCI EAFE ETF | `IEFAon` | ETF | Ondo | | iShares Core MSCI Emerging Markets ETF | `IEMGon` | ETF | Ondo | | iShares Core S\&P 500 ETF | `IVVon` | ETF | Ondo | | iShares Core S\&P MidCap ETF | `IJHon` | ETF | Ondo | | iShares Core S\&P Total US Stock Market ETF | `ITOTon` | ETF | Ondo | | iShares Defense Industrials Active ETF | `IDEFon` | ETF | Ondo | | iShares Ethereum Trust ETF | `ETHAon` | ETF | Ondo | | iShares Expanded Tech-Software ETF | `IGVon` | ETF | Ondo | | iShares International Country Rotation Active ETF | `COROon` | ETF | Ondo | | iShares Large Cap Core Active ETF | `BLCRon` | ETF | Ondo | | iShares MSCI Brazil ETF | `EWZon` | ETF | Ondo | | iShares MSCI Chile ETF | `ECHon` | ETF | Ondo | | iShares MSCI EAFE ETF | `EFAon` | ETF | Ondo | | iShares MSCI EAFE Value ETF | `EFVon` | ETF | Ondo | | iShares MSCI Emerging Markets ETF | `EEMon` | ETF | Ondo | | iShares MSCI India ETF | `INDAon` | ETF | Ondo | | iShares MSCI Japan ETF | `EWJon` | ETF | Ondo | | iShares MSCI South Korea ETF | `EWYon` | ETF | Ondo | | iShares Russell 1000 Growth ETF | `IWFon` | ETF | Ondo | | iShares Russell 2000 ETF | `IWMon` | ETF | Ondo | | iShares Russell 2000 Value ETF | `IWNon` | ETF | Ondo | | iShares S\&P Small-Cap 600 Value ETF | `IJSon` | ETF | Ondo | | iShares Semiconductor ETF | `SOXXon` | ETF | Ondo | | iShares Systematic Alternatives Active ETF | `IALTon` | ETF | Ondo | | iShares US Aerospace and Defense ETF | `ITAon` | ETF | Ondo | | iShares US Equity Factor Rotation Active ETF | `DYNFon` | ETF | Ondo | | iShares US Industry Rotation Active ETF | `INROon` | ETF | Ondo | | iShares US Technology ETF | `IYWon` | ETF | Ondo | | Jabil | `JBLon` | Company | Ondo | | JD.com | `JDon` | Company | Ondo | | Johnson & Johnson | `JNJon` | Company | Ondo | | JPMorgan Chase | `JPMon` | Company | Ondo | | Kanzhun | `BZon` | Company | Ondo | | Keel Infrastructure | `KEELon` | Company | Ondo | | Keysight Technologies | `KEYSon` | Company | Ondo | | KLA | `KLACon` | Company | Ondo | | Kopin | `KOPNon` | Company | Ondo | | KraneShares CSI China Internet ETF | `KWEBon` | ETF | Ondo | | Lam Research | `LRCXon` | Company | Ondo | | Lattice Semiconductor | `LSCCon` | Company | Ondo | | Leonardo DRS | `DRSon` | Company | Ondo | | Li Auto | `LIon` | Company | Ondo | | LightPath Technologies | `LPTHon` | Company | Ondo | | Lightwave Logic | `LWLGon` | Company | Ondo | | Lincoln Electric | `LECOon` | Company | Ondo | | Linde plc | `LINon` | Company | Ondo | | Lockheed | `LMTon` | Company | Ondo | | Lowe's | `LOWon` | Company | Ondo | | Lumentum Holdings | `LITEon` | Company | Ondo | | MACOM Technology Solutions Holdings | `MTSIon` | Company | Ondo | | MARA Holdings | `MARAon` | Company | Ondo | | Marvell Technology | `MRVLon` | Company | Ondo | | MasTec | `MTZon` | Company | Ondo | | Mastercard | `MAon` | Company | Ondo | | MaxLinear | `MXLon` | Company | Ondo | | McDonald's | `MCDon` | Company | Ondo | | MercadoLibre | `MELIon` | Company | Ondo | | Merck | `MRKon` | Company | Ondo | | Meta Platforms | `METAon` | Company | Ondo | | Methode Electronics | `MEIon` | Company | Ondo | | Micron Technology | `MUon` | Company | Ondo | | Microsoft | `MSFTon` | Company | Ondo | | MicroStrategy | `MSTRon` | Company | Ondo | | MKS Inc. | `MKSIon` | Company | Ondo | | Mobileye Global | `MBLYon` | Company | Ondo | | Moderna | `MRNAon` | Company | Ondo | | MP Materials | `MPon` | Company | Ondo | | MYR Group | `MYRGon` | Company | Ondo | | NANO Nuclear Energy | `NNEon` | Company | Ondo | | Navitas Semiconductor | `NVTSon` | Company | Ondo | | Nebius Group | `NBISon` | Company | Ondo | | NetEase | `NTESon` | Company | Ondo | | Netflix | `NFLXon` | Company | Ondo | | Newmont | `NEMon` | Company | Ondo | | NextEra Energy | `NEEon` | Company | Ondo | | Nike | `NKEon` | Company | Ondo | | NIO | `NIOon` | Company | Ondo | | nLIGHT | `LASRon` | Company | Ondo | | Nokia | `NOKon` | Company | Ondo | | Nordic American Tankers | `NATon` | Company | Ondo | | Northrop Grumman | `NOCon` | Company | Ondo | | Nova | `NVMIon` | Company | Ondo | | Novo Nordisk | `NVOon` | Company | Ondo | | Nucor | `NUEon` | Company | Ondo | | NuScale Power | `SMRon` | Company | Ondo | | nVent Electric | `NVTon` | Company | Ondo | | NVIDIA | `NVDAon` | Company | Ondo | | Occidental Petroleum | `OXYon` | Company | Ondo | | Oceaneering International | `OIIon` | Company | Ondo | | Okeanis Eco Tankers | `ECOon` | Company | Ondo | | Oklo | `OKLOon` | Company | Ondo | | ON Semiconductor | `ONon` | Company | Ondo | | Ondas Holdings | `ONDSon` | Company | Ondo | | Onto Innovation | `ONTOon` | Company | Ondo | | Opendoor Technologies | `OPENon` | Company | Ondo | | Opera | `OPRAon` | Company | Ondo | | Oracle | `ORCLon` | Company | Ondo | | Oscar Health | `OSCRon` | Company | Ondo | | Ouster | `OUSTon` | Company | Ondo | | Palantir Technologies | `PLTRon` | Company | Ondo | | Palo Alto Networks | `PANWon` | Company | Ondo | | PayPal | `PYPLon` | Company | Ondo | | PDD Holdings | `PDDon` | Company | Ondo | | Penguin Solutions | `PENGon` | Company | Ondo | | PepsiCo | `PEPon` | Company | Ondo | | Petrobras | `PBRon` | Company | Ondo | | Pfizer | `PFEon` | Company | Ondo | | PG\&E | `PCGon` | Company | Ondo | | Pinterest | `PINSon` | Company | Ondo | | Planet Labs | `PLon` | Company | Ondo | | Plug Power | `PLUGon` | Company | Ondo | | Powell Industries | `POWLon` | Company | Ondo | | Power Integrations | `POWIon` | Company | Ondo | | Primoris Services | `PRIMon` | Company | Ondo | | Procter & Gamble | `PGon` | Company | Ondo | | ProShares Short QQQ | `PSQon` | ETF | Ondo | | ProShares UltraPro QQQ | `TQQQon` | ETF | Ondo | | ProShares UltraPro Short QQQ | `SQQQon` | ETF | Ondo | | Qualcomm | `QCOMon` | Company | Ondo | | Quanta Services | `PWRon` | Company | Ondo | | Quantum Computing | `QUBTon` | Company | Ondo | | Rambus | `RMBSon` | Company | Ondo | | REalloys | `ALOYon` | Company | Ondo | | Reddit | `RDDTon` | Company | Ondo | | Redwire | `RDWon` | Company | Ondo | | Regeneron Pharmaceuticals | `REGNon` | Company | Ondo | | Rigetti Computing | `RGTIon` | Company | Ondo | | Riot Platforms | `RIOTon` | Company | Ondo | | Rivian Automotive | `RIVNon` | Company | Ondo | | Robinhood Markets | `HOODon` | Company | Ondo | | RoboStrategy | `BOTon` | Company | Ondo | | Rocket Lab | `RKLBon` | Company | Ondo | | Rockwell Automation | `ROKon` | Company | Ondo | | Roundhill Memory ETF | `DRAMon` | ETF | Ondo | | RTX | `RTXon` | Company | Ondo | | S\&P Global | `SPGIon` | Company | Ondo | | Salesforce | `CRMon` | Company | Ondo | | SanDisk | `SNDKon` | Company | Ondo | | SAP | `SAPon` | Company | Ondo | | Scorpio Tankers | `STNGon` | Company | Ondo | | Seagate | `STXon` | Company | Ondo | | ServiceNow | `NOWon` | Company | Ondo | | SharpLink Gaming | `SBETon` | Company | Ondo | | Shopify | `SHOPon` | Company | Ondo | | SK Hynix | `SKHYon` | Company | Ondo | | Skyworks Solutions | `SWKSon` | Company | Ondo | | SLB | `SLBon` | Company | Ondo | | Snap | `SNAPon` | Company | Ondo | | Snowflake | `SNOWon` | Company | Ondo | | SoFi Technologies | `SOFIon` | Company | Ondo | | SolarEdge Technologies | `SEDGon` | Company | Ondo | | SoundHound AI | `SOUNon` | Company | Ondo | | Southern | `SOon` | Company | Ondo | | Southern Copper | `SCCOon` | Company | Ondo | | SpaceX | `SPCXon` | Company | Ondo | | SPDR S\&P 500 ETF | `SPYon` | ETF | Ondo | | Spotify | `SPOTon` | Company | Ondo | | Starbucks | `SBUXon` | Company | Ondo | | Steel Dynamics | `STLDon` | Company | Ondo | | STMicroelectronics | `STMon` | Company | Ondo | | Super Micro Computer | `SMCIon` | Company | Ondo | | Symbotic | `SYMon` | Company | Ondo | | T-Mobile US | `TMUSon` | Company | Ondo | | Taiwan Semiconductor Manufacturing | `TSMon` | Company | Ondo | | Talen Energy | `TLNon` | Company | Ondo | | TaskUs | `TASKon` | Company | Ondo | | TE Connectivity | `TELon` | Company | Ondo | | Teekay Tankers | `TNKon` | Company | Ondo | | Teradyne | `TERon` | Company | Ondo | | Terawulf | `WULFon` | Company | Ondo | | Tesla | `TSLAon` | Company | Ondo | | Texas Instruments | `TXNon` | Company | Ondo | | Thermo Fisher Scientific | `TMOon` | Company | Ondo | | Tower Semiconductor | `TSEMon` | Company | Ondo | | Toyota | `TMon` | Company | Ondo | | Trane Technologies | `TTon` | Company | Ondo | | Trip.com Group | `TCOMon` | Company | Ondo | | Tsakos Energy Navigation | `TENon` | Company | Ondo | | TTM Technologies | `TTMIon` | Company | Ondo | | Uber | `UBERon` | Company | Ondo | | Ultra Clean Holdings | `UCTTon` | Company | Ondo | | Union Pacific Corporation | `UNPon` | Company | Ondo | | United Microelectronics | `UMCon` | Company | Ondo | | United States Antimony | `UAMYon` | Company | Ondo | | UnitedHealth | `UNHon` | Company | Ondo | | Uranium Energy | `UECon` | Company | Ondo | | USA Rare Earth | `USARon` | Company | Ondo | | VanEck Oil Services ETF | `OIHon` | ETF | Ondo | | Vanguard Energy ETF | `VDEon` | ETF | Ondo | | Vanguard Real Estate ETF | `VNQon` | ETF | Ondo | | Vanguard Total Stock Market ETF | `VTIon` | ETF | Ondo | | Vanguard Value ETF | `VTVon` | ETF | Ondo | | VeriSign | `VRSNon` | Company | Ondo | | Verizon | `VZon` | Company | Ondo | | Vertex Pharmaceuticals | `VRTXon` | Company | Ondo | | Vertiv | `VRTon` | Company | Ondo | | Vicor | `VICRon` | Company | Ondo | | VinFast Auto | `VFSon` | Company | Ondo | | Visa | `Von` | Company | Ondo | | Vishay Intertechnology | `VSHon` | Company | Ondo | | Vishay Precision Group | `VPGon` | Company | Ondo | | Vistra | `VSTon` | Company | Ondo | | Walmart | `WMTon` | Company | Ondo | | Waste Management | `WMon` | Company | Ondo | | Wells Fargo | `WFCon` | Company | Ondo | | WESCO International | `WCCon` | Company | Ondo | | Western Digital | `WDCon` | Company | Ondo | | Westlake | `WLKon` | Company | Ondo | | WhiteFiber | `WYFIon` | Company | Ondo | | Williams | `WMBon` | Company | Ondo | | WisdomTree US Quality Dividend Growth Fund | `DGRWon` | Fund | Ondo | | Wolfspeed | `WOLFon` | Company | Ondo | | Worthington Steel | `WSon` | Company | Ondo | Tokenized treasuries, credit, and other yield-bearing assets. # Fixed income Source: https://docs.passage.coinlist.co/assets/fixed-income Every tokenized treasury, bond, and credit product in the Passage catalog, with ticker and issuer. Fixed income covers assets that pay a return over time, from US Treasuries through corporate credit and securitized products. See [Asset catalog](/assets/catalog) for a primer on the asset class, and [Asset issuers](/assets/partners) for what ownership of an Ondo token actually means. The catalog below reflects Ondo's published assets as of July 22, 2026. For current availability, retrieve the [latest catalog](https://github.com/ondoprotocol/ondo-global-markets-token-list) at runtime. ## Assets 31 tokenized fixed income and yield-bearing assets. The type column describes the instrument itself: **Company** for shares in a single company, **ETF**, **Fund**, or **Trust** for pooled vehicles that hold a basket on your behalf, and **Preferred** or **Token** where neither applies. | Asset | Ticker | Type | Issued by | | - | - | - | - | | AB Ultra Short Income ETF | `YEARon` | ETF | Ondo | | Franklin High Yield Corporate ETF | `FLHYon` | ETF | Ondo | | iBoxx \$ High Yield Corporate Bond ETF | `HYGon` | ETF | Ondo | | iShares 0-3 Month Treasury Bond ETF | `SGOVon` | ETF | Ondo | | iShares 1-3 Year Treasury Bond ETF | `SHYon` | ETF | Ondo | | iShares 20+ Year Treasury Bond ETF | `TLTon` | ETF | Ondo | | iShares 3-7 Year Treasury Bond ETF | `IEIon` | ETF | Ondo | | iShares 7-10 Year Treasury Bond ETF | `IEFon` | ETF | Ondo | | iShares Aaa - A Rated Corporate Bond ETF | `QLTAon` | ETF | Ondo | | iShares AAA CLO Active ETF | `CLOAon` | ETF | Ondo | | iShares Core US Aggregate Bond ETF | `AGGon` | ETF | Ondo | | iShares Euro High Yield Corporate Bond USD Hedged ETF | `EUHYon` | ETF | Ondo | | iShares Flexible Income Active ETF | `BINCon` | ETF | Ondo | | iShares Floating Rate Loan Active ETF | `BRLNon` | ETF | Ondo | | iShares Global Government Bond USD Hedged Active ETF | `GGOVon` | ETF | Ondo | | iShares High Yield Active ETF | `BRHYon` | ETF | Ondo | | iShares High Yield Corporate Bond BuyWrite Strategy ETF | `HYGWon` | ETF | Ondo | | iShares Investment Grade Systematic Bond ETF | `IGEBon` | ETF | Ondo | | iShares J.P. Morgan EM Local Currency Bond ETF | `LEMBon` | ETF | Ondo | | iShares Securitized Income Active ETF | `SECUon` | ETF | Ondo | | iShares Systematic Bond ETF | `SYSBon` | ETF | Ondo | | iShares TIPS Bond ETF | `TIPon` | ETF | Ondo | | iShares Total Return Active ETF | `BRTRon` | ETF | Ondo | | Janus Henderson AAA CLO ETF | `JAAAon` | ETF | Ondo | | Ondo U.S. Dollar Yield | `USDY` | Token | Ondo | | PIMCO 0-5 Year High Yield Corporate Bond Index ETF | `HYSon` | ETF | Ondo | | SPDR Bloomberg 1-3 Month T-Bill ETF | `BILon` | ETF | Ondo | | Strategy Stretch Preferred | `STRCon` | Preferred | Ondo | | Strive Preferred | `SATAon` | Preferred | Ondo | | VanEck CLO ETF | `CLOIon` | ETF | Ondo | | WisdomTree Floating Rate Treasury Fund | `USFRon` | Fund | Ondo | Tokenized metals, energy, and broad commodity exposure. # Asset issuers Source: https://docs.passage.coinlist.co/assets/partners What a token represents and what your users can do with it, issuer by issuer. What a token actually represents, and what your users can do with it after they buy, both depend on who issued it. Two tokens tracking the same stock can carry meaningfully different rights depending on which platform issued them, so each issuer has its own page covering its ownership model alongside the lifecycle of its assets. ## Issuer profiles at a glance | Issuer | What they issue | Ownership model | Powers which use cases | | - | - | - | - | | [Superstate](/assets/partners/superstate) | Tokenized US equities (Opening Bell) | Direct ownership of the underlying equity | Tokenized stocks, permissioned lending/borrowing pools | | [Ondo](/assets/partners/ondo) | 400+ assets: equities, fixed income, commodities | Wrapped economic exposure | Tokenized stocks, yield, commodities | | [CoinList](/assets/partners/coinlist) | Token sales | Direct token allocation / ownership | Early stage crypto exposure | | Centrifuge (coming soon) | Tokenized funds from established managers including Janus Henderson and Apollo | Wrapped economic exposure | Yield, tokenized funds, DeFi collateral | | WisdomTree (coming soon) | US Treasury funds, index funds, and mutual funds | Direct ownership of the underlying funds | Yield, tokenized funds | Direct ownership Wrapped exposure Token sales The other half of the partner story: who supplies the wallets your users sign with. # CoinList Source: https://docs.passage.coinlist.co/assets/partners/coinlist Token sales run by CoinList, embedded inside your app, with direct token allocation on each sale's terms. CoinList is the token sale partner. Where the other partners issue assets that trade continuously, CoinList offers run as discrete sales with their own window, terms, and eligibility. ## What it is Token sales. Passage embeds sales that CoinList already runs inside your app, so your users get access to new token offerings without leaving it. This is for surfacing sales CoinList already runs. If you are raising for your own token, see [Run your raise](/use-cases/run-your-raise). ## Ownership model Direct token allocation on the terms of each sale. Your users receive an allocation of the token itself, not a wrapper or a claim on an underlying security. Allocation follows whatever schedule the sale defines. ## How users buy Users contribute during the sale window, with eligibility set per sale rather than once across the catalog. A user who qualified for one offering will not necessarily qualify for the next. The contribution settles later on Passage's own schedule rather than in the same transaction, which is the main way this differs from the instant swaps used by [Superstate](/assets/partners/superstate) and [Ondo](/assets/partners/ondo). Offers carry a `type` field, and CoinList sales arrive as `coinlist::token_sale`. Rendering [`CheckoutContainer`](/sdk/checkout) routes on that for you. ## What users can do with it Receive distributions on the sale's configured schedule, including any vesting or lockups that apply. Sell the tokens through DeFi or exchanges. Stake the tokens on the network, where applicable. CoinList powers [token sales](/use-cases/token-sales). ## Build it Approve funds and record a participation with `coinlist.tokenSale.execute`. Read and create participations for a sale. # Ondo Source: https://docs.passage.coinlist.co/assets/partners/ondo A broad catalog of 400+ tokenized assets across equities, fixed income, and commodities. Ondo assets are only available to non-US investors. Ondo adds assets regularly, so treat the [Ondo token list](https://github.com/ondoprotocol/ondo-global-markets-token-list) as the source of truth rather than a fixed roster. ## What it is A broad catalog of 400+ assets, spanning [equities](/assets/equities), [fixed income](/assets/fixed-income), and [commodities](/assets/commodities). That covers single-company shares, index and sector funds, Treasuries and corporate credit, and physical metals and energy. Assets are issued as wrapped economic exposure. The asset holder has economic exposure to the underlying asset, but does not own the asset outright. The issuing entity owns the underlying asset and issues a claim against it to the investor. ## Ownership model Wrapped exposure. The token tracks the value of the underlying asset but confers fewer rights than direct share ownership. Practically, that means your users get the price exposure without the shareholder rights that come with a directly owned share. Where those rights matter to your users, [Superstate](/assets/partners/superstate) is a better partner. ## How users buy Tokens are minted directly from the issuer through an RFQ purchase flow. The user places an order and confirms the price before the purchase processes. Tokens and stablecoins settle in a single transaction. Unlike Superstate assets, Ondo assets are not allowlist-gated, so there is no wallet registration step before the purchase. ## What users can do with it Buy, sell, and hold it for exposure, and earn yield where the product accrues it. Ondo powers [tokenized stocks and funds](/use-cases/tokenized-assets), yield, and commodities. ## FAQ Economic exposure, not title in the underlying asset. Ondo tokenized equities are backed 1:1 by securities at licensed US broker-dealers, issued by Ondo Global Markets (BVI) Limited through a bankruptcy-remote SPV with an independent director, overcollateralization, a third-party security agent, and daily attestations. Not always. Ondo stocks are total-return trackers, which means dividends are reinvested net of withholding taxes. The price typically matches the price of the stock exactly on the day Ondo issues it, but over time, as dividends are reinvested, the price drifts to represent more than one share and stops tracking the stock. This matters for how you communicate price to users. Do not use traditional market data for the stock. Use the official pricing from Ondo, or a provider such as CoinGecko. Scaled UI makes this look share-like on Solana and BNB Chain, but not on Ethereum. No. Individual users do not need to complete KYC with Ondo. Compliance obligations differ based on your business model and structure. DEXs and self-custodial swaps may not require direct KYC on their users to transact in Ondo tokens. Other businesses may be obligated to perform KYC, which Passage supports. In all cases you are obligated to perform certain compliance functions and apply geographic restrictions, which Passage will help you support. Contact your Passage representative about the compliance model appropriate for your business. Ondo tokens are not available in the following jurisdictions: Afghanistan, Belarus, Crimea, Cuba, Donetsk People's Republic (DNR) Ukraine, Luhansk People's Republic (LNR) Ukraine, Kherson region Ukraine, Zaporizhzhia region Ukraine, Iran, Libya, Myanmar, North Korea (DPRK), Russia, Sevastopol Ukraine, Somalia, South Sudan, Sudan, Syria, United States (including states, territories, possessions, and federal districts), and Canada. There may be additional restrictions for specific tokens. Each offering outlines its full restricted list. Tokens are available through an RFQ purchase flow. Users place an order and then confirm the price before the purchase processes. Purchases can only be made in USDC. Tokens are generally minted directly via Ondo, available instantly and atomically. Tokens and stablecoins settle in a single transaction. Fractional tokens are supported. Because it is an RFQ system connected directly to Ondo Global Markets, it generally provides good pricing up to the individual token limits offered via the RFQ. Most Ondo tokens trade 24/5, meaning 24 hours a day, 5 days a week, following US market conventions. There is no trading on weekends, holidays, between market sessions, during corporate actions, or if risk limits trip. A select and growing set of assets trade in an off-hours session over weekends and US market holidays. Live status is at [status.ondo.finance](https://status.ondo.finance). Even where assets are supported outside US market hours, liquidity is likely to be reduced. Users should be aware of the price quoted by the RFQ when buying or selling. Each asset has different order sizing limits, visible in the offer details. The minimum is \$1. Yes. The selling flow is identical to the purchase flow: an atomic burn redeeming to USDC. Dividends are auto-reinvested net of withholding taxes, reflected as an increasing token balance on Solana (support coming soon) and as tracker value on Ethereum. There is no cash distribution. Ondo token holders do not own the underlying stock, so they are not entitled to voting rights. Proxy voting may be available where Ondo enables it. Yes, peer-to-peer and into DeFi protocols across Ethereum, BNB Chain, and Solana, and bridgeable to HyperEVM. There is no allowlisting on Ondo tokens. Yes, widely. Certain Ondo tokens may be used as collateral in lending, or for perps on Ondo's perp platform. AMM pools also exist, but liquidity is often very thin. Passage's Ondo swaps tend to have much better pricing at larger sizes. No. Your users will not get tax documents from Passage or Ondo related to purchases and sales of Ondo tokens, dividends, or any other corporate actions. The usual cause is timing. The platform runs 24/5 with pauses between sessions, during corporate actions on a given ticker, and if risk limits trip. Live status is published at [status.ondo.finance](https://status.ondo.finance). Transfers are unaffected by any of this and keep working when mint and redeem are paused. Mint and redeem also require completed onboarding. Holding tokens does not make someone eligible to redeem, so a user who picked up tokens on a secondary market and never onboarded has no redemption path. Their only options are to keep holding or to sell on a secondary market where permitted. Users keep and control their tokens. ## Build it The buy and sell paths used by Ondo assets. Every asset Ondo issues, by asset class, with ticker and type. # Superstate Source: https://docs.passage.coinlist.co/assets/partners/superstate Tokenized US equities through Opening Bell. Each token represents direct ownership of the underlying share. Companies sell stock to individuals directly through Opening Bell, typically at a discount, or through IPOs. Each token represents direct ownership of the underlying share. ## What it is Users purchase shares directly from the company, for example Tesla or NVIDIA, often at a discount to the currently traded price. Your users hold an asset issued by the company directly, tokenized through Superstate. Superstate acts as the transfer agent and records the ownership both offchain and onchain. Superstate tokenized assets are direct ownership with full rights, not a wrapper or indirect economic exposure only. ## Ownership model Direct ownership. The token represents real ownership of the underlying share, so holding the token means owning the equity itself. Owners receive dividends directly and can vote their shares, and ownership is recorded in the name of the investor. This distinction may matter to your users. A Superstate token and an [Ondo](/assets/partners/ondo) token can track the same stock and still carry meaningfully different rights. Superstate equities are typically available to U.S. individuals, whereas Ondo equities are not. ## How users buy Direct issuance, minting from the issuer. The user pays a stablecoin and receives the tokenized share in the same transaction, with no separate settlement step. Superstate assets are allowlisted, so a user's wallet has to be registered against the offer before a swap or transfer can settle. This allowlisting is how ownership is recorded offchain. Passage provides all the transactions and flows for allowlist registration end to end, including proving the user controls the wallet and broadcasting the allowlist transaction where the contract requires one. ## What users can do with it Hold it, or use it in permissioned DeFi. The allowlists that gate Superstate assets are the same ones that make permissioned Uniswap pools and RWA lending markets available to your users, so these assets are not just static positions sitting in a wallet. Tokenized stock can also be redeemed directly for shares offline via Superstate. If your users want to send or transfer their shares after purchase, they will need to allowlist each new wallet. Allowlisting is once per wallet per offer, so a wallet cleared for one offer is not automatically cleared for another, and any transaction to a non-allowlisted wallet will fail. Superstate powers [tokenized stocks](/use-cases/tokenized-assets) and [permissioned DeFi pools](/use-cases/permissioned-defi-pools). ## FAQ Real shares. Superstate is the SEC-registered transfer agent and records legal ownership onchain as tokens move. No wrappers, derivatives, or a separate share class. The public company itself. Superstate is the transfer agent maintaining the registry across book-entry, tokenized, and DeFi-protocol holdings. No, only a CoinList account. Passage KYC satisfies the requirement. To redeem for ordinary shares offchain, the user will need to complete that process with Superstate. A wallet must be allowlisted before it can hold tokenized shares, and supported networks vary by equity. Adding a wallet to the allowlist requires the user to complete KYC and some additional requirements, then sign a transaction onchain. Allowlisting is once per wallet per offer, so a wallet cleared for one offer is not automatically cleared for another. It can be completed as part of the purchase process, or independently per wallet for transfers. See [Allowlist a wallet](/sdk/swap-flow#step-3-allowlist-the-wallet). Different assets and offerings may have different eligibility requirements. In general, Superstate assets are available in most geographies including the U.S. OFAC-sanctioned countries are always restricted. Check the specific offering requirements for eligibility restrictions. Direct issuance, minting from the issuer. Minimums are set by each issuing company and may differ from offer to offer. Minimums apply to direct issuance only. Secondary purchases on a supported DEX have no issuer-imposed floor. Yes, tokens can be sold via permissioned DeFi pools such as those offered by Uniswap. As of today, there are no live pools. Users can also convert the token into traditional equity via Superstate. No. Real dividends, paid out to the holder rather than reinvested. There are two routes, and the issuing company picks which one applies, so it can differ by asset. * **Onchain:** paid in USDC or another crypto asset, using the wallet information Superstate collects from investors. * **Fiat:** the dividend is paid out in cash. Superstate coordinates with the company's existing transfer agent to make that happen. Yes, between allowlisted wallets, with restrictions enforced at the token level. Transactions or transfers between non-allowlisted wallets will fail onchain. We recommend adding a check to the onchain allowlist before allowing a user to transfer tokens. You can allowlist additional wallets through Passage for users without a purchase transaction. See [Allowlist a wallet](/sdk/swap-flow#step-3-allowlist-the-wallet). Users can also burn to book-entry and DRS-transfer to a traditional brokerage, which takes 2 to 3 business days. Yes, if relevant to their holding period. Holders of Superstate tokenized equities directly own the underlying asset and receive dividends directly. The dividends are taxable, and tax documents are provided directly to the investor from Superstate, via the email associated with their Passage account. As part of onboarding to the allowlist, the user is required to submit tax documentation through Passage. For U.S. investors, this information is collected automatically as part of KYC. Non-U.S. investors are required to complete a W-8 form. Yes, but each pool needs to be specially configured because of the allowlist. Kamino pools exist on Solana and Aave pools exist on Ethereum. No permissioned Uniswap pools are live today. Most commonly the wallet is not allowlisted for that offer. Allowlisting is per wallet per offer, so a wallet cleared for one Superstate offer is not automatically cleared for another. Transfers also require the receiving wallet to be allowlisted, so sending to an unverified address will fail. Restrictions are enforced at the token level, meaning the transaction reverts onchain rather than being blocked beforehand. On the sell side, a swap can fail because no DEX pool is live for that asset, or because the pool is too thin for the size. For direct issuance, purchases below an issuer's minimum will be rejected, and offerings can close once the size is reached. Users keep the tokens in their own wallet and can hold or liquidate independently. ## Build it Register a user's wallet against the offer with `authorizeWallet` before they buy. The purchase path used by Superstate assets. # React SDK changelog Source: https://docs.passage.coinlist.co/changelogs/react Release history for @coinlist-co/react
This changelog covers the [`@coinlist-co/react`](https://www.npmjs.com/package/@coinlist-co/react) SDK. 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: | Variant | `type` | Only on this variant | | - | - | - | | `CoinListTokenSaleOffer` / `CoinListTokenSaleOfferDetail` | `coinlist::token_sale` | `tagline`, `bannerUrl`, `startsAt`; `logoUrl: string` | | `SuperstateSwapOffer` / `SuperstateSwapOfferDetail` | `superstate::swap` | `tagline`, `bannerUrl`, `startsAt`; `logoUrl: string \| null` | | `OndoSwapOffer` / `OndoSwapOfferDetail` | `ondo::swap` | `swapContracts`; `logoUrl: string \| null` | 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. ```ts theme={null} // Before const subtitle = offer.tagline; const spender = swapSpender(detail.swapContracts, chain); // After const subtitle = offer.type === "ondo::swap" ? null : offer.tagline; const spender = detail.type === "ondo::swap" ? swapSpender(detail.swapContracts, chain) : null; ``` 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: | API | Before | After | | - | - | - | | `useOndoBuyCheckoutViewModel`, `useOndoSellCheckoutViewModel` `offer` | `OfferDetail` | `OndoSwapOfferDetail` | | `useSuperstateSwapCheckoutViewModel` `offer` | `OfferDetail` | `SuperstateSwapOfferDetail` | | `config["ondo::swap"].symbol` | `(offer: OfferDetail) => AssetSymbol` | `(offer: OndoSwapOfferDetail) => AssetSymbol` | | `config["coinlist::token_sale"].render` | `(offer: OfferDetail) => ReactNode` | `(offer: CoinListTokenSaleOfferDetail) => ReactNode` | 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. | Before | After | | - | - | | `data?: Offer[]` | `data?: OffersGridData` (`{ offers?: Offer[]; tokens?: TokenMetadata[] }`) | The change applies to both `OffersGridContainer` and `useOffersGridViewModel`. Each half is independent: seed only `offers` and the client still fetches the registry. **Migration:** ```tsx theme={null} // Before // After const server = coinListServerForRSC(); const [offers, tokens] = await Promise.all([server.offers.list(), server.tokens.list()]); ``` #### 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: | Method | Before | After | | - | - | - | | `coinlist.erc20.getAllowance(params)` | `Promise` (`{ allowance }`) | `Promise` | | `coinlist.erc20.getBalance(params)` | `Promise` (`{ balance }`) | `Promise` | | `coinlist.superstate.getAuthorization(params)` | `Promise` (`{ authorized }`) | `Promise` | `TokenAllowance`, `TokenBalance` and `SwapAuthorization` are removed. **Migration:** drop the property access. ```ts theme={null} // Before const { balance } = await coinlist.erc20.getBalance({ tokenAddress, owner, chain }); const { authorized } = await coinlist.superstate.getAuthorization(params); // After const balance = await coinlist.erc20.getBalance({ tokenAddress, owner, chain }); const authorized = await coinlist.superstate.getAuthorization(params); ``` **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: ```ts theme={null} type SwapStatus = | { state: "active" } | { state: "paused"; pausedLevels: PauseFlags } | { state: "stopped" }; ``` | Before | After | | - | - | | `isStopped(status)` | `!isSwapAvailable(status)` | | `SwapStatus.fromDto`, `SwapPreview.fromDto` | `SwapStatus.fromContract`, `SwapPreview.fromContract` | 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: | `step` | Meaning | Copy to show | | - | - | - | | `swap-paused` | **New.** The contract has suspended swapping and may resume. | "Try again shortly." | | `swap-stopped` | The contract is permanently halted. | "This swap has 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. ```tsx theme={null} // Before // After const { isReady, coinlist } = useCoinList(); return isReady && coinlist.auth.getState() === "logged-in" ? ( ) : ( ); ``` 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 | Before | After | | - | - | | `ONDO_QUOTE_EXPIRY_THRESHOLD_MS` (5 s buffer before Ondo's deadline) | `ONDO_QUOTE_MAX_TTL_MS` (15 s) and `ondoQuoteOfferDeadline(expiresAt, nowMs?)` | 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`: ```ts theme={null} import { fallback, http } from "viem"; createCoinListClient({ clientId, redirectUri, rpc: { ethereum_mainnet: "https://eth-mainnet.example.com/v2/KEY", ethereum_sepolia: fallback([http(primaryUrl), http(backupUrl)]), // base_* unset: proxied through CoinList }, }); ``` 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. | Export | What it is | | - | - | | `useTokenRegistry({ data?, enabled? })` | Loads the whole registry in one request and indexes it. Returns `{ state, refetch }`, where `state` is `LoadTokenRegistryState` (`DISABLED`, `LOADING`, `ERROR`, `CONTENT`). No polling. | | `TokenRegistrySnapshot` | `fromTokens(tokens)`, `get(snapshot, { chain, address })`, `forOffer(snapshot, offer)`. Exported from `/universal`. | | `offerDisplayTokens(offer)` | The tokens an offer is about, most specific first: `swap` entries, then `distribution`, never `funding`. | | `OfferLogoUi`, `LogoImageUi` | `{ light, dark }` logo with `src` and optional `srcSet`. `dark` is `null` when the registry has no dark variant. | `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: | Field | Example | Notes | | - | - | - | | `issuer` | `"Ondo Global Markets"` | The issuer of the *token*. The security's issuer is `name`. | | `isin` | `"US0378331005"` | New `Isin` newtype. `null` for offers that tokenize no security *and* for the few whose supplier publishes none. | | `ticker` | `"QQQ"` | The listing-venue symbol, not the token symbol (`asset.code`, e.g. `"QQQon"`). | | `instrumentType` | `"Stock"`, `"ETF"` | The supplier's vocabulary, which may grow. Render it as given rather than branching on it. | | `listingVenue` | `"NASDAQ"` | Recorded by hand, so `null` is normal on a newly listed offer. | 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. 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. | Before | After | | - | - | | `@coinlist-co/react/shared` | `@coinlist-co/react/universal` | `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. ```ts theme={null} // Before import { AssetSymbol, type OfferDetail } from "@coinlist-co/react/shared"; // After import { AssetSymbol, type OfferDetail } from "@coinlist-co/react/universal"; ``` 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. | Before | After | | - | - | | `coinlist.ondo.prepareSwap(params)` | `coinlist.ondo.prepareBuy(params)` or `coinlist.ondo.prepareSell(params)` | | `coinlist.ondo.buildSwapTransaction(params)` | `coinlist.ondo.buildBuyTransaction(params)` or `coinlist.ondo.buildSellTransaction(params)` | | `coinlist.ondo.executeSwap(params)` | unchanged | 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. ```ts theme={null} // Before const prepared = await coinlist.ondo.prepareSwap({ wallet, symbol, chain, tokenAddress, amount }); // After const prepared = await coinlist.ondo.prepareBuy({ wallet, symbol, chain, tokenAddress, amount, swapContracts: offer.swapContracts, }); ``` 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: | Before | After | | - | - | | `OndoAmountView` | `OndoBuyAmountView` | | `OndoReviewView` | `OndoBuyReviewView` | | `OndoOrderConfirmed` | `OndoBuyOrderConfirmed` | | `useOndoAmountViewModel` | `useOndoBuyAmountViewModel` | | `useOndoReviewViewModel` | `useOndoBuyReviewViewModel` | | `useOndoSwapTransaction` | `useOndoBuyTransaction` | | `OndoWalletSelectView`, `OndoSidebarView` | unchanged | | `useOndoWalletSelectViewModel`, `useOndoSidebarViewModel` | unchanged | #### 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". ```ts theme={null} import { swapSpender } from "@coinlist-co/react/universal"; // Before const spender = ondoSwapContractAddress(chain); // After const spender = swapSpender(offer.swapContracts, chain); // EvmContractAddress | null ``` `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. ```tsx theme={null} const checkoutConfig = defaultCheckoutConfig({ "ondo::swap": { symbol: (offer) => AssetSymbol(offer.asset.code), side: () => "buy", }, "coinlist::token_sale": { render: (offer) => }, }); ``` **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: ```ts theme={null} type OndoBuyCheckoutUiState = | { type: "content"; wallet; amount; review; sidebar; orderConfirmed } | { type: "error"; reason: OndoCheckoutErrorReason }; ``` 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 | Before | After | | - | - | | `OfferCard`, `OfferCardProps` | `OfferSaleCard` (`OfferSaleCardProps`) and `OfferAssetCard` (`OfferAssetCardProps`) | 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. ```tsx theme={null} // Before // After ``` #### `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. | Rung | What ships | | - | - | | L3 | `OndoSellCheckoutContainer`, `OndoSellCheckoutView`, `OndoSellAmountView`, `OndoSellReviewView`, `OndoSellOrderConfirmed` | | L2 | `useOndoSellCheckoutViewModel` and its `.types`, plus `useOndoSellAmountViewModel`, `useOndoSellReviewViewModel`, `useOndoSellTransaction` | | L1 | `coinlist.ondo.prepareSell`, `coinlist.ondo.buildSellTransaction` | 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](/sdk/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. ```tsx theme={null} import { pinoClientLogger } from "@coinlist-co/react"; ``` * **`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 `-rc.` 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. 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. **Client** | Before | After | | - | - | | `coinlist.getAuthState()` | `coinlist.auth.getState()` | | `coinlist.startOAuth()` | `coinlist.auth.startOAuth()` | | `coinlist.completeOAuth()` | `coinlist.auth.completeOAuth()` | | `coinlist.logout()` | `coinlist.auth.logout()` | | `coinlist.fetchOffers()` | `coinlist.offers.list()` | | `coinlist.fetchOffersPage(params)` | `coinlist.offers.listPage(params)` | | `coinlist.fetchOfferDetails(id)` | `coinlist.offers.get(id)` | | `coinlist.fetchOfferRequirements(offerId)` | `coinlist.requirements.forOffer(offerId)` | | `coinlist.fetchRequirementStatuses(offerId)` | `coinlist.requirements.statuses(offerId)` | | `coinlist.fetchPii()` | `coinlist.requirements.getPii()` | | `coinlist.submitDocument(documentType, fields)` | `coinlist.requirements.submitDocument({ documentType, fields })` | | `coinlist.createKycToken(levelName, reset)` | `coinlist.requirements.createKycToken({ levelName, reset })` | | `coinlist.handleRequirement(requirement)` | `coinlist.requirements.handle(requirement)` | | `coinlist.createWalletOwnershipChallenge(params)` | `coinlist.wallets.createOwnershipChallenge(params)` | | `coinlist.connectExternalWallet(offerId, params)` | `coinlist.wallets.connectExternal({ offerId, ...params })` | | `coinlist.listOptionAddresses(offerId, offerOptionId)` | `coinlist.wallets.list({ offerId, offerOptionId })` | | `coinlist.removeOptionAddress(offerId, addressId)` | `coinlist.wallets.remove({ offerId, addressId })` | | `coinlist.contactSupport()` | `coinlist.support.contact()` | **Server** | Before | After | | - | - | | `coinlist.completeOAuth(code, codeVerifier)` | `coinlist.auth.completeOAuth({ code, codeVerifier })` | | `coinlist.accessToken()` | `coinlist.auth.getAccessToken()` | | `coinlist.clientCredentialsOAuth()` | `coinlist.auth.clientCredentials()` | | `coinlist.logout()` | `coinlist.auth.logout()` | | `coinlist.fetchOffers(clientCreds?)` | `coinlist.offers.list(clientCreds?)` | | `coinlist.fetchOffersPage(params, clientCreds?)` | `coinlist.offers.listPage(params, clientCreds?)` | | `coinlist.fetchOfferDetails(id, clientCreds?)` | `coinlist.offers.get(id, clientCreds?)` | | `coinlist.fetchOfferRequirements(offerId, clientCreds?)` | `coinlist.requirements.forOffer(offerId, clientCreds?)` | | `coinlist.fetchRequirementStatuses(offerId)` | `coinlist.requirements.statuses(offerId)` | | `coinlist.fetchPii()` | `coinlist.requirements.getPii()` | | `coinlist.submitDocument(documentType, fields)` | `coinlist.requirements.submitDocument({ documentType, fields })` | | `coinlist.createKycToken(levelName, reset)` | `coinlist.requirements.createKycToken({ levelName, reset })` | | `coinlist.createWalletOwnershipChallenge(params)` | `coinlist.wallets.createOwnershipChallenge(params)` | | `coinlist.connectExternalWallet(offerId, params)` | `coinlist.wallets.connectExternal({ offerId, ...params })` | | `coinlist.listOptionAddresses(offerId, offerOptionId)` | `coinlist.wallets.list({ offerId, offerOptionId })` | | `coinlist.removeOptionAddress(offerId, addressId)` | `coinlist.wallets.remove({ offerId, addressId })` | **Migration:** insert the namespace before the method name, and pass a single object to any method that used to take more than one argument. ```ts theme={null} // Before const offers = await coinlist.fetchOffers(); const detail = await coinlist.fetchOfferDetails(offers[0].id); const addresses = await coinlist.listOptionAddresses(offerId, offerOptionId); // After const offers = await coinlist.offers.list(); const detail = await coinlist.offers.get(offers[0].id); const addresses = await coinlist.wallets.list({ offerId, offerOptionId }); ``` ```ts theme={null} // Before, on your backend await coinListServer(response).completeOAuth(AuthorizationCode(code), CodeVerifier(codeVerifier)); const token = await coinListServer(response).accessToken(); // After await coinListServer(response).auth.completeOAuth({ code: AuthorizationCode(code), codeVerifier: CodeVerifier(codeVerifier), }); const token = await coinListServer(response).auth.getAccessToken(); ``` #### `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. | Before | After | | - | - | | `coinlist.swap.executeSwap(params)` | `coinlist.superstate.execute(params)` | | `coinlist.swap.authorizeWallet(params)` | `coinlist.superstate.authorizeWallet(params)` | | `coinlist.swap.getPreview(params)` | `coinlist.superstate.getPreview(params)` | | `coinlist.swap.getAuthorization(params)` | `coinlist.superstate.getAuthorization(params)` | | `coinlist.swap.getStatus(params)` | `coinlist.superstate.getStatus(params)` | | `coinlist.swap.getOutputToken(params)` | `coinlist.superstate.getOutputToken(params)` | | `coinlist.swap.allowWallet(params)` | `coinlist.superstate.allowWallet(params)` | | `coinlist.swap.requestWalletOwnershipChallenge(params)` | `coinlist.wallets.createOwnershipChallenge(params)` | **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. ```ts theme={null} // Before const result = await coinlist.swap.executeSwap({ wallet, contractAddress, chain, inputTokenAddress, quote, slippageBps }); // After const result = await coinlist.superstate.execute({ wallet, contractAddress, chain, inputTokenAddress, quote, slippageBps }); ``` #### Token-sale and ERC-20 methods dropped their stuttering prefixes | Before | After | | - | - | | `coinlist.tokenSale.executeTokenSale(params)` | `coinlist.tokenSale.execute(params)` | | `coinlist.tokenSale.fetchParticipations(offerId?)` | `coinlist.tokenSale.list(offerId?)` | | `coinlist.tokenSale.fetchParticipationsPage(params)` | `coinlist.tokenSale.listPage(params)` | | `coinlist.tokenSale.fetchParticipation(id)` | `coinlist.tokenSale.get(id)` | | `coinlist.erc20.getTokenAllowance(params)` | `coinlist.erc20.getAllowance(params)` | | `coinlist.erc20.getTokenBalance(params)` | `coinlist.erc20.getBalance(params)` | `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. | Before | After | | - | - | | `offer.type === 'sale'` | `offer.type === 'coinlist::token_sale'` | | `offer.type === 'swap'` | `offer.type === 'superstate::swap'` or `'ondo::swap'` | **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. ```ts theme={null} switch (offer.type) { case 'coinlist::token_sale': return ; case 'superstate::swap': return ; case 'ondo::swap': return ; } ``` 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. | Before | After | | - | - | | `` | `` | | `` | `` | | `` | `` | | `` | `` | | `` | `` | | `` | `` | **Migration:** append `Container` to the component name. Props are unchanged. ```tsx theme={null} // Before import { OffersGrid } from "@coinlist-co/react"; // After import { OffersGridContainer } from "@coinlist-co/react"; ``` `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. | Method | Endpoint | | - | - | | `coinlist.ondo.getTradingStatus({ symbol, side })` | `GET /v1/ondo/swap/trading-status` | | `coinlist.ondo.getQuote({ symbol, side, tokenAmount \| notionalValue, duration? })` | `GET /v1/ondo/swap/quote` | | `coinlist.ondo.buildSwapTransaction({ symbol, chain, walletAddress, amount })` | `POST /v1/ondo/swap/transaction` | 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. ```tsx theme={null} import { CheckoutContainer, defaultCheckoutConfig } from "@coinlist-co/react"; import { AssetSymbol } from "@coinlist-co/react/shared"; const config = defaultCheckoutConfig({ "ondo::swap": { symbol: (offer) => AssetSymbol(offer.asset.code) }, "coinlist::token_sale": { render: (offer) => }, }); ``` `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. | Method | Returns | | - | - | | `coinlist.tokens.get({ chain, address })` | `TokenMetadata`, or `null` when the registry has no entry | | `coinlist.tokens.list(chain)` | Every token on a chain, in one request | 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` ```tsx theme={null} ``` 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. ```ts theme={null} import { apiErrorCode, HttpError } from "@coinlist-co/react/shared"; try { await coinlist.tokenSale.createParticipation(params); } catch (error) { if (error instanceof HttpError) { switch (apiErrorCode(error)) { /* ... */ } } } ``` #### 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. ### 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`](/sdk/invest-flow#reading-and-writing-imperatively) 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. ### 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`. | Before | After | | - | - | | `coinlist.fetchParticipations(offerId?)` | `coinlist.tokenSale.fetchParticipations(offerId?)` | | `coinlist.fetchParticipationsPage(params)` | `coinlist.tokenSale.fetchParticipationsPage(params)` | | `coinlist.fetchParticipation(id)` | `coinlist.tokenSale.fetchParticipation(id)` | | `coinlist.createParticipation(params)` | `coinlist.tokenSale.createParticipation(params)` | **Migration:** insert the `tokenSale` namespace before the method name. The parameters and return types are unchanged. ```ts theme={null} // Before const participations = await coinlist.fetchParticipations(offerId); // After const participations = await coinlist.tokenSale.fetchParticipations(offerId); ``` #### 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`. | Before | After | | - | - | | `coinlist.swap.getTokenAllowance(params)` | `coinlist.erc20.getTokenAllowance(params)` | | `coinlist.swap.getTokenBalance(params)` | `coinlist.erc20.getTokenBalance(params)` | **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`. ```ts theme={null} // Before — endsAt was always a Date const label = offer.endsAt.toLocaleDateString(); // After — endsAt may be null const label = offer.endsAt ? offer.endsAt.toLocaleDateString() : "No end date"; ``` ### 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`: ```ts theme={null} const result = await coinlist.tokenSale.executeTokenSale({ wallet, offerId, offerOptionId, assetId, paymentTokenAddress, // ERC-20 the user pays with (e.g. USDC/USDT) fundingContractAddress, // the approve() spender chain, amount, // BlockchainAmount, base units onProgress, // optional phase callback }); ``` 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](/sdk/invest-flow) 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: | Method | Endpoint | | - | - | | `erc20.getTokenAllowance` | `GET /v1/token/allowance` | | `erc20.getTokenBalance` | `GET /v1/token/balance` | #### 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. 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: | Method | Endpoint | | - | - | | `swap.getOutputToken` | `GET /v1/swap/output-token` | | `swap.getPreview` | `GET /v1/swap/preview` | | `swap.getStatus` | `GET /v1/swap/status` | | `swap.getTokenAllowance` | `GET /v1/token/allowance` | | `swap.getTokenBalance` | `GET /v1/token/balance` | | `swap.getAuthorization` | `GET /v1/wallet/authorized` | | `swap.allowWallet` | `POST /v1/offers/{offer_id}/allow-wallet` | | `swap.requestWalletOwnershipChallenge` | `POST /v1/wallet-ownership` | 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](/sdk/swap-flow) 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. ### 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. ```ts theme={null} await coinlist.createParticipation({ offerId, offerOptionId, chain, walletAddress, amount, assetId, approvalTransactionHash, // ← now required }); ``` #### `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. ### 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. ```ts theme={null} // Before — no-op workaround (caused silent logout bug) createCoinListServer({ sessionStore: { getSession: () => getServerSession(), setSession: async () => {}, // ← remove this }, }); // After — read-only store (safe, explicit) createCoinListServer({ sessionStore: { getSession: () => getServerSession(), }, }); ``` ### 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. ### Breaking Changes #### Client methods renamed: `fetchAll*` → `fetch*` `fetchAllOffers` and `fetchAllParticipations` have been renamed on both `CoinListClient` and `CoinListServer` to drop the redundant `All` prefix. | Before | After | | - | - | | `CoinListClient.fetchAllOffers()` | `CoinListClient.fetchOffers()` | | `CoinListClient.fetchAllParticipations(offerId?)` | `CoinListClient.fetchParticipations(offerId?)` | | `CoinListServer.fetchAllOffers()` | `CoinListServer.fetchOffers()` | | `CoinListServer.fetchAllParticipations(offerId?)` | `CoinListServer.fetchParticipations(offerId?)` | **Migration:** do a global find-and-replace in your codebase: ``` fetchAllOffers( → fetchOffers( fetchAllParticipations( → fetchParticipations( ``` #### Hooks renamed: `useCoinList*` → `use*` The `CoinList` infix has been dropped from all hook names and their associated option/result/reason types. | Before | After | | - | - | | `useCoinListOffers` | `useOffers` | | `UseCoinListOffersOptions` | `UseOffersOptions` | | `UseCoinListOffersResult` | `UseOffersResult` | | `useCoinListOfferDetails` | `useOfferDetails` | | `UseCoinListOfferDetailsOptions` | `UseOfferDetailsOptions` | | `UseCoinListOfferDetailsResult` | `UseOfferDetailsResult` | | `useCoinListRequirements` | `useRequirements` | | `UseCoinListRequirementsOptions` | `UseRequirementsOptions` | | `UseCoinListRequirementsResult` | `UseRequirementsResult` | | `useCompleteCoinListOAuth` | `useCompleteOAuth` | | `UseCompleteCoinListOAuthOptions` | `UseCompleteOAuthOptions` | | `CompleteCoinListOAuthFailureReason` | `CompleteOAuthFailureReason` | **Migration:** rename the hook calls and any imported types. Example: ```tsx theme={null} // Before import { useCoinListOffers, useCoinListRequirements } from '@coinlist-co/react'; const { offersState } = useCoinListOffers({ serverOffers: data }); // After import { useOffers, useRequirements } from '@coinlist-co/react'; const { offersState } = useOffers({ data }); ``` #### 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`. | Before | After | | - | - | | `UseOffersOptions.serverOffers` | `UseOffersOptions.data` | | `UseOfferDetailsOptions.serverData` | `UseOfferDetailsOptions.data` | | `UseParticipationsOptions.serverData` | `UseParticipationsOptions.data` | | `UseRequirementsOptions.serverData` | `UseRequirementsOptions.data` | | `OffersGrid` prop `serverOffers` | `OffersGrid` prop `data` | | `RequirementsChecklist` prop `serverData` | `RequirementsChecklist` prop `data` | | `RequirementsServerData` type | `RequirementsData` type | **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. ```tsx theme={null} // Before if (requirementsState.type === 'CONTENT') { const byOption = requirementsState.requirementsByOptionId; } // After if (requirementsState.type === 'CONTENT') { const byOption = requirementsState.requirements; } ``` #### `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: | Entry point | Contents | | - | - | | `@coinlist-co/react` | React client — components, hooks, `CoinListClient` | | `@coinlist-co/react/server` | Node/SSR — `CoinListServer` | | `@coinlist-co/react/shared` | **New** — isomorphic domain types (`Offer`, `Participation`, `Requirement`, errors, pagination, etc.) | 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: ```ts theme={null} // Before (sub-path imports — no longer valid) import { CoinListClient } from '@coinlist-co/react/client'; import { useOffers } from '@coinlist-co/react/client/hooks'; // After import { CoinListClient, useOffers } from '@coinlist-co/react'; ``` #### `OffersGridProps` type renamed from `Props` The exported type for `OffersGrid` props was the generic name `Props`. It is now exported as `OffersGridProps`. ```ts theme={null} // Before import type { Props } from '@coinlist-co/react'; // After import type { OffersGridProps } from '@coinlist-co/react'; ``` ### 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. ### Added #### Client (`@coinlist-co/react`, `@coinlist-co/react/client`) * `fetchOfferRequirements(offerId)` on `CoinListClient` — returns requirements grouped by option ID (`Record`). * `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`. ### 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`. ### Added #### Client (`@coinlist-co/react`, `@coinlist-co/react/client`) * Added support for passing `className` and `containerClassName` to ``, ``, and ``. ### 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`. ### Added #### Client (`@coinlist-co/react/client`) * `fetchAllOffers()`, `fetchOffersPage()`, and `fetchOfferDetails()` on `CoinListClient` for fetching offers data. * `` — batteries-included component that loads offers and displays them in a responsive grid, with `` 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. ### 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+.
# Eligibility and compliance Source: https://docs.passage.coinlist.co/eligibility-compliance Passage runs KYC, eligibility, and ongoing screening so you can offer regulated assets without standing up a compliance function. The short version: you do not have to worry about compliance but you still get to provide compliant assets and offerings. ## Who performs KYC? Passage does. Users are taken through Passage KYC and AML screening as part of onboarding, run by a dedicated compliance team, covering KYC, KYB, accreditation checks, and jurisdiction-specific workflows. Issuers determine the requirements for each offering and Passage implements them. None of it lands on your engineering or compliance roadmap. ## Can eligibility requirements vary by offering? Yes. Issuers configure eligibility per offering, including jurisdiction restrictions, KYC level, accreditation requirements, wallet requirements, and other conditions. Requirements also differ by asset category. Token sales have different rules than tokenized stocks. Eligibility requirements can vary by issuer, asset, and jurisdiction, creating significant implementation and maintenance overhead. The Passage SDK does most of the work for you: Passage reads the requirements for the offering your user is interested in and walks them through exactly those steps, nothing extra. Your app never has to know what the rules are, or notice when an issuer changes them. To read requirements and gate your invest flow on them, see [Fetch offers](/sdk/fetch-offers) and [Sale details](/sdk/sale-details). ## When is eligibility checked? Before a user can invest. Passage checks each user against the requirements for that specific offering, and anyone ineligible or restricted is screened out before the transaction can complete. Screening does not always stop at purchase. Some issuers require ongoing screening of allowlisted wallets, Superstate being one example, and Passage runs that monitoring. You don't need to perform additional compliance screening post-transaction. Where an offering calls for post-distribution checks, we handle those too. ## Do I need my own licenses to integrate Passage? International adoption, clearer global regulatory frameworks, and targeted SEC relief in the U.S. have made it possible to offer access to digital asset securities, RWAs, cryptocurrencies, and other financial products on the same interface without blurring regulatory obligations. As a non-custodial service, Passage is designed to allow maximum flexibility for you and your app. Licensing obligations depend on your jurisdiction and your existing regulatory status, so treat this as a starting point rather than legal advice and talk to us early about your setup. Passage was designed to help keep the regulatory weight off your build. Passage can support the most stringent regulated activity and everything down to unregulated offerings, so you can move quickly across the full spectrum. CoinList does not provide investment, legal, or tax advice. Nothing in this documentation constitutes or should be construed as investment, legal, or tax advice, or as an investment recommendation. ## Does my existing KYC carry over? If you already perform some form of identity verification, talk to us and we will see what options exist to accommodate your setup. Who controls the keys, which wallets you can support, and which chains are live. # Offer onchain assets with one integration Source: https://docs.passage.coinlist.co/index Passage is an SDK that lets fintech platforms offer tokenized real-world assets, token sales, and other offerings to their users through a single integration. It is built for digital wallets, neobanks, protocols, investment apps, and exchanges. **You bring the app and the users. Passage brings the assets, the compliance, and all the plumbing in between.** Learn how the platform works before you integrate. Jump into the SDK and ship your first flow. See what Passage takes on so your team does not have to. ## What does Passage solve? Offering tokenized assets the hard way means signing up every issuer one at a time, then rebuilding KYC, eligibility, allowlisting, funding, and settlement for each of them. Passage does that work once, so you never have to do it at all. | What you get | What it means | | - | - | | **One integration, a whole catalog** | Passage connects you to multiple tokenization platforms (Superstate, Ondo, WisdomTree, CoinList, with more coming). Every platform we add shows up for you with no extra build on your side. | | **Compliance is our job, not yours** | Every issuer carries its own compliance, jurisdiction, disclosures, and eligibility requirements. Passage runs KYC, AML screening, accreditation, and jurisdiction checks, and tracks the requirements as issuers change them. You get to offer regulated assets and stay compliant without standing up a compliance function or repeating the work for each issuer. | | **We take care of the unglamorous parts** | Allowlist registration, funding contracts, payments, settlement, distributions, and reporting all sit on our side of the line. | | **A marketplace that keeps growing** | Instead of a single-source catalog, your users get an expanding, multi-platform selection of assets. You can pick and choose which assets and offerings to make available to your users, or offer everything. | The result is one surface to build against instead of one per issuer. Individual issuers can still attach their own one-off requirements to an offering, and when they do, Passage walks your users through them inside the same flow. ## What can I offer my users? | Category | What your users get | | - | - | | **Equities** | Tokenized US stocks and equity funds, including diversified exposure such as the S\&P 500, embedded natively in your app. | | **Fixed income (yield)** | US Treasury funds, corporate credit, and CLOs (ETFs and mutual funds). | | **Commodities** | Gold, silver, oil, and natural gas, as a single token. | | **Token sales** | Access to token offerings run through CoinList. | | **DeFi utility** | Allowlisted access to permissioned pools and lending markets, for example Uniswap V4 permissioned pools or Kamino lending pools. | See [Asset catalog](/assets/catalog) for the live catalog. ## Am I the kind of company this is for? Passage is built for consumer fintech platforms: digital wallets, neobanks, protocols, investment apps, and exchanges that want to offer regulated, tokenized financial products and assets to their users without building compliance and issuer infrastructure themselves. See [Eligibility and compliance](/eligibility-compliance). ## How do I start building? Head to the [SDK quickstart](/sdk/quickstart). Credentials, install, and your first offer. REST endpoints for custom integrations. Talk to us about your integration. The three parties in an offer, the user journey, and who holds the money. # Building the Checkout flow Source: https://docs.passage.coinlist.co/sdk/checkout Render CheckoutContainer with an offer, a chain, your wallets and one config object. It picks the right provider's flow for you. `CheckoutContainer` is the **plug-and-play checkout**: hand it an offer and it renders whichever provider that offer belongs to. Superstate swap, Ondo buy, Ondo sell, CoinList token sale - you write one integration, not four. This is the highest rung of the SDK (**L3, components**) and the one to start from. Reach for a provider-specific container or the hooks below it only when you need markup the SDK does not give you. Prefer `CheckoutContainer` over `OndoBuyCheckoutContainer`, `OndoSellCheckoutContainer` or `SuperstateSwapCheckoutContainer` even when you know the offer type. The provider containers exist, but routing on `offer.type` yourself means a new provider is your problem; using `CheckoutContainer` avoids requiring provider-specific routing when new providers are added. ## Prerequisites * A completed [OAuth](/sdk/oauth-authentication) session with a working `CoinListProvider` * An `OfferDetail` from [Display offer details](/sdk/sale-details) * A [`CheckoutWalletSelection`](/sdk/wallets#checkoutwalletselection-what-checkout-takes) built from your wallet stack ## Render it ```tsx theme={null} "use client"; import { CheckoutContainer, defaultCheckoutConfig, type CheckoutWalletSelection, } from "@coinlist-co/react"; import { AssetSymbol, type OfferDetail } from "@coinlist-co/react/universal"; // Per-integration, not per-offer - except `side`, which is per-render. const checkoutConfig = defaultCheckoutConfig({ "ondo::swap": { symbol: (offer) => AssetSymbol(offer.asset.code), side: () => "buy", }, "coinlist::token_sale": { render: (offer) => , }, }); export function Checkout({ offer, wallets, }: { offer: OfferDetail; wallets: CheckoutWalletSelection; }) { return ( ); } ``` That is the whole integration. The container is **self-scoped**: it renders fully styled with no style provider in the tree. ### Props | Prop | Type | Description | | - | - | - | | `offer` | `OfferDetail` | The offer being bought. Its `type` decides which provider renders. Fixed for the lifetime of the mount. | | `chain` | `EthereumChain` | Where the order executes: balance reads, the approval, and the broadcast all go here. Fixed for the lifetime of the mount. | | `wallets` | `CheckoutWalletSelection` | The wallets the buyer can spend from, plus connect and disconnect lambdas. See [Wallets](/sdk/wallets). | | `config` | `CheckoutConfig` | What each offer type needs beyond the offer itself. See below. | | `enabled` | `boolean` | Default `true`. `false` turns off every request and timer in every provider, for a checkout that is mounted but not on screen yet. | | `className` | `string` | Applied to the rendered provider view. | ## One mount, one offer, one chain **To render a different offer or chain, remount rather than reassign.** Give the container a `key` that changes with them, as the example above does. `offer` and `chain` are read as fixed for the lifetime of the mount. Each provider's viewmodel owns a step machine - which wallet was authorized, how much is being spent - and that progress is bound to the offer it was made against: a Superstate wallet authorization is allowlisted per offer, and an ERC-20 approval is granted per chain. Changing either prop in place would carry a completed step from one offer onto another. Worse, a swap could land mid-execution, between an approval and the broadcast it was granted for. React discards the old flow's state along with its component instance, which is the only reset that cannot strand an in-flight transaction. ## CheckoutConfig `CheckoutConfig` is keyed by `OfferType`, and **every key is required**. That is not an oversight. A host that renders `CheckoutContainer` is saying it will handle whatever offer it is given, so when a new offer type ships, every host breaks **at compile time** rather than rendering a blank screen their users find first. A new offer type arriving in a catalog is something you have to decide about. Almost nothing in the config is per-offer. It is per-*integration*, so you build one object and pass the same one for every offer you render. The one exception is Ondo's `side`, which is per-*render*. | Key | Field | What you supply | | - | - | - | | `ondo::swap` | `symbol: (offer) => AssetSymbol` | **Required.** Ondo's API symbol for the offer's asset, e.g. `AAPLon`. | | | `side: () => OrderBookSide` | **Required.** `"buy"` invests in the asset, `"sell"` liquidates a holding of it. | | | `onOrderConfirmed?: (order) => void` | Optional. Fired once an Ondo swap has mined, either way. The confirmation dialog shows regardless. | | `superstate::swap` | `onOrderConfirmed?: (order) => void` | Optional. Superstate needs nothing required today: the swap contract is looked up from the chain, and the funding assets and issuer are the provider's own constants. | | `coinlist::token_sale` | `render: (offer) => ReactNode` | **Required.** Token sales currently require a custom UI implementation. | `defaultCheckoutConfig({ ... })` fills in the entries that have a sensible default, so you only write the ones that do not. Today that is `superstate::swap`; it exists mostly so a provider that gains an *optional* setting can be defaulted there rather than appearing in every host's config object. ### Why `symbol` takes the offer `symbol` is a function of the offer rather than a bare value because the symbol is per-*asset* while the config is per-integration: a host listing `AAPLon` and `TSLAon` side by side has one config and two answers. The SDK cannot answer it for you. Ondo's symbol tracks the underlying ticker and changes on a rebrand, and it already disagrees with `offer.asset.code` on Sepolia, where a mock asset stands in. If your catalog does agree, return `AssetSymbol(offer.asset.code)`; if it does not, map the exceptions. ### Why `side` is a thunk `side` takes no offer, because no offer can answer it. `offer.type` is `ondo::swap` in both directions - CoinList exposes one Ondo offer type, and the direction is a property of the page rather than of the catalog. Close the thunk over whatever selects it: a route, a tab, a prop. It is required with no default. 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 Ondo flow holds a committed quote and an approval granted for one specific token, so a resolver that starts answering differently mid-flow is ignored rather than obeyed. Include the side in the container's `key`, as ["One mount, one offer, one chain"](#one-mount-one-offer-one-chain) does for the offer and the chain. `symbol` and `side` run only when an `ondo::swap` offer is showing. A Superstate or token-sale checkout never calls them, so `symbol` receives an `OndoSwapOfferDetail` and only has to answer for Ondo offers. ## What happens underneath `CheckoutContainer` switches on `offer.type` and mounts only the matching provider's container, handing it the offer narrowed to its variant. Only that provider's viewmodel runs, so the others fetch nothing and hold no timers. `enabled` is forwarded to the mounted container as-is: it pauses a checkout that is mounted but not on screen yet. The `switch` on `offer.type` is exhaustive, and its `default` branch assigns to `const exhaustive: never`, so a new offer type is a compile error inside the SDK as well as in your config. Each product exposes a **god viewmodel** wrapping its entire flow, plus the smaller hooks it is built from. Call one behind your own markup when you want the SDK's logic, state and error handling but not its UI. ```tsx theme={null} import { useOndoBuyCheckoutViewModel } from "@coinlist-co/react"; const { state, onEvent } = useOndoBuyCheckoutViewModel({ offer, ondoSymbol: AssetSymbol(offer.asset.code), chain: "ethereum_mainnet", wallets, }); ``` `offer` is the narrowed variant: `OndoSwapOfferDetail` here and for `useOndoSellCheckoutViewModel`, which takes the same options, and `SuperstateSwapOfferDetail` for `useSuperstateSwapCheckoutViewModel`, which takes them minus `ondoSymbol`. All three return `{ state, onEvent }`, where `state` is a discriminated union you `switch` on exhaustively - including an `error` arm, on both Ondo products, for an offer that cannot be swapped on the chain you passed. If you call these directly you are routing on `offer.type` yourself. Narrow the offer and call each viewmodel inside its own component, so only the matching one is mounted. That routing is exactly what `CheckoutContainer` does for you. Per-provider detail: [Superstate Swap](/sdk/swap-flow), [Ondo Swap](/sdk/ondo-swap), [CoinList Token Sale](/sdk/invest-flow). The conventions the hooks share - `enabled`, `data`, and the shape of `state` - are in [SDK structure](/sdk/structure#hooks). Below the hooks are plain TypeScript namespaces on `CoinListClient`, with no React and no state. Use them for a non-React app, a custom state layer, or a step the SDK's flows do not cover. ```ts theme={null} const { coinlist } = useCoinList(); // Ondo: approve, then commit a firm quote, then broadcast it. // prepareBuy spends the funding coin; prepareSell delivers the asset. const prepared = await coinlist.ondo.prepareBuy({ /* ... */ }); const filled = await coinlist.ondo.executeSwap({ /* ... */ }); // Superstate: prove the wallet, then swap. await coinlist.superstate.authorizeWallet({ /* ... */ }); await coinlist.superstate.execute({ /* ... */ }); // CoinList token sale: approve and record a participation. await coinlist.tokenSale.execute({ /* ... */ }); ``` These return **step-tagged results rather than throwing**, so you can map each failure to your own copy. The full parameters, progress phases and error steps live on each provider's page. ## Next steps Wallet allowlisting, live quotes, and what Superstate needs from you. The symbol resolver, the side thunk, and what each direction approves. The render slot, the approval, and tracking participations. Every error shape the flows return, and how to handle it. # Errors and edge cases Source: https://docs.passage.coinlist.co/sdk/errors Every error shape the SDK flows return, what causes it, and how to handle it. The SDK draws a line between two kinds of failure. * **Flow-level failures** are expected outcomes. The on-chain flows (`coinlist.superstate.execute`, `coinlist.tokenSale.execute`, `coinlist.superstate.authorizeWallet`, `coinlist.ondo.prepareBuy`, `coinlist.ondo.prepareSell`, `coinlist.ondo.executeSwap`) return a tagged result rather than throwing, so you handle them with a branch, not a `catch`. * **Thrown errors** are for genuinely exceptional conditions, such as calling an authenticated method without a session. ```tsx theme={null} const result = await coinlist.superstate.execute({ /* ... */ }); if (result.type === "success") { showReceipt(result.swapTxHash, result.outputAmount); } else { handleSwapError(result.error); // result.error.step tells you where it stopped } ``` ## Wallet errors `WalletError` is the shared vocabulary for anything the user's wallet does. It appears as the `cause` on any step that asked the wallet to sign or send. | `type` | Meaning | Suggested handling | | - | - | - | | `user_rejected` | The user dismissed the wallet prompt. | Not an error state. Return the user to the previous step so they can retry. | | `insufficient_funds` | The wallet cannot cover the amount or the gas. | Tell the user what is short. Retrying without a top-up will fail again. | | `contract_reverted` | The transaction reverted on-chain. Carries a `reason` string. | Surface a generic message and log `reason`. Usually a stale quote or a failed eligibility check. | | `timeout` | The receipt did not arrive in time. Carries the `hash`. | The transaction may still confirm. Link the user to an explorer with `hash` rather than prompting a resend. | | `unknown` | Unclassified. Carries the original `cause`. | Show a generic error and log `cause`. | If you drive a wallet yourself, `classifyWalletError(error, { hash })` turns a raw thrown wallet error into a typed `WalletError`. Pass `hash` when you are awaiting a receipt so a timeout can be reported against the right transaction. ## Superstate swap errors `coinlist.superstate.execute` returns `SwapExecutionError`, tagged by step. | `step` | What happened | | - | - | | `status-check` | Reading the swap contract's status failed. | | `swap-paused` | The swap contract has suspended swapping and may resume. Ask the user to try again shortly. | | `swap-stopped` | The swap contract is permanently halted. The swap has closed. | | `allowance-check` | Reading the current ERC-20 allowance failed. | | `approval` | The user did not approve the ERC-20 spend. Carries a `WalletError` cause. | | `approval-reverted` | The approval transaction reverted on-chain. | | `swap` | The user did not sign the swap transaction. Carries a `WalletError` cause. | | `swap-reverted` | The swap transaction reverted on-chain. | The wallet is only asked to sign after all read-only checks pass, so a user never signs a transaction the swap would revert on for a reason the SDK could have caught first. ## Token sale errors `coinlist.tokenSale.execute` returns `TokenSaleExecutionError`. | `step` | What happened | | - | - | | `allowance-check` | Reading the current ERC-20 allowance failed. | | `allowance-reset` | The user did not sign the reset to zero. Carries a `WalletError` cause. | | `allowance-reset-reverted` | The reset transaction reverted on-chain. | | `approval` | The user did not approve the ERC-20 spend. Carries a `WalletError` cause. | | `approval-reverted` | The approval transaction reverted on-chain. | | `participation` | The approval succeeded but recording the participation failed. Carries `approvalTxHash`. | The `participation` step is the one to handle carefully. The user's funds are already approved on-chain, but the participation was not recorded. Keep `approvalTxHash`, surface it to the user, and reconcile rather than asking them to approve a second time. ### Allowance resets The `allowance-reset` steps only occur when the wallet already holds a non-zero allowance that must be set to zero first. USDT-style tokens reject a non-zero to non-zero `approve()`, so the SDK resets before re-approving. This means those users see two wallet prompts instead of one, which is worth reflecting in your loading copy. ## Ondo swap errors Placing an Ondo order is two calls, so it has two error types. `coinlist.ondo.prepareBuy` and `coinlist.ondo.prepareSell` both return `OndoSwapPreparationError` - every arm names a *remedy* rather than a direction, and an unsupported chain, a refused approval and a build that would not complete are fixed the same way whichever token was being approved. | `step` | What happened | | - | - | | `unsupported-chain` | The offer names no CoinList swap contract on the requested chain. Carries the `chain`. Both Ondo checkouts surface this as a flow-level `error` state before any step runs. | | `allowance-check` | Reading the current ERC-20 allowance failed. | | `approval` | The user did not approve the ERC-20 spend. Carries a `WalletError` cause. | | `approval-reverted` | The approval transaction reverted on-chain. | | `insufficient-allowance` | The approval mined, but the backend still reads a short allowance. Usually its RPC node lagging a block. **Retry, do not re-approve.** | | `build-transaction` | Building the transaction failed. | `coinlist.ondo.executeSwap` returns `OndoSwapExecutionError`, for either direction. | `step` | What happened | | - | - | | `quote-expired` | The built calldata is at or past its deadline. Build a fresh transaction. | | `swap` | The user did not sign the swap transaction. Carries a `WalletError` cause. | | `swap-reverted` | The swap transaction reverted on-chain. | `quote-expired` is a refusal to broadcast rather than a failure of a broadcast. It would revert on-chain and cost the user gas, and it is fixed by building a fresh transaction rather than by retrying the same one. **`spender-mismatch` was removed in v0.12.0.** The spender now comes off the offer's `swapContracts`, and the backend compares the built transaction's destination to the contract it serves there, so a third comparison would check one source against itself. A wrong spender still surfaces, as `insufficient-allowance`. ## Wallet authorization errors `coinlist.superstate.authorizeWallet` returns `WalletAuthorizationError`. See [Superstate Swap](/sdk/swap-flow#error-steps) for the full table and the progress phases. ## Wallet connection errors `ConnectWalletError` carries a `code`, a `message`, and a `retryable` flag. Respect `retryable` when deciding whether to offer a retry button. | `code` | Meaning | | - | - | | `not_authenticated` | No CoinList session. Send the user through [OAuth](/sdk/oauth-authentication) first. | | `user_rejected` | The user dismissed the connection prompt. | | `wallet_not_whitelisted` | The wallet is not allowlisted for this offer. See [Superstate Swap](/sdk/swap-flow#step-3-allowlist-the-wallet). | | `max_wallets_reached` | The user has already connected the maximum number of wallets for the offer. | | `unknown` | Unclassified. | ## OAuth errors `useCompleteOAuth` fails with a `CompleteOAuthFailureReason`, which is either `complete_request_failed` or one of the client-side reasons below. | Reason | Meaning | | - | - | | `user_canceled` | The user declined authorization. | | `missing_state` | No `state` parameter came back on the redirect. | | `invalid_state` | The returned `state` did not match the stored value. Treat as a possible CSRF attempt and restart sign-in. | | `missing_code` | No authorization code on the redirect. | | `missing_code_verifier` | The stored PKCE verifier was gone, usually because storage was cleared mid-flow. | | `complete_request_failed` | The code-for-token exchange against your backend failed. | ## KYC token errors `useKycToken` fails with `not-authenticated` when there is no session, or `generic-error` for everything else. ## Thrown errors | Error | When | | - | - | | `NotAuthenticatedError` | An authenticated method was called without a valid session. | | `HttpError` | The API refused a request. Carries the `response`; read the body's error code with `apiErrorCode(error)`. | | `ValidationError` | Arguments the type system accepts but the API cannot, such as an Ondo quote sized by both `tokenAmount` and `notionalValue`, or an unrecognized chain string. | | `CoinListClientInitializationError` | `useCoinList()` was called outside `CoinListProvider`. | | `NotImplementedError` | A surface that is not available in the current environment was called. | | `WritableSessionStoreRequiredError` | `auth.completeOAuth` or `auth.logout` was called on a `CoinListServer` with a read-only session store. From `@coinlist-co/react/server`. | ### Reading an API error code `HttpError` is what every API call rejects with when the backend refuses a request, so it is the only way to catch a 4xx by type. Catching by type is half of it - telling two 422s apart takes the code inside the body, which is what `apiErrorCode` reads. Below, `insufficient_allowance` is the code `POST /v1/ondo/swap/buy` and `POST /v1/ondo/swap/sell` return when the wallet has not approved the swap contract for the amount. `prepareBuy` and `prepareSell` already absorb it into their `insufficient-allowance` step, so you only write this when you call a builder yourself. ```ts theme={null} import { apiErrorCode, HttpError } from "@coinlist-co/react/universal"; try { await coinlist.ondo.buildBuyTransaction(params); } catch (error) { if (error instanceof HttpError) { switch (apiErrorCode(error)) { case "insufficient_allowance": return showApprovalPrompt(); default: return showGenericError(); } } throw error; } ``` ## Next steps Wallet authorization, its phases, and its error steps. Entry points and what each one exports. # Fetch offers Source: https://docs.passage.coinlist.co/sdk/fetch-offers List Passage offers with OffersGridContainer, pre-fetch them on your server for SSR, and read token display metadata. After [OAuth](/sdk/oauth-authentication), you can load every sale the current user can see and render them with the SDK - no hand-written HTTP integration for the common case. ## Prerequisites * `CoinListProvider` with working `getAccessToken` (session established on your backend) * User signed in: your `getAccessToken` returns a non-null access token after OAuth (see [Set up OAuth authentication](/sdk/oauth-authentication)) ## Recommended: OffersGridContainer `OffersGridContainer` uses `useOffers` internally and renders a responsive grid of offer cards with loading, error, and empty states. It also loads CoinList's public token registry, so each card shows the registry's name and logo for the offer's token where the registry lists it, and the offer's own fields otherwise. ```tsx theme={null} "use client"; import { OffersGridContainer } from "@coinlist-co/react"; export function OffersSection() { return ( { // Navigate or open your detail route - offer.id is the API offer id console.log("Selected offer", offer.id); }} /> ); } ``` Optional props: * **`maxColumns`** - upper bound for the grid when space allows (default `3`) * **`loading`**, **`error`**, **`emptyState`** - replace default slots with your own React nodes * **`onOfferClick`** - when set, cards become interactive; omit for read-only lists * **`data`** - `{ offers?, tokens? }` pre-fetched on your server for SSR: `offers` from `coinlist.offers.list()`, `tokens` from `coinlist.tokens.list()`. Each half skips its own client-side request; pass both for a grid that makes no request of its own. * **`enabled`** - default `true`. Pass `false` to mount the grid without fetching until it is needed. For full control over markup while keeping the SDK's logic, call `useOffersGridViewModel()` yourself and render `OffersGridView` (or your own markup) against the state it returns. ## Server-side pre-fetch Every hook and container that fetches accepts `data`. Pass it and the component **uses that data as-is and skips the initial client-side fetch** - the user sees offers in the first paint instead of a spinner. This is the SSR path. Your Server Component calls `CoinListServer`, and the browser renders with the answer already in hand: ```tsx theme={null} // app/offers/page.tsx - a Server Component import { OffersGridContainer } from "@coinlist-co/react"; import { coinListServerForRSC } from "@/lib/coinlist-server-readonly"; export default async function OffersPage() { const server = coinListServerForRSC(); const [offers, tokens] = await Promise.all([ server.offers.list(), server.tokens.list(), ]); return ; } ``` Use a **read-only** session store in Server Components: they can read cookies but not write them, so a writable store would silently consume a refresh token. See [read-only session stores](/sdk/oauth-authentication#read-only-session-stores-server-components). The same prop exists on the hooks (`useOffers({ data })`) and on the other data-backed containers, including `RequirementsChecklistContainer`. Where a hook exposes `refetch()`, the seed answers the initial render only - an explicit refetch still goes to the network. `coinlist.offers.list()` on the server also takes an optional app-level token from `coinlist.auth.clientCredentials()`, so a public catalog page can render with no user session at all. ## Going lower Open these when you need custom layouts or non-React code. Use `useOffers` from `@coinlist-co/react` when you want full control over markup but still prefer reactive loading state. The hook exposes `offersState`: * **`LOADING`** - provider not ready or request in flight * **`ERROR`** - `reason` is `not-authenticated` or `generic-error` * **`CONTENT`** - `offers` is every page of offers merged (same data as `coinlist.offers.list()`) ```tsx theme={null} "use client"; import { useOffers } from "@coinlist-co/react"; export function CustomOffersLayout() { const { offersState } = useOffers(); if (offersState.type === "LOADING") return

Loading…

; if (offersState.type === "ERROR") return

Could not load offers.

; return (
    {offersState.offers.map((offer) => ( // `Offer` is a union on `type`: an Ondo swap runs no sale window, // so it has no `tagline` to read until you narrow.
  • {offer.type === "ondo::swap" ? offer.slug : offer.tagline}
  • ))}
); } ``` Pass `data` if you have server-fetched offers to hydrate from: `useOffers({ data })`. Pass `enabled: false` to skip the fetch while the list is not needed yet.
Inside React you usually reach these through `useCoinList()`: * **`coinlist.offers.list()`** - iterates every paginated page and returns all offers (throws `NotAuthenticatedError` if the user is not logged in) * **`coinlist.offers.listPage(params)`** - one page of API results when you implement your own pagination UI ```tsx theme={null} "use client"; import { useCoinList } from "@coinlist-co/react"; import { useEffect } from "react"; export function OffersDebugLogger() { const { coinlist, isReady } = useCoinList(); useEffect(() => { if (!isReady) return; void coinlist.offers.list().then((offers) => { console.log(offers.length); }); }, [coinlist, isReady]); return null; } ``` For imperative, non-hook usage, create a `CoinListClient` with `createCoinListClient` from `@coinlist-co/react`. Server code (Next.js Route Handlers, Server Components) should use the equivalent `coinlist.offers.list()` on `CoinListServer` from `@coinlist-co/react/server` instead - on the server it also takes an optional app-level token from `coinlist.auth.clientCredentials()`, so it can run with no user session. The SDK calls the same API documented in the [API reference](/api-reference). Use raw HTTP only if you are not using React, or you need fields or flows the SDK does not expose yet. You still need a valid Bearer access token from your OAuth session.
## Token display metadata Offers carry chain and address pairs in `offer.tokens`, but not the name, symbol, decimals or logo you need to render them. Those come from CoinList's public token registry through `coinlist.tokens`. ```tsx theme={null} "use client"; import { useTokenMetadata } from "@coinlist-co/react"; export function TokenBadge({ chain, address }) { const { state } = useTokenMetadata({ chain, address }); if (state.type !== "CONTENT" || state.tokenMetadata === null) return null; const { symbol, logo } = state.tokenMetadata; return ( {symbol} ); } ``` A `TokenMetadata` carries `identifier`, `name`, `symbol`, `decimals`, `logo` and `logoDark`. `logo` is a union: `VECTOR` is a single SVG `url`, while `RASTER` carries an `original` plus webp `variants` at 32 to 512px - pick the smallest variant that covers your render size, or `original` if none does. `coinlist.tokens` is **the one unauthenticated namespace**: no method needs a logged-in user, and reads go to the registry's CDN rather than the CoinList API. It works with no session at all, and with a read-only server session store. * **`coinlist.tokens.get(token)`** returns metadata for one token, or `null` when the registry does not list it. The registry is curated, so `null` is a normal answer: fall back to your own display defaults rather than treating it as an error. * **`coinlist.tokens.list(chain)`** returns every token the registry lists for a chain, in one request. Prefer it over calling `get` in a loop when rendering a catalog - two hundred tokens is still a single snapshot download. A missing chain snapshot throws rather than returning `[]`, because its absence is a deployment problem and not an empty catalog. `useTokenMetadata` takes `enabled` and `data` like the other data hooks, and exposes `refetch()`. A `data` of `null` is a valid seed meaning "not in the registry", and is distinct from leaving `data` undefined. ## Next step Load the full `OfferDetail` for an id from the grid, which is what checkout takes. # CoinList Token Sale Source: https://docs.passage.coinlist.co/sdk/invest-flow CoinList token sales: the checkout render slot, recording a participation with coinlist.tokenSale.execute, and tracking status afterwards. CoinList supplies its own **token sales**. An offer arrives with `type: "coinlist::token_sale"`, and the user commits a stablecoin to buy into it. The participation settles later on CoinList's own schedule, so nothing leaves the wallet during the flow: the user signs an ERC-20 `approve` and that is all. **Recommended:** render [`CheckoutContainer`](/sdk/checkout). It routes on `offer.type`, so your integration handles every provider without knowing which one an offer belongs to. ## What the token sale needs from you Unlike Superstate and Ondo, **the SDK ships no token-sale UI yet**, so `CheckoutContainer` hands this branch back to you through a required render slot. An L3 token-sale component is planned; until it lands, `render` is where your own checkout goes: ```tsx theme={null} const checkoutConfig = defaultCheckoutConfig({ "coinlist::token_sale": { render: (offer) => , }, "ondo::swap": { symbol: (offer) => AssetSymbol(offer.asset.code) }, }); ``` It is a render function rather than a `ReactNode` because the container knows which offer it is being asked about, and a host with a working token-sale page of its own needs that offer to render it. A host that never lists token-sale offers returns `null`. The rest of this page is what goes inside that slot. ## Prerequisites * Completed [OAuth](/sdk/oauth-authentication) with a working `CoinListProvider` * An `OfferDetail` and the `OfferOption` the user picked (from [Display offer details](/sdk/sale-details)) * The **funding contract address** for the offer. CoinList provides it during onboarding - keep it in a constant. * A wallet integration exposed as an `EvmWallet`. The partner demo uses [wagmi](https://wagmi.sh/) + [Reown AppKit](https://reown.com/appkit); any stack that can send transactions works. The adapter is the same one every flow uses - see [Wallets](/sdk/wallets). ```bash theme={null} npm install @coinlist-co/react wagmi @wagmi/core viem @reown/appkit @reown/appkit-adapter-wagmi @tanstack/react-query ``` The token sale is one of the two features that stop at L2: it has a namespace and data hooks, but no components. `useParticipations` is the hook it exposes - see [Track participations](#track-participations) below, which is the same surface used for reading rather than buying. For the buy itself, drive `coinlist.tokenSale.execute` from your own component state. It is a single call that returns a step-tagged result, so there is little for a viewmodel to hold beyond the amount and the current phase. The L1 walkthrough below shows exactly that. `coinlist.tokenSale` runs the whole flow end-to-end: it reads the allowance, submits the ERC-20 `approve`, and records the participation, so you do not hand-roll the on-chain plumbing. ### Flow at a glance ```mermaid theme={null} flowchart LR connect[connect_wallet] idle[enter_amount] investing[investing] success[success] error[error] connect -->|wallet connected| idle idle -->|user confirms amount| investing investing -->|approve mined + recorded| success investing -->|rejected / reverted| error error -->|retry| idle ``` 1. **connect\_wallet** - your wallet UI; the SDK only needs the connected account. 2. **enter\_amount** - the user picks a funding asset (`offerDetail.fundingAssets`) and enters an amount. 3. **investing** - `coinlist.tokenSale.execute` reads the allowance, runs the ERC-20 approval, and records the participation, reporting progress phases. 4. **success / error** - show the recorded participation, or a readable error. Unlike a Superstate swap, a token sale needs **no wallet allowlisting step** - there's no ownership challenge to sign. The user only signs the ERC-20 `approve`. The funding move settles later on Passage's contracts, so nothing leaves the wallet during this flow. ### Step 1: Pin the sale parameters Keep the chain, funding contract, and payment token in one place. `TOKEN_REGISTRY` knows the ERC-20 metadata and per-chain address for USDC and USDT. ```ts constants.ts theme={null} import { EvmContractAddress, type EthereumChain, TOKEN_REGISTRY, USDC_SYMBOL, } from "@coinlist-co/react/universal"; export const SALE_CHAIN: EthereumChain = "ethereum_sepolia"; // The contract the approval grants an allowance to - the approve() spender. // Provided by CoinList during onboarding. export const FUNDING_CONTRACT_ADDRESS = EvmContractAddress( "YOUR_FUNDING_CONTRACT_ADDRESS", ); // The stablecoin the user pays with (USDC, 6 decimals). export const PAYMENT_TOKEN = TOKEN_REGISTRY.erc20(USDC_SYMBOL); export const PAYMENT_TOKEN_ADDRESS = TOKEN_REGISTRY.contractAddress( USDC_SYMBOL, SALE_CHAIN, ); // BCP-47 locale used for amount formatting. export const LOCALE = "en-US"; ``` This page runs on **Sepolia**. To move to mainnet, set `SALE_CHAIN` to `"ethereum_mainnet"` and point `FUNDING_CONTRACT_ADDRESS` at the mainnet contract CoinList gives you - `TOKEN_REGISTRY` already knows the mainnet USDC/USDT addresses. ### Step 2: Bring your wallet The token-sale flow drives **any** wallet through the same `EvmWallet` interface as every other flow. It only ever calls `address`, `writeContract`, and `awaitTx` - there is no message to sign - so an adapter you already wrote works unchanged. The five methods, the wagmi + AppKit adapter, and how wallet errors are classified. ```ts theme={null} import { buildEvmWallet } from "./wagmiEvmWallet"; ``` ### Step 3: Execute the sale `coinlist.tokenSale.execute` runs the whole on-chain sequence and returns a discriminated result. The `amount` is a `BlockchainAmount` in the payment token's decimals - parse the user's input with `parseBlockchainAmount`. ```tsx theme={null} const parsed = parseBlockchainAmount(amountInput, PAYMENT_TOKEN.decimals); if (!parsed.valid || parsed.amount.raw <= 0n) return; const result = await coinlist.tokenSale.execute({ wallet, offerId: offerDetail.id, offerOptionId: option.id, // the OfferOption the user picked assetId: fundingAsset.id, // funding asset from offerDetail.fundingAssets paymentTokenAddress: PAYMENT_TOKEN_ADDRESS, fundingContractAddress: FUNDING_CONTRACT_ADDRESS, chain: SALE_CHAIN, amount: parsed.amount, onProgress: (phase) => setBusy(phase), }); if (result.type === "success") { // result: participation, approvalTxHash const explorerUrl = txExplorerUrl(SALE_CHAIN, result.approvalTxHash); } else { // result.error.step: "allowance-check" | "allowance-reset" // | "allowance-reset-reverted" | "approval" | "approval-reverted" // | "participation" setError("The investment did not go through."); } ``` Phases (wallet popups marked): `checking-allowance` → *(`resetting-allowance` → `confirming-allowance-reset`, only for stale non-zero allowances)* → **`approving`** *(wallet popup)* → `confirming-approval` → `recording-participation` An `approve` is submitted on **every** participation, even when the existing allowance already covers the amount: the backend requires a fresh approval transaction hash and verifies it on-chain before recording. USDT-style tokens with a stale non-zero allowance need one extra reset-to-zero transaction first, reported as the distinct `allowance-reset` phase. When recording fails after the approval mined (`error.step === "participation"`), the error carries the `approvalTxHash` so you can retry the record without asking the user to approve again. ### Putting it together A complete, minimal invest component. It reuses `constants.ts` (Step 1) and the `buildEvmWallet` adapter from [Wallets](/sdk/wallets). ```tsx MinimalInvest.tsx theme={null} "use client"; import { type TokenSaleExecutionPhase, useCoinList, } from "@coinlist-co/react"; import { type Asset, EvmWalletAddress, formatAmount, type OfferDetail, type OfferOption, parseBlockchainAmount, txExplorerUrl, } from "@coinlist-co/react/universal"; import { useAppKit, useAppKitAccount } from "@reown/appkit/react"; import { useState } from "react"; import { useConfig, useWalletClient } from "wagmi"; import { LOCALE, FUNDING_CONTRACT_ADDRESS, PAYMENT_TOKEN, PAYMENT_TOKEN_ADDRESS, SALE_CHAIN, } from "./constants"; import { buildEvmWallet } from "./wagmiEvmWallet"; const GENERIC_ERROR = "Something went wrong. Please try again."; export function MinimalInvest({ offer, option, fundingAsset, }: { offer: OfferDetail; option: OfferOption; fundingAsset: Asset; }) { const { coinlist, isReady } = useCoinList(); const { open } = useAppKit(); const { address, isConnected } = useAppKitAccount(); const { data: walletClient } = useWalletClient(); const config = useConfig(); const [amountInput, setAmountInput] = useState(""); const [busy, setBusy] = useState(null); const [error, setError] = useState(null); const [confirmed, setConfirmed] = useState<{ explorerUrl: string } | null>( null, ); const parsed = parseBlockchainAmount(amountInput, PAYMENT_TOKEN.decimals); const hasAmount = parsed.valid && parsed.amount.raw > 0n; const handleInvest = async () => { if (!isReady || !walletClient || !address || !hasAmount) return; setError(null); setBusy("checking-allowance"); try { const wallet = buildEvmWallet({ address: EvmWalletAddress(address), walletClient, config, }); const result = await coinlist.tokenSale.execute({ wallet, offerId: offer.id, offerOptionId: option.id, assetId: fundingAsset.id, paymentTokenAddress: PAYMENT_TOKEN_ADDRESS, fundingContractAddress: FUNDING_CONTRACT_ADDRESS, chain: SALE_CHAIN, amount: parsed.amount, onProgress: setBusy, }); if (result.type === "success") { setConfirmed({ explorerUrl: txExplorerUrl(SALE_CHAIN, result.approvalTxHash), }); } else { setError(GENERIC_ERROR); } } catch { setError(GENERIC_ERROR); } finally { setBusy(null); } }; if (confirmed) { return (

Investment recorded

Your participation in {offer.name} is being processed.

View approval transaction
); } if (!isConnected || !address) { return ; } return (
{ if (e.target.value === "" || /^\d*\.?\d*$/.test(e.target.value)) { setAmountInput(e.target.value); } }} /> {hasAmount && (

You invest {formatAmount(parsed.amount, LOCALE)} {PAYMENT_TOKEN.symbol}

)} {busy &&

{investPhaseLabel(busy)}

} {error &&

{error}

}
); } function investPhaseLabel(phase: TokenSaleExecutionPhase): string { switch (phase) { case "resetting-allowance": return `Reset your ${PAYMENT_TOKEN.symbol} allowance in your wallet…`; case "approving": return `Approve ${PAYMENT_TOKEN.symbol} in your wallet…`; case "recording-participation": return "Recording your participation…"; default: return "Working…"; } } ``` ### Going further * **Show the user's balance** - `coinlist.erc20.getBalance({ tokenAddress, owner, chain })` reads the connected wallet's balance for a payment token, so you can validate the amount or render a Max button before investing. `useErc20TokenBalances({ address, chain, assets, enabled })` is the polling hook form. * **Lower-level control** - if you drive the ERC-20 `approve` yourself (a custom wallet, batching, a different confirmation UX), skip `coinlist.tokenSale.execute` and record the participation directly with `coinlist.tokenSale.createParticipation`, passing the `approvalTransactionHash` you obtained. `execute` is the batteries-included version of exactly that sequence.
## Track participations A **participation** is the record CoinList keeps of a user taking part in a token sale. It moves through its statuses asynchronously - the SDK does not push, so you re-fetch on session refresh or after a user action. ### Recommended: useParticipations ```tsx theme={null} "use client"; import { useParticipations } from "@coinlist-co/react"; export function Participations({ offerId }) { const { participationsState } = useParticipations(offerId); switch (participationsState.type) { case "LOADING": return ; case "ERROR": // reason: "not-authenticated" | "generic-error" return ; case "CONTENT": return (
    {participationsState.participations.map((p) => (
  • {p.displayAmount} {p.asset.code} - {p.status}
  • ))}
); } } ``` The `offerId` is optional: omit it to list every participation for the signed-in user. Pass `data` to seed it with participations pre-fetched via `CoinListServer#tokenSale.list()` and skip the client-side fetch. See [SDK structure](/sdk/structure#hooks). ### Reading and writing imperatively `coinlist.tokenSale` is available on both the browser client and `CoinListServer`, except `execute`, which needs a wallet. | Method | Returns | | - | - | | `list(offerId?)` | Every participation, auto-paginating. Optionally scoped to one offer. | | `listPage(params)` | One page, when you implement your own pagination. | | `get(id)` | A single participation. | | `createParticipation(params)` | Records a participation. Requires the hash of the ERC-20 approval you submitted first. | | `execute(params)` | Client only. Approves and records in one call. | ```ts theme={null} import { Blockchain } from "@coinlist-co/react/universal"; const existing = await coinlist.tokenSale.list(offerId); const created = await coinlist.tokenSale.createParticipation({ offerId, offerOptionId, chain: Blockchain("ethereum_mainnet"), walletAddress, amount: "1000.00", // decimal token amount, not base units assetId, approvalTransactionHash, // required; verified on-chain before recording }); ``` Two chain params on this page, and only one of them takes a plain string. `execute` takes an `EthereumChain`, a string union, so `chain: "ethereum_mainnet"` compiles. `createParticipation` takes a `Blockchain`, a branded type, so the same literal is a type error - wrap it in `Blockchain(...)`. `amount` is a **decimal token amount** (`"100"` for 100 USDC), not raw base units. The SDK maps the camelCase keys to the API's snake\_case fields; see the [API reference](/api-reference) for the raw request and response schema. Every method requires a logged-in user and throws `NotAuthenticatedError` otherwise. ## Common questions Passage settles the funding move on its own contracts, on its own schedule, after on-chain verification. The user only signs an allowance up to the amount they want to invest; nothing leaves their wallet until the Passage backend later pulls it. This pattern is cancelable (the user can revoke the allowance) and lets Passage batch transfers. The backend hard-requires an approval transaction hash on every participation and verifies that exact transaction on-chain (sender, token, spender, and approved amount) before confirming. A participation recorded without a fresh approval always fails, so `coinlist.tokenSale.execute` submits one even when the standing allowance already covers the amount - the allowance read only decides whether a USDT-style reset-to-zero is needed first. No. Anything implementing the `EvmWallet` interface works - viem directly, Privy, ethers with a thin adapter. The token-sale flow only calls `address`, `writeContract`, and `awaitTx`; it never sees your wallet library. It's the same `EvmWallet` every other flow uses. The error comes back as `{ step: "participation", approvalTxHash }`. The approval is already on-chain, so retry `coinlist.tokenSale.createParticipation` with that hash rather than re-running the whole flow - the user doesn't need to approve again. The flow generalizes: the same `EvmWallet` adapter maps each `EthereumChain` to a network, and `TOKEN_REGISTRY` knows the payment-token addresses per chain. Your account manager will confirm which chains and assets are enabled for your offers. ## Next step The recommended path: one container that renders whichever provider an offer belongs to. # Set up OAuth authentication Source: https://docs.passage.coinlist.co/sdk/oauth-authentication Authenticate users with Passage and authorize your application using OAuth 2.0 with PKCE and a backend session. Users authenticate with Passage and authorize your application - no passwords are shared with you. Passage uses OAuth 2.0 with PKCE (Proof Key for Code Exchange) to secure the redirect flow. This page assumes you run a **backend** you control: the **client secret**, refresh token, and token exchange stay on the server. The React SDK handles PKCE in the browser and session hydration through `getAccessToken`. ## Prerequisites * A registered `client_id` and `redirect_uri` from Passage - [contact us](mailto:support@coinlist.co) if you need access * `redirect_uri` must match your OAuth callback page (for example `https://your-domain.com/oauth/coinlist/callback`) * `client_secret` available **only** on the server * `@coinlist-co/react` installed ([SDK quickstart](/sdk/quickstart)) Working examples line up with the [partner-demo](https://github.com/coinlist/partner-demo) app. ## Wrap your app with CoinListProvider Configure `clientId`, `redirectUri`, and `getAccessToken` so the SDK can run OAuth in the browser and read short-lived access tokens from your session API. ```tsx theme={null} // app/providers.tsx "use client"; import { ClientId, CoinListProvider, RedirectUri, } from "@coinlist-co/react"; import type { ClientConfig } from "@coinlist-co/react"; export function Providers({ children }: { children: React.ReactNode }) { const config: ClientConfig = { clientId: ClientId(process.env.NEXT_PUBLIC_COINLIST_CLIENT_ID!), redirectUri: RedirectUri(process.env.NEXT_PUBLIC_COINLIST_REDIRECT_URI!), getAccessToken: async () => { const res = await fetch("/api/coinlist/oauth/access-token", { credentials: "include", }); if (res.status === 204) return null; if (!res.ok) { throw new Error("GET /api/coinlist/oauth/access-token failed"); } const data = (await res.json()) as { value: string; expiresAt: string }; return { value: data.value, expiresAt: new Date(data.expiresAt), }; }, }; return {children}; } ``` If you omit `baseUrl` in `ClientConfig`, the SDK uses its default API base URL. Set `baseUrl` when you need the SDK to call a different host (for example your own backend proxy). Since v0.11.0 the provider also takes a `locale`, a BCP-47 tag applied to number, date and currency formatting in every SDK component in the tree. It defaults to `'en-US'`, a fixed constant rather than `navigator.language`, so server and client render the same markup. It does not reach embedded third-party flows. The Sumsub identity-verification UI takes its language from [`identityVerificationOptions.locale`](/sdk/requirements#props) on `RequirementsChecklistContainer`, and falls back to Sumsub's own default when you omit it. ```tsx theme={null} {children} ``` ## Sign-in UI (recommended) Use the packaged sign-in card inside any subtree that already has `CoinListProvider`. It calls `coinlist.auth.startOAuth()` when the user clicks **Sign in with CoinList**. ```tsx theme={null} "use client"; import { CoinListSignInCardContainer } from "@coinlist-co/react"; export function SignInPanel() { return ; } ``` If you want the button without the card around it, render `CoinListSignInButton` instead. Importing components from `@coinlist-co/react` pulls in the SDK styles needed for this UI. For auth gating, loading states, and `OffersGridContainer` after sign-in, see [partner-demo `app/page.tsx`](https://github.com/coinlist/partner-demo/blob/main/app/page.tsx). ## Server: CoinListServer and session storage Create a small factory that binds `createCoinListServer` to your outgoing response and session store. The example below uses HTTP-only cookies; adjust to your security model. ```ts theme={null} // lib/coinlist-server.ts import type { NextResponse } from "next/server"; import type { CoinListServer } from "@coinlist-co/react/server"; import { ClientId, ClientSecret, createCoinListServer, RedirectUri, } from "@coinlist-co/react/server"; import { createSessionCookiesStore } from "./session-store"; export function coinListServer(outgoingResponse: NextResponse): CoinListServer { return createCoinListServer({ clientId: ClientId(process.env.NEXT_PUBLIC_COINLIST_CLIENT_ID!), clientSecret: ClientSecret(process.env.COINLIST_CLIENT_SECRET!), redirectUri: RedirectUri(process.env.NEXT_PUBLIC_COINLIST_REDIRECT_URI!), sessionStore: createSessionCookiesStore(outgoingResponse), }); } ``` ```ts theme={null} // lib/session-store.ts import { cookies } from "next/headers"; import { NextResponse } from "next/server"; import type { OAuthSession, SessionStore } from "@coinlist-co/react/server"; import { OAuthRefreshToken } from "@coinlist-co/react/server"; const COINLIST_SESSION_COOKIE = "coinlist_session"; /** Copy Set-Cookie headers from the SDK scratch response onto the response you return (e.g. after refresh in `auth.getAccessToken()`). */ export function copyCookiesFromTo(source: NextResponse, target: NextResponse) { for (const cookie of source.cookies.getAll()) { const { name, value, ...options } = cookie; target.cookies.set(name, value, options); } } export function createSessionCookiesStore( outgoingResponse: NextResponse, ): SessionStore { return { async getSession(): Promise { const raw = (await cookies()).get(COINLIST_SESSION_COOKIE)?.value; if (!raw) return null; let parsed: { accessToken?: { value?: string; expiresAt?: string }; refreshToken?: string; }; try { parsed = JSON.parse(raw); } catch { return null; } if (!parsed.accessToken?.value || !parsed.accessToken?.expiresAt) return null; const expiresAt = new Date(parsed.accessToken.expiresAt); if (Number.isNaN(expiresAt.getTime())) return null; return { accessToken: { value: parsed.accessToken.value, expiresAt }, ...(parsed.refreshToken ? { refreshToken: OAuthRefreshToken(parsed.refreshToken) } : {}), }; }, async setSession(session: OAuthSession | null): Promise { if (session == null) { outgoingResponse.cookies.delete(COINLIST_SESSION_COOKIE); return; } outgoingResponse.cookies.set( COINLIST_SESSION_COOKIE, JSON.stringify({ accessToken: { value: session.accessToken.value, expiresAt: session.accessToken.expiresAt.toISOString(), }, ...(session.refreshToken ? { refreshToken: String(session.refreshToken) } : {}), }), { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", path: "/", }, ); }, }; } ``` ### Read-only session stores (Server Components) In Next.js **Server Components** you can read cookies but cannot write them, so a writable `SessionStore` is unsafe - a refreshed session would be silently discarded after consuming the refresh token, effectively logging the user out. Since v0.5.1, `setSession` is **optional**: omit it to opt into read-only mode explicitly. ```ts theme={null} // lib/coinlist-server-readonly.ts import { cookies } from "next/headers"; import type { OAuthSession, SessionStore } from "@coinlist-co/react/server"; import { ClientId, ClientSecret, RedirectUri, createCoinListServer, } from "@coinlist-co/react/server"; // Read-only store: only `getSession`, no `setSession`. const readOnlySessionStore: SessionStore = { async getSession(): Promise { const raw = (await cookies()).get("coinlist_session")?.value; // …parse and return, same as the writable store above return parseSession(raw); }, }; export function coinListServerForRSC() { return createCoinListServer({ clientId: ClientId(process.env.NEXT_PUBLIC_COINLIST_CLIENT_ID!), clientSecret: ClientSecret(process.env.COINLIST_CLIENT_SECRET!), redirectUri: RedirectUri(process.env.NEXT_PUBLIC_COINLIST_REDIRECT_URI!), sessionStore: readOnlySessionStore, }); } ``` What the SDK does in read-only mode: * **`auth.getAccessToken()`** returns the current token. If it's expired, the SDK surfaces the 401 immediately instead of attempting a refresh (it won't waste a network round-trip with a token it knows is dead). * **`auth.completeOAuth()` and `auth.logout()`** throw `WritableSessionStoreRequiredError` synchronously, before any network call. Use a writable store in your Route Handlers for those flows. * **`coinlist.tokens`** still works. It is unauthenticated, so token metadata reads need no session at all. Don't fake a writable store with a no-op `setSession: async () => {}`. That used to be the workaround for Server Components, but it silently consumed refresh tokens during a refresh attempt and logged the user out. If you upgrade from a pre-0.5.1 version, **remove** any no-op `setSession` you added. See the [v0.5.1 changelog](/changelogs/react#v0-5-1) for the full migration note. Use the writable cookie store (above) inside **Route Handlers** for `auth.completeOAuth`, `auth.getAccessToken()`-driven refresh, and `auth.logout`. Use the read-only store inside **Server Components** for plain reads (for example, pre-fetching offers with `coinlist.offers.list()` for SSR). ## Callback route On the URL you registered as `redirect_uri`, use **`useCompleteOAuth`**: it validates the redirect (state / PKCE via `coinlist.auth.completeOAuth`), POSTs to your complete endpoint, then runs `coinlist.init()` so `getAccessToken` sees the new session. This matches [partner-demo `app/oauth/coinlist/callback/page.tsx`](https://github.com/coinlist/partner-demo/blob/main/app/oauth/coinlist/callback/page.tsx): ```tsx theme={null} // app/oauth/coinlist/callback/page.tsx "use client"; import { useCompleteOAuth } from "@coinlist-co/react"; import type { CompleteOAuthFailureReason } from "@coinlist-co/react"; import { useRouter } from "next/navigation"; export default function CoinListCallbackPage() { const router = useRouter(); useCompleteOAuth({ async postOAuthComplete({ code, codeVerifier }) { const res = await fetch("/api/coinlist/oauth/complete", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ code, codeVerifier }), }); return res.ok; }, onFailure(reason: CompleteOAuthFailureReason) { router.replace(`/?error=${encodeURIComponent(reason)}`); }, onSuccess() { router.replace("/"); }, }); return (

Completing sign-in…

); } ``` Put this file at a path that matches `NEXT_PUBLIC_COINLIST_REDIRECT_URI`. Read `reason` on your home page (for example via `useSearchParams`) if you want to show OAuth errors — see partner-demo `app/page.tsx`. ## Exchange the code on your backend The server SDK performs the token exchange — you do not call the token URL with raw `fetch` in application code. ```ts theme={null} // app/api/coinlist/oauth/complete/route.ts import { NextResponse } from "next/server"; import { AuthorizationCode, CodeVerifier } from "@coinlist-co/react/server"; import { coinListServer } from "@/lib/coinlist-server"; interface CompleteOAuthRequest { code: string; codeVerifier: string; } export async function POST(req: Request) { const body: CompleteOAuthRequest = await req.json(); const { code, codeVerifier } = body; if (typeof code !== "string" || typeof codeVerifier !== "string") { return NextResponse.json({ error: "missing_fields" }, { status: 400 }); } const response = NextResponse.json({ ok: true }); await coinListServer(response).auth.completeOAuth({ code: AuthorizationCode(code), codeVerifier: CodeVerifier(codeVerifier), }); return response; } ``` ## Serve access tokens to the client The browser SDK calls `getAccessToken` whenever it needs a Bearer token. Implement this route with `coinListServer(response).auth.getAccessToken()` so refresh happens server-side. ```ts theme={null} // app/api/coinlist/oauth/access-token/route.ts import { coinListServer } from "@/lib/coinlist-server"; import { copyCookiesFromTo } from "@/lib/session-store"; import { NextResponse } from "next/server"; export async function GET() { const cookieSink = new NextResponse(null, { status: 204 }); const token = await coinListServer(cookieSink).auth.getAccessToken(); if (token == null) { return cookieSink; } const response = NextResponse.json({ value: token.value, expiresAt: token.expiresAt.toISOString(), }); copyCookiesFromTo(cookieSink, response); return response; } ``` When `auth.getAccessToken()` refreshes the session, the SDK may set cookies on `cookieSink`. `copyCookiesFromTo` applies those to the JSON response so the browser gets the updated session cookie. ## Log out Clear the session with the server SDK from a route handler: ```ts theme={null} // app/api/coinlist/oauth/logout/route.ts import { coinListServer } from "@/lib/coinlist-server"; import { NextResponse } from "next/server"; export async function POST() { const response = NextResponse.json({ ok: true }); await coinListServer(response).auth.logout(); return response; } ``` Call `coinlist.auth.logout()` on the client when you need to drop the in-memory token after the server clears the session. ## Token management * **Access tokens** are short-lived. The SDK attaches them as `Authorization: Bearer` on API requests. * **Refresh tokens** are long-lived. Keep them on the server. With the server SDK, `auth.getAccessToken()` refreshes when possible. * Never expose **`client_secret`** or long-lived refresh tokens to the browser bundle. ## Lower-level and custom integrations Use the sections below only if you are not using the defaults above. If you build your own button or entry point, call `auth.startOAuth()` from `useCoinList()`: ```tsx theme={null} "use client"; import { useCoinList } from "@coinlist-co/react"; export function LoginButton() { const { coinlist } = useCoinList(); return ( ); } ``` You can call `coinlist.auth.completeOAuth()` yourself, then POST `code` and `codeVerifier` to `/api/coinlist/oauth/complete`, await `coinlist.init()`, and redirect. Prefer `useCompleteOAuth` when possible - it centralizes failure reasons and avoids duplicate work in React Strict Mode. ```tsx theme={null} "use client"; import { useCoinList } from "@coinlist-co/react"; import { useRouter, useSearchParams } from "next/navigation"; import { Suspense, useEffect, useRef } from "react"; function LegacyCallbackBody() { const { coinlist } = useCoinList(); const searchParams = useSearchParams(); const router = useRouter(); const hasStarted = useRef(false); useEffect(() => { if (hasStarted.current) return; if (searchParams.get("error")) { router.replace("/?error=coinlist_denied"); return; } if (!searchParams.get("code")) return; const oauthRes = coinlist.auth.completeOAuth(); if (oauthRes.type === "error") { router.replace("/?error=coinlist_oauth"); return; } hasStarted.current = true; void (async () => { const res = await fetch("/api/coinlist/oauth/complete", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ code: oauthRes.code, codeVerifier: oauthRes.codeVerifier, }), }); if (!res.ok) { hasStarted.current = false; router.replace("/?error=coinlist_complete_failed"); return; } await coinlist.init(); router.replace("/"); })(); }, [coinlist, searchParams, router]); return

Completing sign-in…

; } export default function LegacyCallbackPage() { return ( Completing sign-in…

}>
); } ```
For non-React browser code, import `createCoinListClient` and `ClientConfig` from `@coinlist-co/react` and call the same namespaces (`coinlist.auth.startOAuth()`, `coinlist.auth.completeOAuth()`, `coinlist.offers.list()`, …). You still need a backend for token exchange in production.
## Next step List offers for the signed-in user with `OffersGridContainer` or hooks, then open [Display offer details](/sdk/sale-details) for one offer. ## Resources * [OAuth 2.0 Playground](https://oauth.net/playground/) * [PKCE authorization code flow (Auth0)](https://auth0.com/docs/get-started/authentication-and-authorization-flow/authorization-code-flow-with-pkce) # Ondo Swap Source: https://docs.passage.coinlist.co/sdk/ondo-swap Buy and sell tokenized stocks with Ondo. One config field switches direction, what each side approves, and the two-call order both share. Ondo supplies **tokenized stocks** - `AAPLon`, `TSLAon` and the rest. An Ondo offer arrives with `type: "ondo::swap"` and runs both ways: a **buy** spends USDC to receive the tokenized asset, a **sell** delivers the asset and settles the proceeds in USDC. **Recommended:** render [`CheckoutContainer`](/sdk/checkout). It detects `ondo::swap`, reads the direction from your config, and renders the whole flow. This page covers what is specific to Ondo, and the levels below the component. Both directions are the same offer, the same contract, and the same two-call order. `offer.type` is `ondo::swap` whichever way the trade runs, because the side is a property of your page rather than of the catalog: the same asset is buyable and sellable at once. ## What Ondo needs from you One config entry, with two required fields. There is no separate sell container - `side` is what picks the flow: ```tsx theme={null} "use client"; import { CheckoutContainer, defaultCheckoutConfig, type CheckoutWalletSelection, } from "@coinlist-co/react"; import { AssetSymbol, type OfferDetail, type OrderBookSide, } from "@coinlist-co/react/universal"; export function OndoCheckout({ offer, wallets, side, }: { offer: OfferDetail; wallets: CheckoutWalletSelection; side: OrderBookSide; }) { const config = defaultCheckoutConfig({ "ondo::swap": { symbol: (offer) => AssetSymbol(offer.asset.code), side: () => side, }, "coinlist::token_sale": { render: () => null }, }); return ( ); } ``` That is the whole integration. A host with a buy page and a sell page passes the same object with a different `side`, or closes the thunk over whatever selects the direction - a route, a tab, a toggle. `side` takes no offer because no offer can answer it: `offer.type` is `ondo::swap` both ways. See [Why `side` is a thunk](/sdk/checkout#why-side-is-a-thunk). **`side` is read once, at mount.** The flow holds a committed quote and an approval granted for one specific token, so a resolver that starts answering differently mid-flow is ignored rather than obeyed. Remount with a `key` covering the offer, the chain and the side, as above. `symbol` refers to **Ondo's API symbol**, not the on-chain `symbol()` and not necessarily `offer.asset.code`. Ondo's symbol tracks the underlying ticker and may change following a rebrand. The values can also differ in test environments, such as Sepolia, where a mock asset may stand in for the underlying asset. If your catalog uses the same symbols as Ondo, you can return `AssetSymbol(offer.asset.code)` directly. If the values differ, map those exceptions explicitly. Your mapping must cover all offers, including those not provided by Ondo, since the same logic runs across the full catalog. See [the warning on the checkout page](/sdk/checkout#why-symbol-takes-the-offer) for additional context. The config also accepts an optional `onOrderConfirmed(order)`, fired once a swap has mined, either way. The confirmation dialog shows regardless; add it only if you want to navigate, refresh a portfolio, or log. **Quotes are priced against Ondo production whatever chain you pass**, because Ondo runs no sandbox. On a testnet the price is real and the money is not. ## Placing an order takes two calls This is the one structural thing to know about Ondo, and it is the same both ways. An order is prepare then execute, not one call, and the split is where the trader's decisions are: 1. **`prepareBuy` / `prepareSell`** approves the swap contract to move the amount, then builds the transaction that moves it. The result is a **committed quote**: firm calldata with an `expiresAt`, valid for about a minute. 2. The trader reviews firm numbers - what goes, the fee, what arrives. 3. **`executeSwap`** broadcasts that calldata verbatim and waits for it to mine. It is one method for both directions, because broadcasting reads calldata and a deadline and knows about neither. Bundling them behind one button would burn most of the transaction's \~60-second life on the approval and hand the trader an expired quote. The approval deliberately comes **first**: building first would spend an attestation on calldata that had already expired by the time they could act on it. The builders spend an attestation on every call. The two reads - `getTradingStatus` and `getQuote` - are free to poll while the user sizes an order. Budget one build per order placed, plus one per refresh the user asks for. **Buy and sell caps are independent.** `getTradingStatus` takes a required `side` and reports `tradable` with size limits for that side only. A working buy does not imply a working sell, and the market can be open one way and closed the other. ## What the two directions do not share Only the transport is common. Beyond it, the amounts mean different things: | | Buy | Sell | | - | - | - | | `amount` you pass | The **funding coin**, USDC, in its base units | The **asset**, in its base units | | What gets approved | USDC on `chain` | The **asset** on `chain`, not a stablecoin | | Where the token's address and decimals come from | `TOKEN_REGISTRY` | The sell quote, and nowhere else | | What the response commits to | An exact quantity | A range: expected, with a floor beneath it | The same integer means a different size on each side, so a size carried across a toggle is wrong by whatever the price is. ### Buying The quantity is attested and exact, with no floor beneath it. The fee is **taken off the deposit rather than added on top**, so the approval never has to cover more than `payInputAmount`. **No CoinList fee is applied to a read quote, and no read discloses one.** Ondo prices exactly the amount passed, so sizing a quote against a user's approval without subtracting the fee first overstates what they receive. ### Selling A sale is the mirror of a purchase: it approves the **asset** and receives USDC. That matters because the SDK ships no registry entry for a provider's asset. `TOKEN_REGISTRY` knows the stablecoins swaps are funded with and nothing else, so on a sale: * the **contract address** the balance is read on and the approval is granted for, and * the **decimals** the typed amount is parsed at both come off the sell quote (`OndoQuote.assetAddress`, `OndoQuote.asset.decimals`) and nowhere else. `useOndoSellCheckoutViewModel` does that resolution for you; a host composing its own viewmodel has to read the quote before it can read a balance. The **settlement coin is USDC**, and it is display-only: the scale of every amount on a built sale comes off the response's `receive_output_decimals`, which is the only authority on how the proceeds are counted. A built sale carries two figures rather than one. `expected` is what the sale should return; `minimum` is the floor the calldata enforces on chain. A fill below `minimum` reverts, so that - not `expected` - is what a seller is actually guaranteed. **Show both.** ### Skipping the wallet step A sell page is usually reached from a position, and a position already names the wallet that holds it. Set [`CheckoutWalletSelection.preselected`](/sdk/wallets#skipping-the-wallet-step) and the checkout opens on the amount, renumbering the two cards that remain: ```ts theme={null} const wallets: CheckoutWalletSelection = { embedded, external, preselected: walletHoldingThePosition, // an EvmWallet, or null to let the user pick connectExternal, disconnectExternal, }; ``` The wallet does not have to appear in `embedded` or `external` - you supply a ready `EvmWallet` either way. Like `side`, it is read once at mount. **The Ondo sell checkout is the only flow that honors `preselected` today.** The field sits on the wallet seam rather than in Ondo's config because nothing about it is one provider's, but a provider that has not adopted it ignores it rather than half-implementing it. `useOndoBuyCheckoutViewModel` and `useOndoSellCheckoutViewModel` each wrap a whole flow - trading status, quotes, the wallet step, the approval, the review and the broadcast - and return `{ state, onEvent }`. They take the same options. ```tsx theme={null} "use client"; import { useOndoSellCheckoutViewModel } from "@coinlist-co/react"; import { AssetSymbol } from "@coinlist-co/react/universal"; export function OndoSellCheckout({ offer, wallets }) { const { state, onEvent } = useOndoSellCheckoutViewModel({ offer, ondoSymbol: AssetSymbol(offer.asset.code), chain: "ethereum_mainnet", wallets, }); switch (state.type) { case "error": // state.reason: "unsupported-chain" return ; case "content": // state.wallet is null when `preselected` skipped the wallet step. // state.amount, state.review, state.sidebar, state.orderConfirmed return ; default: { const exhaustive: never = state; return exhaustive; } } } ``` `OndoBuyCheckoutUiState` and `OndoSellCheckoutUiState` are both discriminated unions with a flow-level `error` arm: an offer that names no CoinList swap contract on the chain you passed has no checkout to render, and the viewmodel discovers that before any step runs, so nothing is fetched and no timer starts. `switch` exhaustively and assign the `default` to `const exhaustive: never = state` so a new step is a compile error rather than a blank screen. Inside the sell flow's `content` arm, `wallet` is `null` exactly when the host preselected a wallet. That `null` is the whole skip - it is what you render nothing for, and the single fact both remaining step numbers derive from. The smaller hooks they compose are exported too, so you can assemble your own viewmodel instead. Four are shared across directions; the rest are per-side, because what a screen reads off them forks: | Hook | Returns | `enabled` | | - | - | - | | `useOndoTradingStatus` | Whether that side's market is open, and its caps. Takes `side`. | yes | | `useOndoPrice` | A pollable read quote. Takes `side` and a size. | yes | | `useOndoWalletSelectViewModel` | The wallet step. | not needed | | `useOndoSidebarViewModel` | The order summary sidebar. | not needed | | `useOndoBuyTransaction` | The committed purchase from `prepareBuy`, with its expiry. | yes | | `useOndoSellTransaction` | The committed sale from `prepareSell`, with its expiry. Never polls. | yes | | `useErc20Balance` | The seller's balance of the asset, keyed by the address off the quote. | yes | | `useOndoBuyAmountViewModel` | The amount step, buy side. Takes `swapContracts`. | not needed | | `useOndoSellAmountViewModel` | The amount step, sell side. | not needed | | `useOndoBuyReviewViewModel` | The review and confirm step, buy side. | not needed | | `useOndoSellReviewViewModel` | The review and confirm step, sell side. | not needed | `useErc20Balance` is the address-keyed counterpart to `useErc20TokenBalances`: a token whose contract address arrives at runtime has no registry symbol to look up, which is exactly the sell case. A `tokenAddress` of `null` fetches nothing, which is the normal state before the first quote lands. Every hook that fetches or polls takes `enabled`, and all of them must be called unconditionally. See [SDK structure](/sdk/structure#hooks) for the conventions they share. `coinlist.ondo` is a plain namespace with no React. The reads and the two builders exist on both the browser client and `CoinListServer`; the wallet-driven halves are client-only, because a backend has no wallet to sign with. | Method | Kind | Notes | | - | - | - | | `getTradingStatus(params)` | read | Free to poll. Requires `side`. Returns `tradable` with size caps, or `not-tradable`. | | `getQuote(params)` | read | Free to poll. Requires `side`. A sell quote carries `assetAddress` and the asset's decimals. | | `buildBuyTransaction(params)` | write | Spends an attestation. `POST /v1/ondo/swap/buy`. | | `buildSellTransaction(params)` | write | Spends an attestation. `POST /v1/ondo/swap/sell`. | | `prepareBuy(params)` | client only | Approves the funding coin, then builds. Step-tagged result. | | `prepareSell(params)` | client only | Approves the asset, then builds. Step-tagged result. | | `executeSwap(params)` | client only | Broadcasts and confirms, either direction. Step-tagged result. | A purchase reads its token from the registry. A sale has to read the quote first, because that is the only place the asset's address and decimals are published: ```ts theme={null} // Selling. The quote is the only source of the asset's address and decimals. const quote = await coinlist.ondo.getQuote({ symbol: AssetSymbol("AAPLon"), side: "sell", tokenAmount: amount, }); const prepared = await coinlist.ondo.prepareSell({ wallet, // your EvmWallet symbol: AssetSymbol("AAPLon"), // Ondo's API symbol chain: "ethereum_mainnet", swapContracts: offer.swapContracts, // the spender, resolved per chain tokenAddress: quote.assetAddress, // the ASSET, not a stablecoin amount, // BlockchainAmount, asset base units onProgress: (phase) => setBusy(phase), }); if (prepared.type === "error") { // prepared.error.step: "unsupported-chain" | "allowance-check" // | "approval" | "approval-reverted" | "insufficient-allowance" // | "build-transaction" return; } // prepared.transaction.expected - what the sale should return // prepared.transaction.minimum - the floor the calldata enforces const filled = await coinlist.ondo.executeSwap({ wallet, transaction: prepared.transaction, chain: "ethereum_mainnet", onProgress: (phase) => setBusy(phase), }); if (filled.type === "success") { // filled.txHash, filled.transaction } else { // filled.error.step: "quote-expired" | "swap" | "swap-reverted" } ``` `prepareBuy` is the same call with `tokenAddress` set to the funding coin and `amount` in its base units, and it needs no quote first. Both flows are **total**: every failure comes back step-tagged rather than thrown, so you map each one to your own copy. `prepareBuy` and `prepareSell` return the same `OndoSwapPreparationError` - every arm names a *remedy*, and an unsupported chain or a refused approval is fixed the same way whichever token was being approved. **Preparation phases**, in order. An allowance that already covers the order skips the approval phases: `checking-allowance` → *(`resetting-allowance` → `confirming-allowance-reset`, only for stale non-zero allowances)* → **`approving`** *(wallet popup)* → `confirming-approval` → `building-transaction` **`executeSwap` phases**: **`broadcasting-swap`** *(wallet popup)* → `confirming-swap` Two error steps are worth calling out: * **`insufficient-allowance`** is separated from the generic build failure because it has a remedy. Reaching it means the approval that just mined is not the one the backend sees, usually an RPC node a block behind. That is a **retry**, not a re-approval. * **`quote-expired`** is a refusal to broadcast rather than a failed broadcast. It would revert on-chain and cost the trader gas, and it is fixed by building a fresh transaction. `executeSwap` broadcasts the calldata **verbatim**. Never re-encode it: the contract verifies a signature over the exact arguments inside. ## Which contract the trader approves The ERC-20 spender comes off the offer, not from a constant in the SDK, and it works the same way on both sides. `OfferDetail.swapContracts` lists every chain the offer can be swapped on with the CoinList contract each settles through, and `swapSpender` reads one out: ```ts theme={null} import { swapSpender } from "@coinlist-co/react/universal"; const spender = swapSpender(offer.swapContracts, chain); // EvmContractAddress | null ``` `null` means this offer cannot be swapped on that chain - an answer, not an error. `CheckoutContainer` and both Ondo viewmodels check it before anything runs and render a flow-level `error` state with `reason: "unsupported-chain"`, so no wallet picker, balance read or price poll happens on a chain the order could never settle on. The address varies by **environment** as well as by chain, which is why one constant could not express it: beta and production both run `ethereum_sepolia` and deploy a different contract on each. `ondoSwapContractAddress(chain)` was removed in v0.12.0 for that reason. ## Next steps The recommended path: one container for every provider, and both directions. The `EvmWallet` seam, and `preselected` for skipping the wallet step. The other swap provider, and what it needs from you. Every error shape the flows return, and how to handle it. # SDK quickstart Source: https://docs.passage.coinlist.co/sdk/quickstart Get OAuth credentials, install the Passage React SDK, and understand client, server, and API layers. This guide covers **credentials**, **installing the SDK**, and how it is organized. When you are ready to wire sign-in, continue with **[Set up OAuth authentication](/sdk/oauth-authentication)**, then **[Fetch offers](/sdk/fetch-offers)**, then **[Display offer details](/sdk/sale-details)**. Examples use **Next.js App Router** patterns; you can adapt the same pieces to other React frameworks as long as you have a **dedicated backend**. Clone the [Partner Demo GitHub repo](https://github.com/coinlist/partner-demo) and follow this tutorial alongside the running example integration. You see it live at [partner.coinlist.dev](https://partner.coinlist.dev/). ## Get OAuth credentials You need values from Passage before you write code: * **`client_id`** - public identifier for your app * **`redirect_uri`** - exact callback URL registered with Passage (must match what you pass in the SDK) * **`client_secret`** - **server-only**; never expose this to the browser or commit it to frontend bundles Request access and registration through [Support](/support) or your Passage contact. Store secrets in server environment variables (for example `COINLIST_CLIENT_SECRET`), and use `NEXT_PUBLIC_` only for values that are safe in the client (typically `client_id` and `redirect_uri`). ## Install the SDK ```bash theme={null} npm install @coinlist-co/react ``` Peer dependencies: `react` and `react-dom` >= 18, and `viem` ^2 (added in v0.9.0 for the on-chain flows). See [Package overview](/sdks) for all import paths and the full namespace map. ## How the SDK is organized The SDK splits three ways, in this order: **environment, then level, then feature.** Learn those three and you can find anything in it. ### Level: how much you want to build Every feature is available at several levels. Lower levels provide more control but require more implementation. **Start with L3 components and use L2 or L1 when you need more control.** 1. **L3 - React components** *(highest)*. You get **UI and behavior together, working**. You provide the input and a config object, and you are done. `` is an entire purchase flow: wallet selection, amount entry, approval, review, confirmation. Others: ``, ``, ``. 2. **L2 - React hooks** *(mid)*. You get **the behavior**: data fetching, state, polling, error handling, and the on-chain sequencing. You provide the UI. `useOffers()` hands you a `LOADING / ERROR / CONTENT` state machine and you render whatever you like from it; `useOndoBuyCheckoutViewModel()` runs the whole Ondo flow behind your own markup. 3. **L1 - TypeScript clients** *(low)*. You get **the logic**: typed API calls, domain mapping, and total on-chain flows that return a step-tagged result instead of throwing. You build the state management and the UI. `CoinListClient` in the browser, `CoinListServer` on your backend, no React anywhere. Use it for a non-React app, a custom state layer, or full control. Below L1 there is no SDK. If you need something the client does not model, call the [HTTP API](/api-reference) directly. A feature may **stop** at a rung, but it never skips one. CoinList token sales are L1 and L2 only today: there is a namespace and a hook, but no component yet, so [that flow's UI is yours to render](/sdk/invest-flow). ### Environment: where code runs Environment comes first in the split because it is a security boundary, not a convenience: `CoinListServer` holds your `client_secret`, and it must never reach a browser bundle. That boundary is exactly the three import paths you type. | | `@coinlist-co/react`
browser | `@coinlist-co/react/server`
your backend | `@coinlist-co/react/universal`
both | | - | - | - | - | | **L3 Components** *(highest)* | ``
``
`` | - | - | | **L2 Hooks** *(mid)* | `useOffers()`
`useRequirements()`
`useOndoBuyCheckoutViewModel()` | - | - | | **L1 Client** *(low)* | `CoinListClient`
`coinlist.ondo.executeSwap()` | `CoinListServer`
`coinlist.offers.list()` | namespace and params types | Not every feature is available at every level. L2 and L3 exist only in the browser, because a backend renders nothing. **L1 is the only level present everywhere**, which is why `coinlist.offers.list()` reads the same on your server as in your React tree. ### Feature Inside each level, the code is grouped by feature, and the same four words appear at every rung: **auth**, **offers**, **requirements**, **checkout**. So `coinlist.requirements` (L1), `useRequirements()` (L2) and `` (L3) are three abstraction levels for the same feature. Checkout is grouped one level deeper, by **provider** and **product** - Superstate swap, Ondo buy, CoinList token sale - because every provider's flow differs in roughly a fifth of its substance. `` is what saves you from caring: it routes on `offer.type` so you integrate once. **Recommendation:** start with components, drop to hooks when you need custom UI, and reach for the clients when you are outside React or need full control. The [HTTP API](/api-reference) remains the contract underneath, for bespoke stacks or endpoints the SDK does not wrap yet. ## Next step The patterns every part of the SDK follows: namespaces, containers, hook state, and flows that return instead of throwing. Skippable if you would rather start building. After OAuth, follow **[Fetch offers](/sdk/fetch-offers)** to render `OffersGridContainer`, then **[Display offer details](/sdk/sale-details)** for a single offer. Then **[Requirements](/sdk/requirements)** for what a user must satisfy before participating, **[Wallets](/sdk/wallets)** to plug in your wallet stack, and **[Building the Checkout flow](/sdk/checkout)** to render the purchase itself. Use the **[API reference](/api-reference)** when you need raw request and response details. # Requirements checklist Source: https://docs.passage.coinlist.co/sdk/requirements Render RequirementsChecklistContainer and the SDK handles identity verification, tax documents, and wallet connection for you. Before a user can participate in an offer, they have to satisfy its **requirements**: identity verification, a signed tax document, a connected wallet, a jurisdiction check. Which ones apply is decided per offer option, by CoinList. `RequirementsChecklistContainer` renders the whole checklist and resolves every requirement type in-app. It is the highest rung (**L3, components**) and the one to start from. ## Prerequisites * A completed [OAuth](/sdk/oauth-authentication) session with a working `CoinListProvider` * An offer id and an offer option id from [Display offer details](/sdk/sale-details) ## Render it ```tsx theme={null} "use client"; import { RequirementsChecklistContainer } from "@coinlist-co/react"; export function Requirements({ offer, option }) { return ( router.push(`/offers/${offer.id}/checkout`)} /> ); } ``` That is the whole integration. The container is **self-scoped**: it renders fully styled with no style provider in the tree. When the user is not authenticated it renders its error state and fetches nothing, so mount it only for a logged-in user and render `CoinListSignInCardContainer` yourself otherwise. ## What it does for you Clicking a requirement's action button does the right thing per type, with no wiring from you: | Requirement type | Default behavior | | - | - | | `kyc_approved`, `accreditation` | Opens an inline Sumsub verification flow. The backend prescribes both the level and whether the applicant must be reset first. | | `document` | Opens the built-in tax document modal. | | `external_wallet`, `whitelisted_wallet` | Opens the connect-wallet modal, if you passed a `wallet`. Otherwise opens CoinList in a new tab. | | `jurisdiction` | No action - there is no CoinList page to send the user to. | The fallback for everything is `coinlist.requirements.handle`, which opens the corresponding CoinList page in a new tab where the type has one. **Omit `onRequirementActionOverride` and rely on the defaults.** Pass your own handler only if you need custom navigation, or `null` to disable the action button entirely. The same applies to `onContactSupportOverride`, which defaults to `coinlist.support.contact`. ## Resolving wallet requirements in-app Pass a `wallet` and the checklist resolves `external_wallet` and `whitelisted_wallet` requirements without leaving your app. It only needs signing capability, so an [`EvmSigner`](/sdk/wallets#two-shapes-one-seam) is enough - no gas, no transaction. ```tsx theme={null} ``` When `wallet` is omitted entirely - or is `null` with no `onRequestConnect` to act on - wallet requirements fall back to opening CoinList in a new tab. ### Props | Prop | Type | Description | | - | - | - | | `offerId` | `OfferId` | Required. | | `optionId` | `OfferOptionId` | Required. Requirements are per option. | | `title`, `description` | `string` | Required. Heading copy. | | `onContinue` | `() => void` | Called when every requirement is satisfied. | | `wallet` | `ConnectWallet \| null` | A connected signer, for in-app wallet requirements. | | `onRequestConnect` | `() => void` | Called when the user asks to connect and none is connected. | | `onRequirementActionOverride` | `((r: Requirement) => void) \| null` | Override the action button for all types, or `null` to disable it. | | `onContactSupportOverride` | `((r: Requirement) => void) \| null` | Override the contact-support button, or `null` to disable it. | | `getLabel`, `getDescription` | `(r: Requirement) => string` | Override the default copy per requirement type. | | `identityVerificationOptions` | `{ levelName?, locale? }` | Override the backend-prescribed Sumsub level, or set the Sumsub UI language. | | `data` | `RequirementsData` | Pre-fetched server-side. Skips the initial client fetch. | | `loading`, `error` | `ReactNode` | Slots. | | `enabled` | `boolean` | Default `true`. `false` fetches nothing, for a checklist that is mounted but not needed yet. | | `className` | `string` | | `data` skips the requirements fetch, but connected-wallet addresses are not part of `RequirementsData` and are always fetched client-side. ## The individual containers The checklist mounts these for you. Render one directly only if you are building your own checklist: * **`IdentityVerificationContainer`** - the inline Sumsub flow. Takes `levelName`, `reset`, `locale`, `onSubmitted`. * **`TaxDocumentModalContainer`** - the tax document form. Takes `isOpen`, `onClose`, `onSubmitted`. * **`ConnectWalletModalContainer`** - wallet ownership proof and binding. Takes `isOpen`, `onClose`, `offerId`, `optionId`, `wallet`, `onRequestConnect`, `onConnected`. `useRequirementsChecklistViewModel` backs the container and returns `{ state, onEvent }`. Below it are the data hooks it composes, each usable on its own: ```tsx theme={null} import { useRequirements } from "@coinlist-co/react"; const { requirementsState, refetch } = useRequirements(offerId); switch (requirementsState.type) { case "LOADING": return ; // reason: "not-authenticated" | "generic-error" case "ERROR": return ; case "CONTENT": return ; } ``` | Hook | What it does | Options | | - | - | - | | `useRequirements(offerId, options)` | Fetches requirements and their statuses together. | `data`, `enabled` | | `useOptionAddresses` | Lists the wallets bound to an offer option. | `data`, `enabled` | | `useKycToken` | Mints a Sumsub token for the prescribed level. | | | `useTaxDocument` | Loads and submits a tax document. | | | `useConnectWallet` | Runs the ownership-proof and binding flow. | | See [SDK structure](/sdk/structure#hooks) for the conventions they share. `coinlist.requirements` is a plain namespace with no React. It is available on both the browser client and `CoinListServer`, except `handle`, which needs a browser. | Method | Returns | | - | - | | `forOffer(offerId)` | `Record` - which requirements apply to each option. | | `statuses(offerId)` | `RequirementStatusInfo[]` - the user's progress, with the Sumsub level that resolves each one. | | `createKycToken(params?)` | A Sumsub token. Takes `{ levelName, reset }`. | | `getPii()` | The user's personal information on file. | | `submitDocument(params)` | Submits a document. Takes `{ documentType, fields }`. | | `handle(requirement)` | Client only. Opens the corresponding CoinList page in a new tab. | ```ts theme={null} const [requirements, statuses] = await Promise.all([ coinlist.requirements.forOffer(offerId), coinlist.requirements.statuses(offerId), ]); ``` A requirement `type` is one of `kyc_approved`, `external_wallet`, `whitelisted_wallet`, `jurisdiction`, `accreditation`, `document` - the six the [API reference](/api-reference) declares. A status is `not_started`, `in_progress`, `action_needed`, `completed`, or `rejected`. Every method requires a logged-in user and throws `NotAuthenticatedError` otherwise. Wallet ownership proof lives on `coinlist.wallets` rather than here, because it is provider-agnostic. See [Wallets](/sdk/wallets#proving-wallet-ownership). ## Next step Once requirements are satisfied, render `CheckoutContainer` and let it pick the provider. # Display offer details Source: https://docs.passage.coinlist.co/sdk/sale-details Load full offer details with the React SDK or the API. Fetch full offer details - pricing options, legal terms, milestones, the tokens the offer settles in, and more - for a single offer the user can access. `OfferDetail.type` tells you which checkout the offer needs: `'coinlist::token_sale'`, `'superstate::swap'`, or `'ondo::swap'`. Switch on it exhaustively, or hand the offer to [`CheckoutContainer`](/sdk/checkout) and let the SDK dispatch. The values changed in v0.11.0 - see the [changelog](/changelogs/react). ## Prerequisites * Completed [OAuth](/sdk/oauth-authentication) and a working `CoinListProvider` * An **offer id** from [Fetch offers](/sdk/fetch-offers) (for example `offer.id` from `OffersGridContainer` `onOfferClick`) ## Recommended: useOfferDetails The hook mirrors the SDK client method `coinlist.offers.get`: it waits until the provider is ready, then loads details and exposes `offerDetailsState` (`LOADING`, `ERROR`, or `CONTENT`). When the user picks an offer from the grid, keep that `offer` in React state and pass `offer.id` - TypeScript then matches the branded id type the hook expects. ```tsx theme={null} "use client"; import type { ComponentProps } from "react"; import { useState } from "react"; import { OffersGridContainer, useOfferDetails } from "@coinlist-co/react"; type OfferFromGrid = Parameters< NonNullable["onOfferClick"]> >[0]; function OfferDetailPanel({ offer }: { offer: OfferFromGrid }) { const { offerDetailsState } = useOfferDetails(offer.id); if (offerDetailsState.type === "LOADING") return

Loading details…

; if (offerDetailsState.type === "ERROR") return

Unable to load this offer.

; const detail = offerDetailsState.offerDetail; return (

{detail.name}

{/* Render options, terms, milestones from detail — see API reference for fields */}
); } export function OfferBrowseAndDetail() { const [selected, setSelected] = useState(null); return ( <> setSelected(offer)} /> {selected ? : null} ); } ``` ## REST: GET /v1/offers/ Use the API directly when you are not in React or you need a one-off server fetch with a token you already hold. ```ts theme={null} // lib/coinlist-api.ts export async function getOfferDetails(offerId: string, accessToken: string) { const res = await fetch( `https://api.coinlist.co/v1/offers/${encodeURIComponent(offerId)}`, { headers: { Authorization: `Bearer ${accessToken}` }, }, ); if (!res.ok) { const error = await res.json(); throw new Error(error.message ?? "Failed to fetch offer"); } return res.json(); } ``` See the [API reference](/api-reference) for the exact response schema. Example response shape (values are illustrative - always rely on the live API and legal URLs from Passage): ```json theme={null} { "object": "offer_details", "id": "{offer_id}", "slug": "example-offer", "name": "Example Token Sale", "starts_at": "2026-01-01T00:00:00.000000Z", "ends_at": "2026-01-31T00:00:00.000000Z", "options": [ { "id": "{option_id}", "slug": "standard", "price_usd": 0.1, "floor_price_usd": 0.08, "minimum_purchase_usd": 100, "total_token_supply": 1000000, "sale_agreement_url": "https://coinlist.co/agreements/example" } ], "terms": [ { "key": "Vesting", "value": "12 months linear" }, { "key": "Lockup", "value": "6 months cliff" } ], "milestones": [ { "name": "Registration", "schedule": "Jan 1", "status": "completed" }, { "name": "Sale Open", "schedule": "Jan 15", "status": "active" }, { "name": "Distribution", "schedule": "Feb 1", "status": "upcoming" } ], "faqs": [], "links": [] } ``` Display the full terms and link to the sale agreement URL as provided — do not summarize or paraphrase legal terms. ## Lower-level client Equivalent to the hook, without the built-in state machine. Pass the same `offer.id` you get from `OffersGridContainer`: ```tsx theme={null} "use client"; import type { ComponentProps } from "react"; import { OffersGridContainer, useCoinList } from "@coinlist-co/react"; import { useEffect, useState } from "react"; type OfferFromGrid = Parameters< NonNullable["onOfferClick"]> >[0]; export function OfferDetailManual({ offer }: { offer: OfferFromGrid }) { const { coinlist, isReady } = useCoinList(); const [detail, setDetail] = useState(null); useEffect(() => { if (!isReady) return; void coinlist.offers .get(offer.id) .then(setDetail) .catch(() => setDetail(null)); }, [coinlist, isReady, offer.id]); if (!isReady) return

Loading…

; return
{JSON.stringify(detail, null, 2)}
; } ``` Prefer `useOfferDetails` unless you need custom control flow.
## Next step Show the user what they must satisfy before they can participate. # SDK structure Source: https://docs.passage.coinlist.co/sdk/structure The handful of patterns every part of the SDK follows: the ladder, namespaces, containers, hook state, and flows that return instead of throwing. **In a hurry?** Skip straight to [Set up OAuth authentication](/sdk/oauth-authentication) and come back when something surprises you. Nothing here is a prerequisite - this page is optional background. The SDK follows a small set of consistent patterns. The five patterns below apply across the SDK. ## The same feature at three levels Every feature is available at several levels. Here is one feature - listing offers - at all three: ```tsx theme={null} // L3 Components. UI and behavior, working. router.push(`/offers/${offer.id}`)} /> // L2 Hooks. The behavior; you write the UI. const { offersState } = useOffers(); // L1 Client. The logic; you write the state and the UI. const offers = await coinlist.offers.list(); // Below L1 there is no SDK - call the HTTP API directly. ``` All three do the same work. They differ only in how much of it you take over. **Start at L3** and drop a rung when the one above cannot give you what you need. Two things follow from this that are easy to miss: * **You pick a rung per feature, not per application.** Rendering `` while driving `coinlist.offers.list()` yourself is normal, not a compromise. The rungs are built from each other, so mixing them cannot desynchronize anything. * **A feature may stop at a rung, but never skips one.** CoinList token sales have a namespace and a hook and no component yet. This indicates that the flow does not yet have a drop-in UI component, so [its UI is yours to render](/sdk/invest-flow). L2 and L3 are browser-only, because a backend renders nothing. **L1 is the only level present everywhere**, which is why `coinlist.offers.list()` reads identically on your server and in your React tree. See [the grid in the quickstart](/sdk/quickstart#environment-where-code-runs). ## Namespaces **The entire L1 surface is namespaces.** `coinlist.init()` is the only top-level member on the browser client, because it is lifecycle rather than domain; `CoinListServer` has none at all. So every operation you will ever want is `coinlist.{something}.{verb}()`, and finding it means picking the right noun. Two naming rules tell you which noun: * **CoinList's own capabilities are named after what they do**: `offers`, `requirements`, `wallets`, `auth`, `erc20`, `tokens`, `support`. * **A third party's namespace is named after the provider**: `superstate`, `ondo`. Not after what it does. A second provider ships its own swap, so `coinlist.swap` could only ever have meant one of them - which is exactly why it was renamed in v0.11.0. Methods share one vocabulary, with the namespace's own noun dropped, so `offers.list()` rather than `offers.listOffers()`: | Method | Meaning | | - | - | | `list()` | Every item, paginating for you | | `listPage(params)` | One page, when you paginate yourself | | `get(id)` | One item by id | | `create*` / `submit*` / `remove*` | Writes | | A real domain verb | Where one exists: `connectExternal`, `allowWallet`, `execute` | **Any method taking more than one argument takes a single params object.** Single-argument methods take the value directly. You never have to remember an argument order. **API-backed methods throw `NotAuthenticatedError` when there is no session.** The one exception is `coinlist.tokens`, which is public: it reads a CDN, needs no login, and works with a read-only server session store. On-chain reads (`coinlist.erc20` and the `coinlist.superstate` reads) are not API-backed: they go through CoinList's JSON-RPC proxy by default, which fails a logged-out read with a viem-wrapped `401`, or through your own node via `Config.rpc`, which needs no session. The full table of namespaces and their methods, for looking one up. ## Components Every component you render is a **container**: `CheckoutContainer`, `OffersGridContainer`, `RequirementsChecklistContainer`. You hand it input and config, and it owns everything else. Three habits will keep you out of trouble: * **Prefer the container that decides for you.** `` routes on `offer.type` so a new provider is a version bump rather than your problem. Reaching past it for `` means you have taken on that routing. * **Optional `onX` callbacks are overrides, not wiring.** They default to something sensible - `RequirementsChecklistContainer`'s action button already calls `coinlist.requirements.handle`. Omit them until a default is wrong for you. * **You never need a style provider.** Every exported component wraps its own output in a style scope, so it renders fully styled wherever you put it. ## Hooks **State is a machine, not a pair of booleans.** Loading, error and content are one discriminated union with machine-readable `reason` codes: ```ts theme={null} type LoadOffersState = | { type: "LOADING" } | { type: "ERROR"; reason: "not-authenticated" | "generic-error" } | { type: "CONTENT"; offers: Offer[] }; ``` Switch on it exhaustively and assign the `default` branch to `const exhaustive: never = state`. A state added in a future release then becomes a compile error rather than a blank screen your users find first. The field is named after what it holds - `offersState`, `requirementsState` - except on viewmodels, which return `{ state, onEvent }`. Two options recur, and both exist because of how React works rather than as conveniences: * **`data` is how server rendering works.** Pass data your backend already fetched with `CoinListServer` and the hook uses it as-is, skipping the initial client fetch. Where a `refetch()` exists it still goes to the network: the seed answers the first render only. [Worked example](/sdk/fetch-offers#server-side-pre-fetch). * **`enabled` is how you turn a hook off.** React forbids calling a hook conditionally, so a hook you cannot skip must instead go quiet: `enabled: false` means no requests, no timers, and a benign state rather than a permanent `LOADING`. This is what lets `CheckoutContainer` call every provider's viewmodel on every render and enable only the matching one. Hooks that poll or drive a flow take it; the plain catalog reads take `data` only. ## Flows return, they don't throw Anything that drives a wallet - `coinlist.superstate.execute`, `coinlist.ondo.prepareBuy`, `coinlist.tokenSale.execute` - is a **flow**, and a flow is **total**. It does not throw. It returns a result tagged with the step that failed: ```ts theme={null} const result = await coinlist.tokenSale.execute({ /* ... */ }); if (result.type === "success") { // result carries what the flow produced } else { // result.error.step names exactly what went wrong, // and carries a typed WalletError cause where a wallet was involved } ``` That is the difference to internalize: **wrap namespace calls in try/catch, and branch on a flow's result.** Every step is documented per provider, so you can write real copy for a rejected signature instead of one generic message. Flows also report progress through `onProgress`, a callback of named phases, so you can tell a user which wallet popup they are looking at. Let your wallet library's own errors propagate out of your `EvmWallet` implementation. The SDK catches and classifies them into a typed `WalletError`, so you never parse a wallet error string. See [Wallets](/sdk/wallets). ## Next step Add `CoinListProvider`, a sign-in surface, callback handling, and server routes. # Superstate Swap Source: https://docs.passage.coinlist.co/sdk/swap-flow Superstate swap offers: allowlisting a wallet, live on-chain quotes, and coinlist.superstate.execute. Superstate supplies **tokenized equities**. A Superstate offer arrives with `type: "superstate::swap"` and settles **directly onchain**: the user pays with a stablecoin and receives the tokenized equity, an ERC-20, in the same flow. **Recommended:** render [`CheckoutContainer`](/sdk/checkout). It detects `superstate::swap` and renders this entire flow - wallet step, amount, review, confirmation - for you. This page covers what is specific to Superstate, and the rungs below the component. The namespace was `coinlist.swap` before v0.11.0, and `executeSwap` is now `execute`. It is named after the provider now, because Ondo ships its own swap and `coinlist.swap` could only ever mean one of them. See the [changelog](/changelogs/react) for the migration table. ## What Superstate needs from you **Nothing required in `CheckoutConfig`.** The swap contract is looked up from the chain, and the funding assets and issuer are the provider's own constants. The key still has to be present, so that the day Superstate does need something the omission is a compile error rather than a runtime surprise: ```tsx theme={null} const checkoutConfig = defaultCheckoutConfig({ // "superstate::swap" is filled in for you by defaultCheckoutConfig. "ondo::swap": { symbol: (offer) => AssetSymbol(offer.asset.code) }, "coinlist::token_sale": { render: (offer) => }, }); ``` Three things distinguish Superstate from the other providers: * **The wallet must be allowlisted.** Superstate only settles to addresses the issuer has approved. The user signs an ownership challenge and the SDK allowlists the address on the swap contract, once per wallet per offer. The same allowlists are what make permissioned DeFi pools and RWA lending markets reachable. * **The fee is charged on top.** The wallet is debited `inputTokenAmount + fee`, so always show that sum as the total cost. (Ondo takes its fee off the deposit instead.) * **Quotes are read quotes, polled every 15 seconds.** Nothing is committed until the swap itself, and slippage protection guards the on-chain minimum output. There is no expiring committed quote to race, as there is with Ondo. ## Prerequisites * Completed [OAuth](/sdk/oauth-authentication) with a working `CoinListProvider` * A **swap-enabled offer**. CoinList provides the offer ID during onboarding - keep it in a constant (`SWAP_OFFER_ID`). Offers expose a `type` field, so you can detect Superstate swap offers from [fetch offers](/sdk/fetch-offers) data via `offer.type === "superstate::swap"` instead of hard-coding IDs. Ondo swap offers are `'ondo::swap'` and take a different flow. * A wallet integration. The partner demo uses [wagmi](https://wagmi.sh/) + [Reown AppKit](https://reown.com/appkit); any stack that can sign messages and send transactions works. See [Wallets](/sdk/wallets). ```bash theme={null} npm install @coinlist-co/react wagmi @wagmi/core viem @reown/appkit @reown/appkit-adapter-wagmi @tanstack/react-query ``` `useSuperstateSwapCheckoutViewModel` wraps the whole flow and returns `{ state, onEvent }`. Call it behind your own markup when you want the SDK's logic, state and error handling but not its UI. ```tsx theme={null} import { useSuperstateSwapCheckoutViewModel } from "@coinlist-co/react"; const { state, onEvent } = useSuperstateSwapCheckoutViewModel({ offer, chain: "ethereum_mainnet", wallets, }); ``` `state` is a discriminated union - `switch` on it exhaustively and assign the `default` to `const exhaustive: never = state`, so a new step is a compile error rather than a blank screen. The smaller hooks it is composed from are exported too, so you can assemble your own viewmodel instead: | Hook | What it does | | - | - | | `useSwapOutputToken` | Reads the tokenized equity's decimals and symbol from the contract. | | `useSwapQuote` | Polls a live quote every 15s. | | `useSuperstateAmountViewModel` | The amount step. | | `useSuperstateWalletViewModel` | The wallet and allowlist step. | | `useSuperstateReviewViewModel` | The review and confirm step. | | `useSuperstateSidebarViewModel` | The order summary sidebar. | | `useErc20TokenBalances` | Per-asset wallet balances, for a Max button. | All but `useSuperstateWalletViewModel` and `useSuperstateReviewViewModel` take `enabled` - those two gate on their step input. Like all React hooks they must be called unconditionally. See [SDK structure](/sdk/structure#hooks) for the conventions they share. `coinlist.superstate` is a plain namespace with no React. The walkthrough below builds the flow from scratch and mirrors [partner-demo's `useSwapViewModel.tsx`](https://github.com/coinlist/partner-demo/blob/main/src/features/swap/useSwapViewModel.tsx), trimmed to the minimum that still compiles and runs. ### Flow at a glance ```mermaid theme={null} flowchart LR connect[connect_wallet] authorize[authorizing] quote[quoting] swapping[swapping] success[success] error[error] connect -->|wallet connected| authorize authorize -->|sign challenge + allow-list tx| quote authorize -->|rejected / not authorized| error quote -->|user confirms amount| swapping swapping -->|approve + swap mined| success swapping -->|rejected / reverted| error error -->|retry| connect ``` 1. **connect\_wallet** - your wallet UI; the SDK only needs the connected account. 2. **authorizing** - `coinlist.superstate.authorizeWallet` proves the user owns the wallet (a message signature) and allow-lists it for the offer, broadcasting an allow-list transaction if the contract requires one. One-time per wallet per offer. 3. **quoting** - `useSwapOutputToken` + `useSwapQuote` show a live quote (input, fee, output), refreshed every 15s. 4. **swapping** - `coinlist.superstate.execute` runs the ERC-20 approval and then the swap, reporting progress phases. 5. **success / error** - show the confirmed output amount and an explorer link, or a readable error. The flow involves **two identities**: the **CoinList session** (from OAuth) identifies *who* is buying, and the **external wallet** is *where* the shares are delivered. `authorizeWallet` is what links them - which is why the user signs a message before ever swapping. ### Step 1: Pin the swap parameters Keep the offer, chain, contract, and input token in one place. `TOKEN_REGISTRY` knows the ERC-20 metadata and per-chain address for USDC and USDT. ```ts constants.ts theme={null} import { type EthereumChain, OfferId, SUPERSTATE_SWAP_CONTRACT_ADDRESS_SEPOLIA, TOKEN_REGISTRY, USDC_SYMBOL, } from "@coinlist-co/react/universal"; // Provided by CoinList during onboarding. export const SWAP_OFFER_ID = OfferId("YOUR_SWAP_OFFER_ID"); export const SWAP_CHAIN: EthereumChain = "ethereum_sepolia"; export const SWAP_CONTRACT_ADDRESS = SUPERSTATE_SWAP_CONTRACT_ADDRESS_SEPOLIA; // The stablecoin the user pays with (USDC, 6 decimals). export const INPUT_TOKEN = TOKEN_REGISTRY.erc20(USDC_SYMBOL); export const INPUT_TOKEN_ADDRESS = TOKEN_REGISTRY.contractAddress( USDC_SYMBOL, SWAP_CHAIN, ); // BCP-47 locale used for amount formatting. export const LOCALE = "en-US"; ``` This page runs on **Sepolia** throughout. The SDK ships `SUPERSTATE_SWAP_CONTRACT_ADDRESS_SEPOLIA`; CoinList provides the production mainnet address when swaps go live. To move to mainnet, set `SWAP_CHAIN` to `"ethereum_mainnet"` and swap the contract constant - `TOKEN_REGISTRY` already knows the mainnet USDC/USDT addresses. ### Step 2: Bring your wallet The swap flows never import wagmi. They drive **any** wallet through the `EvmWallet` interface, which you implement once for every flow in the SDK. The five methods, the wagmi + AppKit adapter, and how wallet errors are classified. ```ts theme={null} import { buildEvmWallet } from "./wagmiEvmWallet"; ``` ### Step 3: Allowlist the wallet Superstate only settles to wallets the issuer has allowlisted. `coinlist.superstate.authorizeWallet` does both halves in one call: it proves the user controls the wallet by having them sign a challenge, then registers the address against the offer, broadcasting an allowlist transaction if the contract requires one. ```tsx theme={null} const wallet = buildEvmWallet({ address: EvmWalletAddress(address), walletClient, config, }); const result = await coinlist.superstate.authorizeWallet({ wallet, offerId: SWAP_OFFER_ID, contractAddress: SWAP_CONTRACT_ADDRESS, chain: SWAP_CHAIN, onProgress: (phase) => setBusy({ kind: "authorize", phase }), }); if (result.type === "success") { setAuthorized(true); } else { setError(result.error.step); } ``` It is **idempotent**: if the wallet is already authorized it returns `success` immediately without prompting, so it is safe to call at the start of every session. Flow-level failures come back as `{ type: "error" }` rather than throwing. #### Parameters | Parameter | Type | Description | | - | - | - | | `wallet` | `EvmWallet` | An already-connected wallet that can sign messages and send transactions. | | `offerId` | `OfferId` | The offer to allowlist the wallet against. | | `contractAddress` | `EvmContractAddress` | The swap contract for the offer. | | `chain` | `EthereumChain` | The EVM chain to authorize on, for example `ethereum_mainnet`. | | `onProgress` | `(phase: WalletAuthorizationPhase) => void` | Optional. Called as the flow advances, for rendering loading states. | #### Progress phases Emitted in order. Two of them open a wallet popup: `checking-authorization` → `requesting-challenge` → **`signing-message`** *(wallet popup)* → `submitting-signature` → **`broadcasting-transaction`** *(wallet popup, only when the contract needs an allowlist transaction)* → `awaiting-confirmation` → `verifying-authorization` #### Error steps `WalletAuthorizationError` is tagged by the step that failed, so you can tell a user rejection apart from an infrastructure problem. | `step` | What happened | | - | - | | `authorization-check` | The initial check for existing authorization failed. | | `challenge-request` | Requesting the ownership challenge failed. | | `signing` | The user did not sign the challenge. Carries a `cause` of type `WalletError`. | | `allow-wallet` | Submitting the signature to allowlist the wallet failed. | | `broadcast` | Broadcasting the allowlist transaction failed. Carries a `cause` of type `WalletError`. | | `not-authorized` | The flow completed but the on-chain re-check still reports the wallet as unauthorized. | `authorizeWallet` wraps three Frontline endpoints, listed here for non-SDK integrations. The middle one is also `coinlist.wallets.createOwnershipChallenge`, which the [requirements checklist](/sdk/requirements) uses to bind a wallet to an offer option. | Endpoint | Purpose | | - | - | | `GET /v1/wallet/authorized` | Reads `authorized(address)` on the swap contract. | | `POST /v1/wallet-ownership` | Requests a challenge for the user to sign. | | `POST /v1/offers/{offer_id}/allow-wallet` | Submits the signature and allowlists the address. | ### Step 4: Show a live quote Read the output token to learn its decimals, parse the user's input, then poll a quote. `useSwapQuote` skips quoting until it has both the output-token decimals and a valid amount. ```tsx theme={null} const { outputTokenState } = useSwapOutputToken({ contractAddress: SWAP_CONTRACT_ADDRESS, chain: SWAP_CHAIN, enabled: true, }); const outputToken = outputTokenState.type === "CONTENT" ? outputTokenState.outputToken : null; const parsed = parseBlockchainAmount(amountInput, INPUT_TOKEN.decimals); const hasAmount = parsed.valid && parsed.amount.raw > 0n; const inputAmount = parsed.valid && parsed.amount.raw > 0n ? parsed.amount : BlockchainAmount({ raw: 0n, decimals: INPUT_TOKEN.decimals }); const { quote, isRefreshing } = useSwapQuote({ contractAddress: SWAP_CONTRACT_ADDRESS, chain: SWAP_CHAIN, inputAmount, inputTokenAddress: INPUT_TOKEN_ADDRESS, outputTokenDecimals: outputToken?.decimals ?? null, enabled: authorized && hasAmount, }); ``` A `SwapQuote` has `inputTokenAmount`, `fee`, and `outputTokenAmount`, all `BlockchainAmount`s. Render them with `formatAmount(amount, LOCALE)`, and derive the total and the slippage-protected minimum: ```tsx theme={null} const total = BlockchainAmount.add(quote.inputTokenAmount, quote.fee); const minReceived = computeSlip(quote.outputTokenAmount, DEFAULT_SLIPPAGE_BPS); ``` The **fee is charged on top** of the amount that buys shares. The wallet is debited `inputTokenAmount + fee`, so always show that sum as the total cost. The quote refreshes every 15s (`SWAP_POLL_INTERVAL_MS`). `isRefreshing` flags a background refresh; a failed poll keeps the last quote and retries on the next tick. ### Step 5: Execute the swap `coinlist.superstate.execute` runs the whole on-chain sequence and returns a discriminated result. ```tsx theme={null} const result = await coinlist.superstate.execute({ wallet, contractAddress: SWAP_CONTRACT_ADDRESS, chain: SWAP_CHAIN, inputTokenAddress: INPUT_TOKEN_ADDRESS, quote, slippageBps: DEFAULT_SLIPPAGE_BPS, onProgress: (phase) => setBusy({ kind: "swap", phase }), }); if (result.type === "success") { // result: swapTxHash, inputAmount, fee, outputAmount, outputAmountConfirmed, // pricePerShare, recipientAddress const amount = `${formatAmount(result.outputAmount, LOCALE)} ${outputToken.symbol}`; const received = result.outputAmountConfirmed ? amount : `${amount} (estimated)`; const explorerUrl = txExplorerUrl(SWAP_CHAIN, result.swapTxHash); } else { // result.error.step: "status-check" | "swap-paused" | "swap-stopped" | "allowance-check" // | "approval" | "approval-reverted" | "swap" | "swap-reverted" setError("The swap did not go through."); } ``` **Read `outputAmountConfirmed` before you show `outputAmount` to a user.** It is `true` when the amount is decoded from the on-chain `Swapped` event, and `false` when that event would not decode and the quote's estimate stands in. The swap mined either way, so the order is settled - but an estimate labeled as a confirmed fill is the one way this flow can misreport what happened. `pricePerShare` derives from the same number and carries the same caveat. Phases (wallet popups marked): `checking-status` → `checking-allowance` → *(`resetting-allowance` → `confirming-allowance-reset`, only for stale non-zero allowances)* → **`approving`** *(wallet popup)* → `confirming-approval` → **`swapping`** *(wallet popup)* → `confirming-swap` The user signs up to **two transactions** - an ERC-20 `approve` for `input + fee`, then the swap itself. USDT-style tokens with a stale non-zero allowance need one extra reset transaction first. All read-only checks run *before* the first popup, so the user never signs a transaction that would revert. `slippageBps` sets the on-chain minimum-output guard. `DEFAULT_SLIPPAGE_BPS` is 0.5% and is fine for a minimal integration; see [Going further](#going-further) for a picker. ### Putting it together A complete, minimal swap component. It compiles against `@coinlist-co/react@0.11.0` and reuses `constants.ts` (Step 1) and `buildEvmWallet` (Step 2). ```tsx MinimalSwap.tsx theme={null} "use client"; import { type SwapExecutionPhase, useCoinList, useSwapOutputToken, useSwapQuote, type WalletAuthorizationPhase, } from "@coinlist-co/react"; import { BlockchainAmount, computeSlip, DEFAULT_SLIPPAGE_BPS, EvmWalletAddress, formatAmount, parseBlockchainAmount, txExplorerUrl, } from "@coinlist-co/react/universal"; import { useAppKit, useAppKitAccount } from "@reown/appkit/react"; import { useEffect, useState } from "react"; import { useConfig, useWalletClient } from "wagmi"; import { INPUT_TOKEN, INPUT_TOKEN_ADDRESS, LOCALE, SWAP_CHAIN, SWAP_CONTRACT_ADDRESS, SWAP_OFFER_ID, } from "./constants"; import { buildEvmWallet } from "./wagmiEvmWallet"; type Busy = | { kind: "authorize"; phase: WalletAuthorizationPhase } | { kind: "swap"; phase: SwapExecutionPhase }; const GENERIC_ERROR = "Something went wrong. Please try again."; export function MinimalSwap() { const { coinlist, isReady } = useCoinList(); const { open } = useAppKit(); const { address, isConnected } = useAppKitAccount(); const { data: walletClient } = useWalletClient(); const config = useConfig(); const [authorized, setAuthorized] = useState(false); const [amountInput, setAmountInput] = useState(""); const [busy, setBusy] = useState(null); const [error, setError] = useState(null); const [confirmed, setConfirmed] = useState<{ received: string; explorerUrl: string; } | null>(null); // The output token (the tokenized equity) tells us the decimals needed to quote. const { outputTokenState } = useSwapOutputToken({ contractAddress: SWAP_CONTRACT_ADDRESS, chain: SWAP_CHAIN, enabled: true, }); const outputToken = outputTokenState.type === "CONTENT" ? outputTokenState.outputToken : null; // Quoting stays disabled until the wallet is authorized, the output token is // known, and the amount is a valid non-zero value. const parsed = parseBlockchainAmount(amountInput, INPUT_TOKEN.decimals); const hasAmount = parsed.valid && parsed.amount.raw > 0n; const inputAmount = parsed.valid && parsed.amount.raw > 0n ? parsed.amount : BlockchainAmount({ raw: 0n, decimals: INPUT_TOKEN.decimals }); const { quote, isRefreshing } = useSwapQuote({ contractAddress: SWAP_CONTRACT_ADDRESS, chain: SWAP_CHAIN, inputAmount, inputTokenAddress: INPUT_TOKEN_ADDRESS, outputTokenDecimals: outputToken?.decimals ?? null, enabled: authorized && hasAmount, }); // If the connected account changes, the prior authorization no longer applies. useEffect(() => { setAuthorized(false); }, [address]); const buildWallet = () => { if (!walletClient || !address) return null; return buildEvmWallet({ address: EvmWalletAddress(address), walletClient, config, }); }; const handleAuthorize = async () => { const wallet = buildWallet(); if (!isReady || !wallet) return; setError(null); setBusy({ kind: "authorize", phase: "checking-authorization" }); try { const result = await coinlist.superstate.authorizeWallet({ wallet, offerId: SWAP_OFFER_ID, contractAddress: SWAP_CONTRACT_ADDRESS, chain: SWAP_CHAIN, onProgress: (phase) => setBusy({ kind: "authorize", phase }), }); if (result.type === "success") setAuthorized(true); else setError(GENERIC_ERROR); } catch { setError(GENERIC_ERROR); } finally { setBusy(null); } }; const handleSwap = async () => { const wallet = buildWallet(); if (!isReady || !wallet || !quote) return; setError(null); setBusy({ kind: "swap", phase: "checking-status" }); try { const result = await coinlist.superstate.execute({ wallet, contractAddress: SWAP_CONTRACT_ADDRESS, chain: SWAP_CHAIN, inputTokenAddress: INPUT_TOKEN_ADDRESS, quote, slippageBps: DEFAULT_SLIPPAGE_BPS, onProgress: (phase) => setBusy({ kind: "swap", phase }), }); if (result.type === "success" && outputToken) { const amount = `${formatAmount(result.outputAmount, LOCALE)} ${outputToken.symbol}`; setConfirmed({ received: result.outputAmountConfirmed ? amount : `${amount} (estimated)`, explorerUrl: txExplorerUrl(SWAP_CHAIN, result.swapTxHash), }); } else { setError(GENERIC_ERROR); } } catch { setError(GENERIC_ERROR); } finally { setBusy(null); } }; if (outputTokenState.type === "ERROR") { return

Swaps are unavailable right now. Please try again later.

; } if (confirmed) { return (

Swap complete

You received {confirmed.received}.

View transaction
); } if (!isConnected || !address) { return ; } if (!authorized) { return (
{busy?.kind === "authorize" &&

{authorizePhaseLabel(busy.phase)}

} {error &&

{error}

}
); } const total = quote ? BlockchainAmount.add(quote.inputTokenAmount, quote.fee) : null; return (
{ if (e.target.value === "" || /^\d*\.?\d*$/.test(e.target.value)) { setAmountInput(e.target.value); } }} /> {quote && total && outputToken ? (
You pay
{formatAmount(quote.inputTokenAmount, LOCALE)} {INPUT_TOKEN.symbol}
CoinList fee
{formatAmount(quote.fee, LOCALE)} {INPUT_TOKEN.symbol}
Total (charged to your wallet)
{formatAmount(total, LOCALE)} {INPUT_TOKEN.symbol}
You receive
{formatAmount(quote.outputTokenAmount, LOCALE)} {outputToken.symbol}
Minimum received
{formatAmount( computeSlip(quote.outputTokenAmount, DEFAULT_SLIPPAGE_BPS), LOCALE, )}{" "} {outputToken.symbol}
) : hasAmount ? (

Fetching quote…

) : null} {isRefreshing && Refreshing quote…} {busy?.kind === "swap" &&

{swapPhaseLabel(busy.phase)}

} {error &&

{error}

}
); } function authorizePhaseLabel(phase: WalletAuthorizationPhase): string { switch (phase) { case "signing-message": return "Sign the message in your wallet…"; case "broadcasting-transaction": return "Confirm the transaction in your wallet…"; default: return "Authorizing…"; } } function swapPhaseLabel(phase: SwapExecutionPhase): string { switch (phase) { case "approving": return `Approve ${INPUT_TOKEN.symbol} in your wallet…`; case "swapping": return "Confirm the swap in your wallet…"; default: return "Working…"; } } ``` The production-grade version - with balances, a slippage picker, per-step error copy, and a review screen - lives in [`useSwapViewModel.tsx`](https://github.com/coinlist/partner-demo/blob/main/src/features/swap/useSwapViewModel.tsx) in the partner demo. ### Going further * **Balances and a Max button** - `useErc20TokenBalances({ address, chain, assets: [USDC_SYMBOL], enabled })` returns a `Map` of per-asset balances so you can show the user's USDC and prefill the max amount. It was `useSwapTokenBalances` before v0.11.0; the options and result are unchanged. * **A slippage picker** - `SLIPPAGE_OPTIONS_BPS` (0.25%-5%) with `formatBpsAsPercent(bps, LOCALE)` for labels; pass the selected `Bps` to `coinlist.superstate.execute`. The SDK also ships `SlippagePicker` if you want the packaged control. * **More formatting helpers** in `@coinlist-co/react/universal`: `shortenAddress`, `formattedPricePerShare`.
## Common questions The flow has two identities: the OAuth **CoinList session** is *who* is buying, and the **external wallet** is *where* shares are delivered. `authorizeWallet` links them - the user signs an ownership challenge, and the SDK allow-lists that address on the swap contract. It's one-time per wallet per offer, and returns success immediately on later calls if the wallet is already authorized. A swap needs an ERC-20 `approve` (for `input + fee`) before the swap itself, so the contract can pull the stablecoin. USDT-style tokens that already have a stale non-zero allowance need one extra reset-to-zero transaction first. Every read-only check (status, allowance) runs before the first popup, so the user never signs a transaction that would revert. `useSwapQuote` polls every 15s. On execution, `coinlist.superstate.execute` passes a slippage-derived minimum output to the contract, so if the price moves beyond your `slippageBps` the swap reverts rather than over-charging. The result's `outputAmount` is decoded from the on-chain `Swapped` event when the receipt yields one, and falls back to the quote's estimate when it does not - `outputAmountConfirmed` tells you which one you got. No. Anything implementing the five-method `EvmWallet` interface works - viem directly, Privy, ethers with a thin adapter. The SDK only calls `signMessage`, `writeContract`, `broadcastRawTx`, and `awaitTx`; it never sees your wallet library. This page uses Sepolia everywhere. CoinList provides the production swap contract address and swap offer ID during onboarding. Set `SWAP_CHAIN` to `"ethereum_mainnet"`, point `SWAP_CONTRACT_ADDRESS` at the mainnet contract, and the wallet adapter's `CHAINS` map handles the network switch. ## Next step The partner demo's swap feature adds balances, slippage selection, a review screen, and typed error handling on top of this flow. # Wallets: the EvmWallet seam Source: https://docs.passage.coinlist.co/sdk/wallets Implement EvmWallet once and every Passage on-chain flow works with your wallet stack. Prove ownership, allowlist addresses, and hand wallets to CheckoutContainer. The SDK ships **no wallet stack**. It is not coupled to Privy, Turnkey, AppKit, wagmi, or anything else, and it never asks a user to connect. Wallet discovery, connection, and disconnection are your app's job. That is deliberate. A partner may be a wallet provider themselves, and every partner already has a wallet story before they reach Passage. So the SDK inverts it: you implement one small interface, `EvmWallet`, and the SDK drives it. Do that once and every on-chain flow works - Superstate swaps, Ondo buys, token-sale approvals, ownership proofs. Using an embedded wallet provider? See [Wallet partners](/wallet-partners) for providers with a documented Passage integration. The SDK uses and ships [viem](https://viem.sh/) types, the de-facto TypeScript standard for Ethereum, so the interface lines up with whatever you are already using. A `SolanaWallet` seam will follow the same shape when Solana lands. ## Two shapes, one seam There are two interfaces, and the smaller one is a subset of the larger. | Interface | Members | Needed for | | - | - | - | | `EvmSigner` | `address`, `signMessage` | Proving wallet ownership. No gas, no transaction. | | `EvmWallet` | the two above, plus `writeContract`, `broadcastRawTx`, `awaitTx` | Everything on-chain: approvals, swaps, allowlist transactions. | `EvmWallet extends EvmSigner`, so implementing the full interface satisfies both. If all you need is the requirements checklist binding a wallet to an offer option, `EvmSigner` is enough and your users never see a gas prompt. ## Implement EvmWallet ```ts theme={null} import type { EvmWallet } from "@coinlist-co/react"; ``` | Member | Signature | What the SDK does with it | | - | - | - | | `address` | `EvmWalletAddress` | Reads balances and allowances, and names the sender. | | `signMessage` | `(message: string) => Promise` | Ownership challenges and allowlist authorization. | | `writeContract` | `(params: WriteContractParams) => Promise` | ERC-20 `approve`, and any contract call the SDK encodes itself. | | `broadcastRawTx` | `(params: BroadcastTxParams) => Promise` | Sends backend-encoded calldata **verbatim**. Never re-encode it: the contract verifies a signature over the exact arguments inside. | | `awaitTx` | `(hash: Hash, chain: EthereumChain) => Promise` | Waits for confirmation before advancing a flow. | Every method that takes a chain gets it as an explicit parameter. Switch the wallet to that chain before acting - the SDK does not assume your wallet is already pointed at the right one. ## Adapter: wagmi and AppKit This is the canonical adapter, and it is the one the [partner demo](https://github.com/coinlist/partner-demo) runs. Any stack that can sign a message and send a transaction works the same way. ```ts wagmiEvmWallet.ts theme={null} import type { EvmWallet } from "@coinlist-co/react"; import type { EthereumChain, EvmWalletAddress, } from "@coinlist-co/react/universal"; import { type Config, getChainId, switchChain, waitForTransactionReceipt, } from "@wagmi/core"; import { type Chain, encodeFunctionData, type WalletClient } from "viem"; import { base, baseSepolia, mainnet, sepolia } from "viem/chains"; // `Record` is total on purpose: the day the SDK models a // new chain, this table fails to compile until you decide what it maps to. const CHAINS: Record = { ethereum_mainnet: mainnet, ethereum_sepolia: sepolia, base_mainnet: base, base_sepolia: baseSepolia, }; export function buildEvmWallet({ address, walletClient, config, }: { address: EvmWalletAddress; walletClient: WalletClient; config: Config; }): EvmWallet { const account = address as `0x${string}`; // Each call passes the target chain; switch the wallet there first. const ensureChain = async (chain: EthereumChain): Promise => { const target = CHAINS[chain]; if (getChainId(config) !== target.id) { await switchChain(config, { chainId: target.id }); } return target; }; return { address, signMessage: (message) => walletClient.signMessage({ account, message }), async writeContract({ abi, address: to, functionName, args, value, chain }) { const targetChain = await ensureChain(chain); const data = encodeFunctionData({ abi, functionName, args }); return walletClient.sendTransaction({ account, chain: targetChain, to, data, value, }); }, async broadcastRawTx({ to, data, chain }) { const targetChain = await ensureChain(chain); return walletClient.sendTransaction({ account, chain: targetChain, to, data }); }, awaitTx: (hash, chain) => waitForTransactionReceipt(config, { hash, chainId: CHAINS[chain].id }), }; } ``` ```bash theme={null} npm install @coinlist-co/react wagmi @wagmi/core viem @reown/appkit @reown/appkit-adapter-wagmi @tanstack/react-query ``` ## Let wallet errors propagate **Do not catch and translate your wallet library's errors.** Let them throw. The SDK catches them and classifies them into a typed `WalletError`, so you never parse a wallet error string yourself: | `WalletError` | Cause | | - | - | | `user_rejected` | The user dismissed the wallet prompt. | | `insufficient_funds` | Not enough native token for gas, or not enough balance. | | `contract_reverted` | The transaction was mined and reverted. | | `timeout` | Confirmation did not arrive in time. | | `unknown` | Anything the SDK could not classify. | Flows surface it as the `cause` on the step that failed, so you can tell a user rejection apart from an infrastructure problem. See [Errors and edge cases](/sdk/errors) for the full breakdown. ## Proving wallet ownership Two identities are involved in every Passage purchase, and they are not the same thing: * The **CoinList session** (from [OAuth](/sdk/oauth-authentication)) identifies *who* is investing. * The **wallet** is *where* the assets are delivered. An ownership proof is what links them, which is why the user signs a message before they can transact. Some offers go further: the issuer only settles to wallets they have allowlisted. Superstate assets work this way, and the same allowlists are what make permissioned DeFi pools and RWA lending markets reachable. Ownership proof is provider-agnostic, so it lives on the platform namespace rather than being duplicated per provider: ```ts theme={null} const challenge = await coinlist.wallets.createOwnershipChallenge({ challengeType: "siwe", // or "plain" walletAddress: wallet.address, chain: "ethereum_mainnet", domain: window.location.host, uri: window.location.origin, statement: "Sign to prove you own this wallet.", }); const signature = await wallet.signMessage(challenge.message); const binding = await coinlist.wallets.connectExternal({ offerId, offerOptionId, walletAddress: wallet.address, chain: "ethereum_mainnet", signature, }); ``` `coinlist.wallets` also reads and removes those bindings: `list({ offerId, offerOptionId })` returns the wallets bound to an option, and `remove({ offerId, addressId })` unbinds one. You rarely need to write the sequence above. [`RequirementsChecklistContainer`](/sdk/requirements) runs it for you when an offer has an `external_wallet` or `whitelisted_wallet` requirement, and the [Superstate Swap](/sdk/swap-flow) allowlist flow wraps it in one idempotent call. ## CheckoutWalletSelection: what checkout takes `EvmWallet` is what the SDK *drives*. `CheckoutWalletSelection` is what you *hand* [`CheckoutContainer`](/sdk/checkout): the wallets the buyer may spend from, plus the two lambdas that connect and disconnect an external one. ```ts theme={null} import type { CheckoutWalletSelection } from "@coinlist-co/react"; const wallets: CheckoutWalletSelection = { // The user's embedded (custodial) wallets, in display order. // Empty is a normal state, not an error. embedded: embeddedWallets, // The connected external wallet, or null while none is connected. external: externalWallet, // A wallet you have already settled on, or null to let the user pick. preselected: null, // Opens your wallet connector. The SDK reads the outcome from `external` // on the next render, not from a return value, so a redirect-based // connector works. Reject to report a declined or failed connection. connectExternal: () => openConnectModal(), disconnectExternal: () => disconnect(), }; ``` ### Skipping the wallet step Set `preselected` to a ready `EvmWallet` and a checkout that supports it opens on the amount, renumbering the cards that remain. That is for the page that already knows the answer: a sell page reached from a position knows which wallet holds it, and asking again would be a question with one possible answer. The wallet does not have to appear in `embedded` or `external` - you supply a ready `EvmWallet` either way, and the SDK never resolves it back to a row. **Only the Ondo sell checkout honors `preselected` today.** The field lives on this seam rather than in one provider's config because nothing about it is Ondo's, but a provider that has not adopted it ignores it rather than half-implementing it. Do not assume a skip from the field being set. It is read **once, at mount**. Changing it mid-flow is ignored: the approval that follows the amount step is granted by one specific wallet, and moving the flow onto another under it would strand that approval. There is deliberately no separate wallet model here: `EvmWallet` already carries the `address`, so an `{ address, signer }` pair would be a second wallet type to keep in sync with the first. Everything a wallet row displays beyond the address - the network name - comes from the checkout's execution chain rather than from the wallet, because that is the chain the transaction goes to whatever the wallet is currently pointed at. ## Next step Hand your wallets to `CheckoutContainer` and let it render whichever provider the offer belongs to. # Package overview Source: https://docs.passage.coinlist.co/sdks Install @coinlist-co/react - React UI, hooks, client, and server utilities for Passage. The **`@coinlist-co/react`** package is the recommended way to integrate Passage in React and Next.js apps with a backend. It gives you OAuth with PKCE on the client, token exchange and sessions on the server, and ready-made UI for common flows. For a linear walkthrough, start with [SDK quickstart](/sdk/quickstart). ## Requirements * **React** 18+ and **React DOM** 18+, and **viem** ^2 (peer dependencies) * A **backend** you control to store `client_secret`, exchange authorization codes, and keep refresh tokens out of the browser ## Install ```bash theme={null} npm install @coinlist-co/react ``` ```bash theme={null} pnpm add @coinlist-co/react ``` ```bash theme={null} yarn add @coinlist-co/react ``` [NPM](https://www.npmjs.com/package/@coinlist-co/react) ## Entry points The package exposes three canonical entry points: | Path | Use for | | - | - | | `@coinlist-co/react` | React client - components (`CheckoutContainer`, `CoinListSignInCardContainer`, `OffersGridContainer`, `OffersAssetGrid`, `OfferSaleCard`, `OfferAssetCard`, `RequirementsChecklistContainer`, plus wallet-connect, identity, and tax-document UI), hooks (`useCoinList`, `useOffers`, `useOfferDetails`, `useParticipations`, `useRequirements`, `useCompleteOAuth`, `useTokenMetadata`, and the checkout viewmodels), `pinoClientLogger`, and `CoinListClient` / `createCoinListClient` for non-React or advanced wiring | | `@coinlist-co/react/server` | Node / SSR - `CoinListServer`, `createCoinListServer`, `pinoServerLogger`, session helpers for Next.js route handlers and Server Components | | `@coinlist-co/react/universal` | Isomorphic domain types - `Offer`, `OfferDetail`, `Participation`, `Requirement`, `TokenMetadata`, `ClientId`, `RedirectUri`, the `EvmWallet` port, the `Logger` port, pagination helpers, error classes (`HttpError`, `NotAuthenticatedError`, `ValidationError`), and swap/KYC/PII types | Most apps import everything they need from `@coinlist-co/react`. Reach for `/server` only inside server-side code, and `/universal` when you want a domain type without pulling the client bundle. Sub-path imports like `@coinlist-co/react/client/components` and `/client/hooks` were removed in v0.5.0, and `/shared` was renamed to `/universal` in v0.12.0 - see the [React SDK changelog](/changelogs/react). ## Namespaces The whole public surface is namespaces. On the client, `init()` is the only top-level member, because it is lifecycle rather than domain; `CoinListServer` has none at all. CoinList's own namespaces are named after what they do. A third-party provider's namespace is named after the **provider**, because a second provider ships its own swap and `coinlist.swap` could only ever mean one of them. | Namespace | Contains | Client | Server | | - | - | - | - | | `coinlist.auth` | Auth state and the OAuth flow. Browser: `getState`, `startOAuth`, `completeOAuth`, `logout`. Backend: `completeOAuth`, `getAccessToken`, `clientCredentials`, `logout` | ✓ | ✓ | | `coinlist.offers` | The offer catalog: `list`, `listPage`, `get` | ✓ | ✓ | | `coinlist.requirements` | What a user must satisfy before participating: `forOffer`, `statuses`, `createKycToken`, `getPii`, `submitDocument`, plus client-only `handle` | ✓ | ✓ | | `coinlist.wallets` | External wallets: `createOwnershipChallenge`, `connectExternal`, `list`, `remove` | ✓ | ✓ | | `coinlist.erc20` | Generic ERC-20 reads shared by every on-chain flow: `getAllowance`, `getBalance` | ✓ | ✓ | | `coinlist.tokenSale` | CoinList token sales: `list`, `listPage`, `get`, `createParticipation`, plus client-only `execute` | ✓ | ✓ | | `coinlist.superstate` | Superstate swaps: `getPreview`, `getAuthorization`, `getStatus`, `getOutputToken`, `allowWallet`, plus client-only `execute` and `authorizeWallet` | ✓ | ✓ | | `coinlist.ondo` | Ondo swaps, both directions: `getTradingStatus`, `getQuote`, `buildBuyTransaction`, `buildSellTransaction`, plus client-only `prepareBuy`, `prepareSell` and `executeSwap` | ✓ | reads only | | `coinlist.tokens` | Token display metadata from CoinList's public registry: `get`, `list`. Unauthenticated | ✓ | ✓ | | `coinlist.support` | `contact()` | ✓ | | Methods follow one vocabulary, with the namespace's own noun dropped: `list()` (every item, auto-paginating), `listPage(params)`, `get(id)`, and `create*` / `submit*` / `remove*` for writes, plus a real domain verb where one exists (`connectExternal`, `allowWallet`, `execute`). Any method taking more than one argument takes a single params object. [SDK structure](/sdk/structure#namespaces) explains why the namespaces are named as they are, and the patterns the hooks and components above follow. Unless a namespace says otherwise, its API-backed methods require a logged-in user and throw `NotAuthenticatedError`. `coinlist.tokens` is the exception: it is public, so it works with no session and with a read-only server session store. On-chain reads - `coinlist.erc20` and the `coinlist.superstate` reads - call the contract directly. By default they go through CoinList's authenticated JSON-RPC proxy, so a logged-out read fails with the proxy's `401` as a viem error rather than `NotAuthenticatedError`. A chain you point at your own node with `Config.rpc` needs no CoinList session. v0.11.0 moved the last flat methods into namespaces and renamed `coinlist.swap` to `coinlist.superstate`. v0.12.0 forked `coinlist.ondo`'s order builders by direction. See the [changelog](/changelogs/react) for the full migration tables. ## Next steps The ladder, namespaces, hook state, and flows that return instead of throwing. End-to-end sign-in with backend session. One container that renders whichever provider an offer belongs to. Every error shape the flows return, and how to handle them. # Support Source: https://docs.passage.coinlist.co/support Get help with Passage. ## Get help Open a support ticket with the CoinList support team. Questions about integrating Passage, or about your setup and licensing. ## Platform status Check real-time platform and service status at [status.coinlist.co](https://status.coinlist.co/). ## See the integration running Clone the Partner Demo repo to see the SDK integration end to end. Open the hosted Partner Demo site to see it running. # Lending pools and vaults Source: https://docs.passage.coinlist.co/use-cases/lending-pools-vaults Let your users borrow stablecoins against their RWA holdings or deposit into a vault to access enhanced yield. DeFi lending platforms run dedicated lending pools and vaults for RWA assets. Allowlisted assets require your users to be registered first, and Passage handles that registration as part of the flow. ## Build it Prove wallet ownership and register it against an offer with `authorizeWallet`. # Permissioned DeFi pools Source: https://docs.passage.coinlist.co/use-cases/permissioned-defi-pools Register your users' wallets onto the allowlists for permissioned assets that unlock DeFi pools for tokenized assets, to support trading in Uniswap and other onchain AMMs. Tokenization platforms including Superstate and Securitize are launching permissioned pools with Uniswap where only allowlisted wallets can interact. You can register your user wallets via Passage to their allowlists. Passage handles the entire onboarding flow end to end, with no management required from you. This allows eligible users to trade supported assets through permissioned DeFi pools. ## Platforms and assets supported | Platform | Status | | - | - | | Superstate | Available | | Securitize | Coming soon | | WisdomTree | Coming soon | Learn more in [Rails Without Riders: How Passage Qualifies Users for Uniswap's Permissioned Pools](https://blog.coinlist.co/rails-without-riders-how-passage-qualifies-users-for-uniswaps-permissioned-pools/). ## Build it Prove wallet ownership and register it against an offer with `authorizeWallet`. # Run your raise Source: https://docs.passage.coinlist.co/use-cases/run-your-raise Raise on CoinList, host the sale on your own platform, or do both, with Passage handling compliance and settlement either way. **Who it's for:** projects and platforms raising capital for their own token, rather than surfacing someone else's sale. This is the issuer side of a token sale. If you want to offer your users access to sales that CoinList already runs, see [Token sales](/use-cases/token-sales) instead. ## Two ways to run it These distribution models are not mutually exclusive. The same offering can be distributed both ways at once, and the compliance, payment, and distribution rails underneath are the same. | | Raise on CoinList | Host it yourself | | - | - | - | | **Where the sale lives** | The CoinList platform | Your own app, built on the Passage SDK | | **Who owns the front end** | CoinList | You | | **Who brings the participants** | CoinList's audience, plus your community | Your users, plus any distribution partners you add | | **What you control** | Sale terms, token economics, marketing | All of the above, plus the entire user experience | | **Best when** | You want reach and want the sale run for you | You already have an audience and want them to stay in your product | ### Raise on CoinList CoinList runs the sale end to end. You define the offering, and CoinList provides the sale page, the participant base, and the operational lift on sale day. You are responsible for token economics and sale terms, marketing and community communications, post-sale reporting to your investors, and long-term token operations. Everything else sits on the Passage side. ### Host the sale on your own platform You build the sale experience into your own product using the SDK, and Passage runs the same compliance and settlement underneath. Your users sign in, qualify, and contribute without leaving your app. This is the right shape when you already own the user relationship and don't want to send people elsewhere to participate in your own raise. ## What Passage handles either way * KYC, AML screening, and identity verification * Jurisdiction and eligibility checks, configured per sale round * Payment collection and settlement * Smart contract deployment for the offering * Token distributions, vesting, and lockups * Investor disclosures and document signing ## What you configure Sale terms are set per offering and per round, and typically cover: * **Schedule** - when the sale opens and closes * **Pricing and caps** - price per token, total raise cap, per-round caps * **Sale rounds** - separate pricing and eligibility per round * **Accepted currencies** - which assets participants can pay with * **Vesting and lockups** - cliff, duration, and unlock schedule per round * **Eligibility** - jurisdictions, KYC level, and accreditation requirements Work with your Passage account manager to define these and to submit the documentation for compliance review. ## Build it Approve funds and record a participation with `coinlist.tokenSale.execute`. Read and create participations, and track their status over time. Surface your offering and its rounds in your own UI. Scope your raise and start the compliance review. # Embed token sales Source: https://docs.passage.coinlist.co/use-cases/token-sales Offer your users access to CoinList token sales, embedded in your app. **Who it's for:** platforms whose users want early access to new token offerings, with compliance and settlement handled by Passage. **The flow:** sign in, qualify for the specific offering (eligibility varies per sale), contribute through the invest flow, and receive distributions on the configured schedule, including any vesting or lockups. This is for surfacing sales that CoinList already runs. If you are raising for your own token, see [Run your raise](/use-cases/run-your-raise). ## Build it Approve funds and record a participation with `coinlist.tokenSale.execute`. Read and create participations for a sale. # Embed tokenized stocks and funds Source: https://docs.passage.coinlist.co/use-cases/tokenized-assets Let your users buy, hold, and sell tokenized stocks and funds natively inside your app. **Who it's for:** fintechs, neobanks, and wallets with a global user base. **The flow:** the user signs in with Passage via OAuth, completes KYC and eligibility, buys through the Passage purchase flow inside your app, and receives tokens to their self-custody wallet. ## What a stock is A stock is an ownership share in a company. Owning a share entitles the holder to the economic upside of the company, to dividends where the company pays them, and to other rights such as voting. A tokenized stock is a blockchain token that represents that share, held in the user's own wallet rather than a brokerage account, and it settles like any other digital asset. Not every tokenized stock carries the same rights: some are direct ownership of the underlying share, others are wrapped exposure that tracks the price. See [Asset catalog](/assets/catalog) for the full primer. ## What a fund is A fund represents a basket of assets managed by an asset manager. Funds can be actively managed, where the manager makes discretionary decisions, or passively managed, where the fund follows an index or a set allocation strategy. See [Asset catalog](/assets/catalog) for the full primer. ## Who issues the assets Superstate issues tokenized US equities through Opening Bell, where the token is direct ownership of the underlying share. Ondo issues a broad catalog of tokenized stocks and funds as wrapped exposure. WisdomTree is coming soon with tokenized funds where the token is a direct fund interest held in the investor's name. The difference matters to your users, so see [Asset issuers](/assets/partners) before you pick what to surface. ## Build it The buy and sell paths for Ondo assets. The purchase path for Superstate assets. List the offers available to your users and render them in your own UI. # Wallet partners Source: https://docs.passage.coinlist.co/wallet-partners Passage ships no wallet stack. Any provider that can satisfy the EvmWallet interface can support it, provider by provider. Passage is wallet-agnostic. The SDK ships **no wallet stack**, is not coupled to any provider, and never asks a user to connect. Wallet discovery, connection, and signing stay with your app and whatever wallet infrastructure it already uses. That inversion is what makes a wallet partner possible. Instead of adopting someone else's wallet, you implement one small interface, [`EvmWallet`](/sdk/wallets), and the SDK drives it. Do that once and every on-chain flow works: Ondo buys and sells, Superstate swaps, token-sale approvals, and wallet ownership proofs. The pages in this section cover providers whose customers can plug Passage in, including what their wallets are, how keys are held, and what the integration takes on each side. ## Partner profiles at a glance | Partner | What they provide | Key model | Networks | | - | - | - | - | | [Para](/wallet-partners/para) | Embedded wallets created at sign-up from email, phone, social login, or a passkey | MPC. Keys are split between the user's device and Para, so neither Para nor your app holds a complete key | EVM: Ethereum and Base, mainnet and Sepolia | Embedded wallets, MPC keys ## What a wallet partner has to satisfy One interface, five members. `EvmSigner` covers `address` and `signMessage`, which is enough to prove wallet ownership. `EvmWallet` adds `writeContract`, `broadcastRawTx`, and `awaitTx`, which covers everything on-chain: approvals, swaps, and allowlist transactions. The SDK ships [viem](https://viem.sh/) types, so a provider that already exposes a viem account or wallet client is most of the way there. See [Wallets](/sdk/wallets) for the full interface, the adapter pattern, and how wallet errors are classified. Users keep custody throughout. Passage has no ability to access, recover, freeze, or otherwise control assets in a user's wallet, whoever provides it. See [Wallets and chains](/wallets-chains). ## Become a wallet partner If you provide wallets, embedded wallets, or wallet-as-a-service infrastructure and want your customers to reach Passage assets without leaving your stack, we can scope the integration with you. Scope a wallet integration with the Passage team. # Para Source: https://docs.passage.coinlist.co/wallet-partners/para An embedded wallet provider. Users get a self-custodial wallet at sign-up, and one adapter hook connects it to every Passage flow. Para maintains its own [Passage integration walkthrough](https://docs.getpara.com/v3/walkthroughs/passage), with the adapter written out in full. Treat it as the source of truth for the Para side of the integration, and this page as the Passage side of it. ## What it is Para is an embedded wallet and authentication provider, launched as Capsule and renamed to Para in 2025. Its npm packages still carry the old name in places. Users sign up with email, phone, social login, or a passkey, and get a wallet in the process. There are no seed phrases and no browser extension, so the wallet is part of signing up for your app rather than a separate step your users have to understand first. ## Key model MPC. Private keys are split between the user's device and Para's infrastructure, and neither Para nor your application ever holds a complete key. That keeps the wallet self-custodial, which is what Passage flows assume: the user signs their own transactions and pays their own gas, and nothing in Passage can move funds on their behalf. ## What the integration looks like The Para-specific work is one hook. Para exposes a viem account through `useParaViemAccount()`, and you write an adapter, called `useParaEvmWallet` in Para's walkthrough, that presents it as Passage's [`EvmWallet`](/sdk/wallets): `address`, `signMessage`, `writeContract`, `broadcastRawTx`, and `awaitTx`. Everything after that is the same code any Passage partner writes. Offers, eligibility and requirements, KYC, wallet screening, disclosures, document signing, and the checkout flows for Ondo and Superstate all run unchanged, because none of them know which wallet stack is underneath. The adapter is also asset-agnostic. It is written against the wallet interface rather than against any particular provider's flow, so assets added to Passage later become available to your users without further Para-side work. Because Para signs locally, there is no chain for the user to switch. The adapter takes the target chain as a parameter on each call, which is the same contract any `EvmWallet` implementation satisfies. ## Supported networks EVM only for now: Ethereum and Base, on both mainnet and Sepolia. Para supports more chains than this, but the Passage integration is limited to the four the SDK's `EthereumChain` type covers. Which of those a given order can actually settle on comes off the offer rather than from this list. `OfferDetail.swapContracts` names the chains an offer supports, and [`swapSpender`](/sdk/ondo-swap#which-contract-the-trader-approves) returns `null` for a chain it does not. See [Wallets and chains](/wallets-chains) for what is live today. ## What you still need from Passage Para handles the wallet. Passage credentials are separate, and you register for your own: a `client_id`, a registered `redirect_uri`, and a `client_secret`. OAuth runs through your backend so the secret never reaches the browser. See [Set up OAuth authentication](/sdk/oauth-authentication) for the flow, and [contact us](mailto:support@coinlist.co) if you need credentials. ## FAQ No, and neither does your app. Keys are split with MPC between the user's device and Para's infrastructure, so a complete key exists in neither place. Passage never has access to a key at all. No. A Para wallet is created from the sign-up method your user already chose, whether that is email, phone, social login, or a passkey. One adapter hook, which Para's walkthrough gives in full. It maps Para's viem account onto the five members of `EvmWallet`. Nothing else in a Passage integration is provider-specific. Yes. The wallet provider does not change what an offer requires. Passage reads the requirements for the offer your user is interested in and walks them through exactly those steps. See [Requirements](/sdk/requirements). Yes, and the SDK runs it. The user signs a message to prove they control the address, and for Superstate the issuer also allowlists that address on-chain before it can settle. A Para wallet satisfies both through the same `signMessage` the adapter already supplies. The user, from their own wallet, as on any Passage integration. A Para wallet needs the native token of whichever chain the order settles on. See [Wallets and chains](/wallets-chains). Yes. `CheckoutWalletSelection` takes an embedded wallet and external ones together, so Para can be the default while a user who prefers their own wallet connects it instead. See [Wallets](/sdk/wallets). ## Build it The adapter in full, step by step, from Para's own docs. The `EvmWallet` seam the adapter satisfies, and how wallet errors are classified. Register your credentials and run the flow through your backend. Hand the adapted wallet to a container that renders any offer type. # Wallets and chains Source: https://docs.passage.coinlist.co/wallets-chains Users keep custody of their own keys. Support embedded wallets, external wallets, or both. ## Who controls the keys? Your users do. Users connect their own wallet and sign their own transactions, keeping custody of their keys and funds the entire time. Passage has no ability to access, recover, freeze, or otherwise control assets in a user's wallet. ## Can I support both embedded and external wallets? Yes. You can support embedded wallets, externally connected wallets, or both, and you configure your own signing process around whatever your product already does. Wallet selection and connection are your app's responsibility. Once a wallet is connected, the SDK takes it from there: see [Wallets](/sdk/wallets) for the `EvmWallet` seam, proving ownership, and registering a wallet against an offer. Using an embedded wallet provider? See [Wallet partners](/wallet-partners). ## Which chains are supported? Superstate and Ondo assets are supported on Ethereum today, and purchases are Ethereum-only for now. Additional chains will follow as our issuing partners expand across chains. ## Who pays gas? The user pays gas on their own transactions. What is in the catalog today, and a primer on each asset class. # What is Passage? Source: https://docs.passage.coinlist.co/what-is-passage The three parties in an offer, what your users experience, and who holds the money. ## The parties in an offer Every offer on Passage involves three parties. * **Your platform (distribution partner).** You own the user relationship and the front-end experience. Your users discover and access assets inside your app. * **Passage.** Passage sits between you and the asset issuers, and handles authentication, KYC and eligibility, allowlists, compliance, purchase rails, and settlement. * **Asset issuers.** Issuers create the underlying assets and set the requirements attached to each one. Some are tokenization platforms such as Superstate and Ondo; others issue directly, such as WisdomTree and CoinList. ## What the end user experiences Sign in, qualify, invest, receive. The user authenticates through Passage via OAuth inside your app, completes eligibility checks, invests in the offer, and receives assets to a wallet they control. Users always stay inside your product. ## What you build vs. what Passage handles | You build | Passage handles | | - | - | | Your app's UI and user experience | Compliance: KYC, AML screening, and identity verification | | User discovery, marketing, and community | Jurisdiction and eligibility checks for every offering | | Wallet connection in your product | Allowlist registration with issuers and pools | | Post-sale communications with your users | Purchase flows, funds settlement, payments and distributions | | | Ongoing wallet screening where an issuer requires it | | | Disclosures, tax documents, and offer-specific reporting requirements | ## Who holds the money? The user. Passage is not a custodian and not a broker. We never take custody of your users' funds or assets, and we have no ability to move, freeze, or recover them. What Passage is, is a secure and audited transaction layer. We verify that a user is eligible, then coordinate the transaction between the user and the asset issuer through a funding smart contract that belongs to that specific offering. For a tokenized stock purchase, that looks like this. The user authorizes the payment amount from their own wallet. Passage confirms they are eligible for that asset. The funds settle through the offering's funding contract to the issuer. The issuer delivers the tokens to the wallet the user controls. At no point do the funds or the assets pass through a Passage account. The smart contracts involved in money movement have been audited by Oak Security and are publicly available at [https://github.com/coinlist/on-chain](https://github.com/coinlist/on-chain). ## Do my users need a separate account with each issuer? Your users verify once through Passage, and that verified identity carries across issuers and offerings. In some cases, accounts may be created directly with the issuer or tokenization platform on their behalf. When this is true, all of this information is fully disclosed to the user prior to investing, and Passage handles the entire process without redirecting the user to a third-party platform. Individual offerings may have unique requirements, a tax form for example, and when that happens Passage collects it inside the same flow rather than sending your user somewhere else. ## Do I have to build my own UI? You control the user experience in your app, and that is the point. You know what your users like and how they want to invest, so the front end is yours to design. Passage provides the APIs and rails underneath, along with some ready-made SDK components if you want a head start. We do the heavy lifting on the back end. Hooking it up is quick. ## See it running The partner demo is a working integration you can read and run, covering OAuth, offers, eligibility, and both purchase flows end to end. Open the hosted demo and click through the flows your users would see. Clone the source to see how each flow is wired up. Who runs KYC, how requirements vary by offering, and what licensing means for you.