Build a cash-out flow
By the end of this guide your app will let a user turn USDC in their wallet into naira in their bank account, with your own screens. You'll use the headless SDK, @recoilpay/intent-core, which does the hard parts: ranking offers, sealing bank details, the Permit2 approval and lock, and following the trade.
What the user goes through: choose an amount → see the offer → enter bank details → approve and sign → the solver pays → they confirm the money arrived.
What you need:
- A web app where users connect an EVM wallet. The examples use viem; with wagmi, use the
WalletClientfromuseWalletClient(). - Testnet USDC on Base Sepolia, Polygon Amoy or BNB Chain Testnet. See Supported networks.
- About 30 minutes.
The widget and React component do all of this already. This guide is for when you want your own UI.
1. Install and connect a wallet
npm install @recoilpay/intent-core viem
A cash-out runs in an off-ramp session, which needs the user's wallet. viemWallet adapts a viem WalletClient:
import { createOfframpSession, viemWallet, type OfframpSession } from '@recoilpay/intent-core';
import { createWalletClient, custom, type WalletClient } from 'viem';
import { baseSepolia } from 'viem/chains';
const [account] = await window.ethereum.request({ method: 'eth_requestAccounts' });
const walletClient = createWalletClient({ account, chain: baseSepolia, transport: custom(window.ethereum) });
export const API_URL = 'https://api.recoilpay.com';
export function startCashOut(walletClient: WalletClient, apiUrl = API_URL): OfframpSession {
return createOfframpSession({ apiUrl, wallet: viemWallet(walletClient) });
}
Use one session per cash-out. It switches the wallet to the right chain itself when it's time to sign.
2. Find where cash-outs run
Don't hardcode chains or token addresses. The chain registry lists every chain with a fiat escrow. This guide signs with an EVM wallet, so it keeps the EVM ones; Solana devnet has one too, and works differently (see On Solana):
export async function cashOutChains(apiUrl = API_URL): Promise<CashOutChain[]> {
const res = await fetch(`${apiUrl}/api/v1/chains`);
const { data } = await res.json();
return data
.filter((c: any) => c.fiat_escrow && c.family !== 'solana') // EVM chains: this flow signs with viem
.flatMap((c: any) => {
const usdc = c.tokens.find((t: any) => t.symbol === 'USDC');
return usdc ? [{ chainId: c.chain_id, name: c.name, usdc: { address: getAddress(usdc.address), decimals: usdc.decimals } }] : [];
});
}
Today that's Base Sepolia (84532), Polygon Amoy (80002) and BNB Chain Testnet (97). Offer them in a chain picker.
3. Describe the cash-out
The session takes an OfframpRequest: the chain, the token, the amount in base units, and the currency corridor.
export function cashOutRequest(chain: CashOutChain, amount: string): OfframpRequest {
return {
chainId: chain.chainId,
chainName: chain.name,
asset: 'USDC',
token: chain.usdc.address,
decimals: chain.usdc.decimals,
amount: parseUnits(amount, chain.usdc.decimals),
corridor: FIAT_CORRIDORS.NGN,
};
}
Already using the intent session for plain-English input? Its state.offramp is this same object when the user types cash out 50 USDC on Base Sepolia to NGN, so you can skip this step.
4. Get an offer
Subscribe to the session, then ask for a quote:
const session = startCashOut(walletClient);
session.subscribe((state) => render(state)); // render: your UI
await session.quote(cashOutRequest(chain, '50'));
When state.phase is quoted, state.preview has everything for the offer card, already formatted:
| Field | Example |
|---|---|
payAmount, paySymbol, chainName | 50, USDC, base-sepolia |
receiveAmount, currencySymbol | 74,725.00, ₦: what reaches the bank account |
fee, rate | 275.00, 1,500.00: naira per USDC |
paymentWindowSecs | how quickly the solver promises to pay |
If no solver covers the chain and amount, the phase is error and state.error says so in words you can show. Let the user try another amount or chain.
5. Collect the bank details
Show a bank picker, the account number and the name on the account. The corridor has the bank list and the account format:
export const BANKS = NG_BANKS; // [{ code: '058', name: 'Guaranty Trust Bank' }, …]
export function bankDetailsProblem(account: string, name: string): string | null {
if (!FIAT_CORRIDORS.NGN.accountPattern.test(account.trim())) return 'Enter the 10-digit account number (NUBAN).';
if (name.trim().length < 2) return 'Enter the name on the account.';
return null;
}
The details are encrypted in the browser for the one solver who pays. Don't log them, send them to your backend, or put them in analytics.
6. Confirm
When the user clicks Cash out:
await session.confirm({ rail: 'ng.nip', bankCode, account, name });
That one call works through the phases: switchingChain → approving (a one-time Permit2 approval of the token; state.needsApproval tells you in advance) → opening → signing (the lock signature) → locking. Your user's wallet prompts at approving and signing. Before asking for the signature, the session checks that the lock names exactly the amount, chain, escrow, solver and bank details the user chose.
7. Follow the trade
Map each phase to what you show. This covers every phase a cash-out goes through:
export function describe(state: OfframpState): string {
const p = state.preview;
switch (state.phase) {
// …see the complete code below
case 'awaitingFiat':
return 'Your USDC is locked. The solver is paying you now.';
case 'awaitingConfirmation':
return `The solver says it sent the money (reference ${state.trade?.payment_reference}). Check your bank app, then confirm or dispute.`;
// …
}
}
The solver has 15 minutes to pay once the USDC is locked; if it doesn't, the phase becomes refunded and the USDC returns to the user. state.trade.deadline_at is when the current clock runs out, if you want a countdown.
8. Remember the trade
A cash-out can wait hours for the user to confirm, so save the trade id as soon as it exists. Also keep payeeSalt: together with the bank details, it proves which account the solver was told to pay if there's ever a dispute. Show it on a receipt too.
export function remember(state: OfframpState): void {
if (state.trade && state.payeeSalt) {
localStorage.setItem('recoilpay:cash-out', state.trade.id);
localStorage.setItem(`recoilpay:cash-out:${state.trade.id}:salt`, state.payeeSalt);
}
}
session.subscribe(remember);
When your page loads, pick it up again:
const saved = localStorage.getItem('recoilpay:cash-out');
if (saved) await session.resume(saved);
While it loads, the phase is briefly quoting with state.trade set; then it continues from wherever the trade is.
9. Confirm or dispute
At awaitingConfirmation, show two buttons:
await session.confirmReceived(); // "I received ₦…": releases the escrow to the solver
await session.dispute(reason); // "It hasn't arrived": an admin reviews it
Both are wallet signatures; neither costs gas. Tell the user to check their bank app first: once they confirm, the solver is paid. If they never answer, the trade goes to an admin after 6 hours, never to the solver.
When the phase is settled, refunded or failed, the cash-out is over. Clear the saved id.
Try it
- Get testnet USDC on Base Sepolia and connect that wallet.
- Cash out a small amount, such as 10 USDC.
- Watch the phases go by. When it's
awaitingConfirmation, confirm.
If you see "No solver is cashing out USDC on … right now", no solver has a live offer for that chain and amount. Try the other chain or a different amount.
Complete code
Everything above in one module. It ran end to end against a local aggregator and solver before publishing.
import {
createOfframpSession,
FIAT_CORRIDORS,
NG_BANKS,
viemWallet,
type OfframpRequest,
type OfframpSession,
type OfframpState,
} from '@recoilpay/intent-core';
import { getAddress, parseUnits, type WalletClient } from 'viem';
export const API_URL = 'https://api.recoilpay.com';
// ── Step 2: find where cash-outs run ─────────────────────────────────────
export interface CashOutChain {
chainId: number;
name: string;
usdc: { address: `0x${string}`; decimals: number };
}
export async function cashOutChains(apiUrl = API_URL): Promise<CashOutChain[]> {
const res = await fetch(`${apiUrl}/api/v1/chains`);
const { data } = await res.json();
return data
.filter((c: any) => c.fiat_escrow && c.family !== 'solana') // EVM chains: this flow signs with viem
.flatMap((c: any) => {
const usdc = c.tokens.find((t: any) => t.symbol === 'USDC');
return usdc ? [{ chainId: c.chain_id, name: c.name, usdc: { address: getAddress(usdc.address), decimals: usdc.decimals } }] : [];
});
}
// ── Step 3: describe the cash-out ────────────────────────────────────────
export function cashOutRequest(chain: CashOutChain, amount: string): OfframpRequest {
return {
chainId: chain.chainId,
chainName: chain.name,
asset: 'USDC',
token: chain.usdc.address,
decimals: chain.usdc.decimals,
amount: parseUnits(amount, chain.usdc.decimals),
corridor: FIAT_CORRIDORS.NGN,
};
}
// ── Step 1 and 4: one session per cash-out ───────────────────────────────
export function startCashOut(walletClient: WalletClient, apiUrl = API_URL): OfframpSession {
return createOfframpSession({ apiUrl, wallet: viemWallet(walletClient) });
}
// ── Step 5: the bank details form ────────────────────────────────────────
export const BANKS = NG_BANKS; // [{ code: '058', name: 'Guaranty Trust Bank' }, …]
export function bankDetailsProblem(account: string, name: string): string | null {
if (!FIAT_CORRIDORS.NGN.accountPattern.test(account.trim())) return 'Enter the 10-digit account number (NUBAN).';
if (name.trim().length < 2) return 'Enter the name on the account.';
return null;
}
// ── Step 7: what to show for each phase ──────────────────────────────────
export function describe(state: OfframpState): string {
const p = state.preview;
switch (state.phase) {
case 'quoting':
return state.trade ? 'Loading your cash-out…' : 'Finding the best cash-out rate…';
case 'quoted':
return `You pay ${p!.payAmount} ${p!.paySymbol} on ${p!.chainName} and receive ${p!.currencySymbol}${p!.receiveAmount} (fee ${p!.currencySymbol}${p!.fee}).`;
case 'switchingChain':
return 'Switching network…';
case 'approving':
return `Approving ${p?.paySymbol} (first time only)…`;
case 'opening':
case 'signing':
return 'Confirm in your wallet…';
case 'locking':
return 'Locking your USDC in escrow…';
case 'awaitingFiat':
return 'Your USDC is locked. The solver is paying you now.';
case 'awaitingConfirmation':
return `The solver says it sent the money (reference ${state.trade?.payment_reference}). Check your bank app, then confirm or dispute.`;
case 'confirming':
case 'disputing':
return 'Confirm in your wallet…';
case 'disputed':
return 'An admin is reviewing this cash-out. Your USDC stays in escrow until they decide.';
case 'settled':
return 'Cashed out.';
case 'refunded':
return 'The solver did not pay in time. Your USDC was returned.';
case 'failed':
return 'The lock never landed, so nothing left your wallet.';
case 'error':
return state.error ?? 'Something went wrong.';
default:
return '';
}
}
// ── Step 8: keep the trade id and the salt ───────────────────────────────
export function remember(state: OfframpState): void {
if (state.trade && state.payeeSalt) {
localStorage.setItem('recoilpay:cash-out', state.trade.id);
localStorage.setItem(`recoilpay:cash-out:${state.trade.id}:salt`, state.payeeSalt);
}
}
Next: Build a purchase flow · How cash-outs work under the hood