type: "coinlist::token_sale", and the user commits a stablecoin to buy into it. During the flow the user signs one ERC-20 approve to the sale’s funding contract, and the SDK records a participation against that approval. CoinList takes it from there on its own clock: it verifies the approval, pulls the payment through the allowance in a batch, and allocates tokens and refunds any excess when the sale settles.
Recommended: render
CheckoutContainer. 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
Nothing. Since v0.13.0CheckoutContainer mounts the SDK’s own token-sale checkout (it used to hand this branch back to you through a required render slot, which is gone). The funding contract, the coins and the price are all read off the offer, so the "coinlist::token_sale" config entry is optional and takes one listener, onOrderPlaced, fired once the participation is recorded. The dialog shows either way. Which option the user buys is the optionId prop, which defaults to the offer’s first option:
optionId is read once, at mount, like chain and side, so put it in the key if the user can switch options on one page. The token-sale entry and useCoinListTokenSaleCheckoutViewModel are @alpha: public, but outside the compatibility contract.
The checkout runs three steps:
- Wallet - the shared wallet step. It is skipped when you set
wallets.preselected(see Wallets). - Amount - entered in USD and paid 1:1 in USDC or USDT, with balances and a Max button. It is checked against the option’s minimum, and blocked outside the sale window, with a banner that says when the sale opens or that it ended.
- Review - estimated tokens at the option’s price, price per token, the receiving wallet and the total. The disclaimer links the option’s sale agreement.
passage.tokenSale.execute. On success the Order placed dialog opens, the steps reset, and onOrderPlaced is called with the wallet, amount, coin, participation, and approval hash. The quantity shown is an estimate: the sale decides each allocation when it settles.
A sale that cannot take an order on the checkout’s chain renders an unavailable screen in place of the steps:
The funding contract comes from the offer:
CoinListTokenSaleOfferDetail.fundingContracts, one { chain, address } per chain, mapped from the API’s funding_contracts. An offer payload without that key fails mapping with a ValidationError. An empty list is valid and means the sale takes no orders anywhere.RequirementsChecklistContainer first, as for every other checkout.
Prerequisites
- Completed OAuth with a working
PassageProvider - A token-sale
OfferDetail(CoinListTokenSaleOfferDetail) and theOfferOptionthe user picked (from Display offer details) - A wallet integration exposed as an
EvmWallet. The partner demo uses wagmi + Reown AppKit; any stack that can send transactions works. The adapter is the same one every flow uses - see Wallets.
Build your own UI with hooks (L2)
Build your own UI with hooks (L2)
useCoinListTokenSaleCheckoutViewModel({ offer, chain, wallets, optionId?, enabled?, onOrderPlaced? }) drives the whole checkout above and returns { state, onEvent }. Call it behind your own markup when you want the SDK’s logic and state but not its UI. state is { type: "error", reason } for a sale that cannot take an order (the reasons in the table above), or { type: "content" } with wallet (null when the wallet step was skipped), amount, review, sidebar, and orderPlaced slices.For reading what the user already bought, useCoinListTokenSaleParticipations is the data hook - see Track participations below. useErc20TokenBalances reads the user’s stablecoin balances.For a buy flow of your own without the viewmodel, drive passage.tokenSale.execute from your component state. It is a single call that returns a step-tagged result, so there is little to hold beyond the amount and the current phase. The L1 walkthrough below shows exactly that.Drive it yourself with the client (L1)
Drive it yourself with the client (L1)
passage.tokenSale runs the whole flow end-to-end: it resolves the funding contract and the coin from the offer, 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
- connect_wallet - your wallet UI; the SDK only needs the connected account.
- enter_amount - the user picks a coin the offer accepts (
offerDetail.fundingAssets) and enters an amount. - investing -
passage.tokenSale.executereads the allowance, runs the ERC-20 approval, and records the participation, reporting progress phases. - 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. CoinList moves the payment afterwards, through that allowance.Step 1: Pin the sale parameters
Keep the chain and the coin in one place.TOKEN_REGISTRY knows the ERC-20 metadata and per-chain address for USDC and USDT. The funding contract is not a constant: execute reads it off the offer.constants.ts
This page runs on Sepolia. To move to mainnet, set
SALE_CHAIN to "ethereum_mainnet". The offer must name a funding contract on that chain, or execute refuses with unsupported-chain - TOKEN_REGISTRY already knows the mainnet USDC/USDT addresses.Step 2: Bring your wallet
The token-sale flow drives any wallet through the sameEvmWallet 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.Wallets: the EvmWallet seam
The five methods, the wagmi + AppKit adapter, and how wallet errors are classified.
Step 3: Execute the sale
passage.tokenSale.execute runs the whole on-chain sequence and returns a discriminated result. It never throws. Pass the offer itself: the funding contract, the coin’s token address, and its asset id are all read off it. The amount is a BlockchainAmount in the coin’s base units - parse the user’s input with parseBlockchainAmount.checking-allowance → (resetting-allowance → confirming-allowance-reset, only for stale non-zero allowances) → approving (wallet popup) → confirming-approval → recording-participationThe reset, when it runs, is a transaction of its own and opens a wallet popup too.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. A failure there is reported as allowance-reset or allowance-reset-reverted, distinct from the main approval’s approval and approval-reverted, so you can tell which of the two prompts failed.error.step === "participation"), the error carries the approvalTxHash. Retry against it with approval: { type: "mined", txHash }: execute then skips straight to recording-participation and asks the wallet for nothing. Use it only for the same amount, since a new amount needs a new approval.Putting it together
A complete, minimal invest component. It reusesconstants.ts (Step 1) and the buildEvmWallet adapter from Wallets. When recording fails after the approval mined, it keeps the approval hash and retries against it, until the user changes the amount.MinimalInvest.tsx
Going further
- Show the user’s balance -
passage.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; it returns{ state }, whoseCONTENTarm carries abalancesmap. - Lower-level control - if you drive the ERC-20
approveyourself (a custom wallet, batching, a different confirmation UX), skippassage.tokenSale.executeand record the participation directly withpassage.tokenSale.createParticipation, passing theapprovalTransactionHashyou obtained. The spender is the offer’s funding contract on your chain, fromoffer.fundingContracts.executeis 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 on the backend’s clock -prepared → pending → submitted → completed as the approval is verified and the payment committed, then remit_submitted → remitted when the sale settles and refunds what the allocation did not use (failed and remit_failed are the failure states). None of it is pushed to the SDK.
To show participations without building any UI, render
ClaimContainer (@alpha) for the token-sale offer. It lists every participation the user placed in that offer, across its options and paying wallets, newest first, and polls like the hook below. Nothing is claimed: the sale pushes the tokens and the refund itself, so its only action is a retry after a load error.Recommended: useCoinListTokenSaleParticipations
offerId is optional: omit it to list every participation for the signed-in user. The hook was useParticipations before v0.13.0. It now:
- Polls
passage.tokenSale.list(offerId)every 15 seconds while the page is visible, and reads once more when the page becomes visible again. PasspollInterval(aSeconds) to change the interval. That is also how an order placed throughCheckoutContaineron the same page appears, with nothing for you to wire. - Keeps the list on a failed poll. A failed tick with a list in hand stays
CONTENT, soERRORmeans no list has arrived yet. Anot-authenticatedfailure is the exception: it drops the list and reportsERROR. - Reports
DISABLEDwhenenabled: falseand nothing is held. A list already in hand survives being switched off. - Returns
refetch(), which reads again now (a no-op while disabled).
data to seed it with participations pre-fetched via PassageServer#tokenSale.list(). The seed opens the state and skips only the first client-side read; the poll and refetch still read. See SDK structure.
Reading and writing imperatively
passage.tokenSale is available on both the browser client and PassageServer, except execute, which needs a wallet.
createParticipation takes the same EthereumChain and EvmWalletAddress as every other flow. The Blockchain and WalletAddress types it took before v0.13.0 are gone. On the way back, a Participation’s chain is a Chain, or null for a chain the SDK does not model, and its walletAddress is an EvmWalletAddress or null.
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 for the raw request and response schema.execute requires a logged-in user and throws NotAuthenticatedError otherwise. execute never throws: logged out, it returns an error step instead.
Common questions
Why an approve instead of a transfer?
Why an approve instead of a transfer?
CoinList moves the payment itself, after on-chain verification. The user only signs an allowance up to the amount they want to invest, and nothing leaves their wallet during the flow. CoinList then verifies the approval and pulls the payment through the allowance into the sale’s funding contract in a batch, usually within a minute or so. That lets CoinList check every participation before it commits the payment, and batch the transfers.
Why is an approval always submitted?
Why is an approval always submitted?
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
passage.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.Do I need wagmi / Reown AppKit specifically?
Do I need wagmi / Reown AppKit specifically?
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.What if recording fails after the approval mined?
What if recording fails after the approval mined?
The error comes back as
{ step: "participation", approvalTxHash }. The approval is already on-chain, so call passage.tokenSale.execute again with approval: { type: "mined", txHash: approvalTxHash } and the same amount, rather than re-running the whole flow - the user doesn’t need to approve again.What about chains other than Ethereum?
What about chains other than Ethereum?
The flow generalizes: the same
EvmWallet adapter maps each EthereumChain to a network, TOKEN_REGISTRY knows the payment-token addresses per chain, and the offer’s fundingContracts names the chains a sale takes orders on. Your account manager will confirm which chains and assets are enabled for your offers.Next step
Building the Checkout flow
The recommended path: one container that renders whichever provider an offer belongs to.