user-stories

US-05 — Buy / Sell / Convert via Mercuryo

docs/user-stories/05-Mercuryo-Buy-Sell-Convert.mdtype: user-storyupdated: 2026-05-03

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 userindividual 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

  1. User goes to /buy, picks asset → /buy/[asset].
  2. User enters fiat amount (e.g., 100 EUR).
  3. We compute the gross quote: Mercuryo's posted price × amount, plus our buy_external fee tier (see ../03-Domains#Fees).
  4. 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 = our transactions.id for reconciliation.
    • HMAC signature so the widget refuses tampering.
  5. User completes 3DS + Mercuryo's KYC step (one-time per user) inside the widget.
  6. Mercuryo settles the crypto to our user's address.
  7. Our deposit indexer credits the wallet; transaction row is reconciled from pendingcompleted against the merchant_transaction_id.
  8. Mercuryo webhook hits /api/webhooks/mercuryo → we mark the order final + write audit_log.

Sell — happy path

  1. User goes to /sell, picks asset → /sell/[asset], enters crypto amount.
  2. We quote: Mercuryo's bid price × amount, minus our platform fee.
  3. User picks a verified payout bank account.
  4. 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.
  5. User sees transactions.status='pending' with a "fiat egress in progress" badge.
  6. Mercuryo webhook on fiat-leg completion → flips status to completed and emits sell.completed email + webhook.

Today MVP: status sticks at pending because no fiat rail is wired (../09-Open Questions#Sell payout rail). Mercuryo cutover unblocks this end-to-end.

Convert — happy path

  1. User goes to /convert, picks from asset, to asset, enters amount.
  2. We request a Mercuryo swap quote (from=ETH, to=SOL, amount=0.1).
  3. We render gross/net rate, slippage tolerance, our convert-tier fee.
  4. User confirms within the quote TTL (e.g., 30 s).
  5. We:
    • Acquire advisory locks on (account, ETH) then (account, SOL)always in this order to avoid deadlock (see ../08-Risks and Drawbacks#Domains).
    • Move from crypto to Mercuryo's deposit address (debit).
    • Receive to crypto into the user's to address (credit) on Mercuryo's settlement.
  6. Both legs land in transactions with type='convert' linked by a single convert_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.ts allows swapping MercuryoProvider for 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_id prevents 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_SECRET signature before any state mutation.
  • Convert acquires per-(account, asset) locks in a deterministic order.
  • audit_log row on every state transition, with Mercuryo order id in metadata.
  • 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 in MockOracle. See ../05-External Providers.

Open questions

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
  • Provider interface: lib/providers/types.tsAcquirerProvider, OracleProvider (today: MockAcquirer, MockOracle)
  • Future: lib/providers/mercuryo/* — buy/sell/convert + webhook verifier
  • Webhooks: /api/webhooks/mercuryo (Beta deliverable)
  • Fee resolution: buy_external and per-op tiers — see 04-Admin-Fees
  • Schema: transactions (types buy, 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