Liquidation addresses
This is the load-bearing architectural choice. Read it before touching any payment or withdraw code.
What they are
A Bridge liquidation address is a Bridge-generated Ethereum/Base address configured with a fiat destination. USDC sent to it is automatically:
- Detected on-chain by Bridge.
- Off-ramped USDC → fiat (BRL, GBP, or EUR).
- Pushed to the pre-configured destination (a PIX key, an FPS account, a SEPA account).
The developer fee (GrinGo's spread) is set on the address at creation time. Bridge deducts it before pushing to the destination.
Why we use them (instead of an operational wallet)
The intuitive design would be:
We do not do this. Reasons, in order of importance:
- No custody. GrinGo never signs a USDC transaction, never holds a key that could move user funds. This is a huge de-risking win — no operational wallet to secure, monitor, rotate, or lose access to.
- Regulatory posture. GrinGo is a pure orchestrator; funds go user → Bridge with GrinGo only creating the routing instructions. Cleaner story for compliance in every jurisdiction we care about.
- Atomicity per payment. Each PIX payment has its own address. Bridge handles detection, conversion, and payout atomically for that address — there's no float, no reconciliation between "USDC arrived in our wallet" and "we forwarded it."
- No
POST /v0/transferscomplexity. No manual settlement, no batched forward jobs.
How it works in code
For a PIX payment (apps/api/src/pix/routes.ts):
// Simplified — see actual route for full validation & error handling
const externalAccount = await bridge.createPixExternalAccount(customerId, {
account_owner_name: recipientName ?? "PIX recipient",
pix_key: pixKey,
});
const liquidationAddress = await bridge.createPixLiquidationAddress(
customerId,
externalAccount.id,
DEFAULT_GRINGO_SPREAD_BPS / 100, // 2.0 (percent)
);
For a withdrawal (apps/api/src/withdraws/routes.ts):
// GBP example — user's own bank account
const external = await bridge.createExternalAccount(customerId, {
currency: "gbp",
bank_details: { sort_code, account_number, account_owner_name },
});
const liquidation = await bridge.createLiquidationAddress(customerId, {
chain: "base",
currency: "usdc",
destination_payment_rail: "fps",
destination_currency: "gbp",
external_account_id: external.id,
});
The mobile client then sends USDC on Base to liquidationAddress.address via Privy's smart-wallet signer.
Reuse semantics
- PIX payment addresses are created per payment. A merchant PIX key today may be a different one tomorrow; there's no benefit to caching.
- Withdraw addresses are created once per (user, bank account) and cached on
bank_details.bridgeLiquidationAddress. The user's own bank account rarely changes.
Webhook contract
Bridge emits drain events per address:
liquidation_address.drain_completed— USDC arrived, off-ramp succeeded, fiat pushed.liquidation_address.drain_failed— USDC arrived but off-ramp failed (rare: address closed, PIX rejected).
Handler: apps/api/src/webhooks/bridge.ts (handleLiquidationDrain). It looks up the transaction by liquidation address, marks it completed or failed, and stores pixEndToEndId / bridgeTransferId where available.
Common failure modes
| Symptom | Cause | Fix |
|---|---|---|
Transaction stuck in pending | User's USDC transfer hasn't been mined yet, or Bridge hasn't indexed it | Wait; check the tx hash on BaseScan |
drain_failed with insufficient_funds | User sent less USDC than the address's minimum (rare — we quote exact amount) | Refund path — currently manual |
drain_failed with destination_rejected | Merchant PIX key invalid or rejected the deposit | Refund path — currently manual |
| No drain event ever | Bridge webhook secret misconfigured; signature verification failing | Check BRIDGE_WEBHOOK_SECRET; look at webhook_events table for entries |