user-stories
US-05 — Buy / Sell / Convert via Mercuryo
US-05 — Buying / Selling / Converting Crypto via Mercuryo
User funds their wallet from a card (buy), liquidates crypto to fiat (sell), or swaps one crypto for another (convert). Live execution is delegated to Mercuryo as the on/off-ramp + swap provider.
Persona
Any signed-in user — individual or company. KYC approved is required for all three flows because Mercuryo's KYC is in addition to ours, not in place of ours.
Goal
"Top up my account with €100 of USDC by card. Or sell 0.1 ETH for EUR to my bank. Or swap my ETH for SOL without leaving the app."
Why Mercuryo
Mercuryo (https://mercuryo.io/) is a licensed EU-based on/off-ramp + crypto-to-crypto swap provider. Choosing Mercuryo lets us:
- Avoid building a card-acquirer relationship (PCI scope, 3DS, chargeback ops).
- Avoid building a fiat-out rail for
sell(SEPA / SEPA-Instant / FPS). - Use a single vendor across the three flows (one contract, one settlement reconciliation cadence).
Trade-off: pricing is opaque per-region, and we re-bill the user for our ../03-Domains#Fees platform fee on top of Mercuryo's spread.
Preconditions
- User KYC is
approved(we run our KYC even when Mercuryo also does theirs — see Drawbacks). - The asset/network is supported by Mercuryo for the selected flow (buy / sell / convert).
- For
buy: user has a card supported in their region. - For
sell: user has a verified payout bank account on file. - Mercuryo SDK / widget keys are configured (
MERCURYO_WIDGET_ID,MERCURYO_SECRET).
Buy — happy path
- User goes to
/buy, picks asset →/buy/[asset]. - User enters fiat amount (e.g.,
100 EUR). - We compute the gross quote: Mercuryo's posted price × amount, plus our
buy_externalfee tier (see ../03-Domains#Fees). - User taps Continue → embed Mercuryo widget (
<iframe>or hosted redirect) seeded with:currency=EUR,amount=100,fiat_currency=EUR,crypto_currency=USDC,network=ETH.address= user's USDC-on-ETH receive address (see 02-QR-Receive).merchant_transaction_id= ourtransactions.idfor reconciliation.- HMAC
signatureso the widget refuses tampering.
- User completes 3DS + Mercuryo's KYC step (one-time per user) inside the widget.
- Mercuryo settles the crypto to our user's address.
- Our deposit indexer credits the wallet; transaction row is reconciled from
pending→completedagainst themerchant_transaction_id. - Mercuryo webhook hits
/api/webhooks/mercuryo→ we mark the order final + writeaudit_log.
Sell — happy path
- User goes to
/sell, picks asset →/sell/[asset], enters crypto amount. - We quote: Mercuryo's bid price × amount, minus our platform fee.
- User picks a verified payout bank account.
- On confirm:
- We move the crypto from the user's wallet to Mercuryo's deposit address (a normal
send, idempotent). - We create an off-ramp order via Mercuryo's API with the bank-account reference and
merchant_transaction_id.
- We move the crypto from the user's wallet to Mercuryo's deposit address (a normal
- User sees
transactions.status='pending'with a "fiat egress in progress" badge. - Mercuryo webhook on fiat-leg completion → flips status to
completedand emitssell.completedemail + webhook.
Today MVP: status sticks at
pendingbecause no fiat rail is wired (../09-Open Questions#Sell payout rail). Mercuryo cutover unblocks this end-to-end.
Convert — happy path
- User goes to
/convert, picksfromasset,toasset, enters amount. - We request a Mercuryo swap quote (
from=ETH,to=SOL,amount=0.1). - We render gross/net rate, slippage tolerance, our convert-tier fee.
- User confirms within the quote TTL (e.g., 30 s).
- We:
- Acquire advisory locks on
(account, ETH)then(account, SOL)— always in this order to avoid deadlock (see ../08-Risks and Drawbacks#Domains). - Move
fromcrypto to Mercuryo's deposit address (debit). - Receive
tocrypto into the user'stoaddress (credit) on Mercuryo's settlement.
- Acquire advisory locks on
- Both legs land in
transactionswithtype='convert'linked by a singleconvert_id.
Alternate flows
- Buy from saved card: skip 3DS where Mercuryo allows; same reconciliation flow.
- Sell to a different fiat: choose payout currency (
EUR/USD/GBP) at quote time. - Convert with manual address override: not supported — convert is wallet-to-wallet for the same user only.
- Switch from Mercuryo to a backup provider (post-Beta): provider abstraction in
lib/providers/types.tsallows swappingMercuryoProviderfor a fallback without UI change.
Edge cases & failure modes
- Mercuryo declines the card: our transaction stays
failed, no funds moved, user sees the decline reason from the widget callback. - Mercuryo KYC rejects the user: we surface the rejection but keep our own KYC state untouched (the two are independent).
- Webhook signature mismatch: rejected, alert raised — see ../07-Non-Functional#Security.
- Duplicate webhook delivery: idempotency on
merchant_transaction_idprevents double-credit. - Quote expired before confirm: re-quote on submit, show diff before charging.
- Mercuryo settlement to wrong address (operator error on our side seeding the widget): unrecoverable; we lock the address into the signed payload.
- Sell bank account fails reverse-funds check: Mercuryo refunds crypto; our system credits back through a reversal transaction.
- Convert slippage exceeds tolerance: order rejected at quote-execute time, no debit.
- Mercuryo regional outage: provider mode flips to
degraded; UI surfaces "buy/sell unavailable in your region", existing flows already in flight continue to settle.
Acceptance criteria
- Mercuryo widget seeded with HMAC-signed payload; tampering refused.
- Reconciliation key (
merchant_transaction_id) written on every order and matched on every webhook. - Fee on top of Mercuryo's spread is the platform's
buy_external/ sell / convert tier rate (see 04-Admin-Fees). - Webhooks verify
MERCURYO_WEBHOOK_SECRETsignature before any state mutation. - Convert acquires per-(account, asset) locks in a deterministic order.
-
audit_logrow on every state transition, with Mercuryo order id inmetadata. - Region/asset support fetched from Mercuryo at runtime — UI hides unsupported pairs rather than failing at submit.
Out of scope
- In-app spot orderbook trading
- Mercuryo loyalty / rewards integration
- Lightning rails via Mercuryo
- Custodial yield products
Drawbacks
- Vendor dependency: Mercuryo outage = no buy/sell/convert. Backup provider is a Beta+ effort.
- Two KYC layers: our KYC + Mercuryo's KYC. Friction for users; we can't reuse their KYC artifacts cleanly because of data-sharing constraints. ../09-Open Questions#KYC vendor
- Pricing transparency: Mercuryo's spread is bundled; users may perceive the all-in price as our fee.
- Sell fiat cadence: Mercuryo settlement is T+0 to T+2 depending on region/rail; UX must not promise instant.
- Convert deadlock risk: two-asset locks must always be ordered. Catalogued in ../08-Risks and Drawbacks#Domains.
- All currently mocked:
MockAcquirer, mock convert inMockOracle. See ../05-External Providers.
Open questions
- ✅ ../09-Open Questions#Acquirer — resolved: Mercuryo is the contracted acquirer + on/off-ramp partner.
- ../09-Open Questions#Sell payout rail — does Mercuryo cover all our launch markets, or do we still need SEPA/Modulr fallback?
- ../09-Open Questions#Tax & regulatory — Mercuryo is MiCA-regulated; whether we pass-through or co-CASP matters for licensing
- ✅ ../09-Open Questions#Oracle — resolved: CEX.IO REST API for invoice rate-freeze (Mercuryo quotes still used for trade execution); revisit at GA.
Implementing surfaces
- UI (currently mocked acquirer, Mercuryo cutover Beta):
- Buy:
app/my/buy/page.tsx,app/my/buy/[asset]/page.tsx,app/my/buy-sell/page.tsx - Sell:
app/my/sell/page.tsx,app/my/sell/[asset]/page.tsx - Convert:
app/my/convert/page.tsx
- Buy:
- Provider interface:
lib/providers/types.ts—AcquirerProvider,OracleProvider(today:MockAcquirer,MockOracle) - Future:
lib/providers/mercuryo/*— buy/sell/convert + webhook verifier - Webhooks:
/api/webhooks/mercuryo(Beta deliverable) - Fee resolution:
buy_externaland per-op tiers — see 04-Admin-Fees - Schema:
transactions(typesbuy,sell,convert),external_orders(Mercuryo order ids + reconciliation),webhook_events - Domain doc: ../03-Domains#Buy, ../03-Domains#Sell, ../03-Domains#Convert
- Provider matrix: ../05-External Providers