Skip to main content
Some Passage offers are token sales: the user commits a stablecoin (USDC, USDT, …) to buy into an offer, and the participation settles later on Passage’s own schedule. The coinlist.tokenSale namespace runs that flow end-to-end - it reads the allowance, submits the ERC-20 approve, and records the participation - so you don’t hand-roll the on-chain plumbing. Like the swap flow, coinlist.tokenSale is headless: it drives the on-chain steps, but you bring the wallet through the same EvmWallet interface. One call, coinlist.tokenSale.executeTokenSale, is the token-sale twin of executeSwap.
Offers now carry a type field ("sale" or "swap"). Use offer.type === "sale" from fetch offers or offer details to route the user into this flow, and "swap" into the swap flow.

Prerequisites

  • Completed OAuth with a working CoinListProvider
  • An OfferDetail and the OfferOption the user picked (from Display offer 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 + Reown AppKit; any stack that can send transactions works. The adapter is the same one the swap flow uses - see Adapt your wallet to EvmWallet.

Flow at a glance

  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.executeTokenSale 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 the swap flow, a token sale needs no wallet authorization 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.
constants.ts
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: Adapt your wallet to EvmWallet

The token-sale flow drives any wallet through the same EvmWallet interface as the swap flow. It only ever calls address, writeContract, and awaitTx (there’s no message to sign), so the adapter you already wrote for swaps works unchanged.
If you haven’t built it yet, copy wagmiEvmWallet.ts from the swap flow. Let your wallet library’s errors propagate as-is - the SDK classifies them into a typed WalletError (user_rejected, insufficient_funds, contract_reverted, timeout, …).

Step 3: Execute the sale

executeTokenSale 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.
Phases (wallet popups marked): checking-allowance(resetting-allowanceconfirming-allowance-reset, only for stale non-zero allowances)approving (wallet popup)confirming-approvalrecording-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 buildEvmWallet (Step 2, shared with the swap flow).
MinimalInvest.tsx

Reflecting status back in the UI

After a successful executeTokenSale, use useParticipations(offerId) to fetch the user’s participations for that offer and render their status (pending, completed, failed, remitted, etc.). The participation moves through statuses asynchronously - the SDK doesn’t push; you re-fetch on session refresh or after a user action.

Going further

  • Show the user’s balance - coinlist.erc20.getTokenBalance({ 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.
  • Lower-level control - if you drive the ERC-20 approve yourself (a custom wallet, batching, a different confirmation UX), skip executeTokenSale and record the participation directly with coinlist.tokenSale.createParticipation, passing the approvalTransactionHash you obtained. executeTokenSale is the batteries-included version of exactly that sequence.

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 cancellable (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 executeTokenSale 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 the swap 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 generalises: 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

Display participation status

Use useParticipations(offerId) or CoinListClient.tokenSale.fetchParticipations() to render the user’s participation status after they invest.