Skip to content

Withdrawal flow — endpoint + dashboard UI (the managed-wallet escape hatch) #147

@0xdevcollins

Description

@0xdevcollins

Why

Approach A (auto-provisioned managed Stellar wallet at signup, PR #136) is the right onboarding model for businesses. But without a withdrawal path it's a roach motel: merchants can take payments but can't move the USDC to a wallet they control.

The "managed by default, self-custody when you want" marketing claim falls apart without this. It's the honest-custody lever that makes Approach A defensible.

This is also flagged as a known gap in `apps/api/docs/architecture/merchant-settlement-onboarding.md`.

Scope

Three deliverables — keep them in one PR if possible:

  1. `POST /v1/merchants/me/settlement/withdraw` — backend endpoint
  2. Withdrawal modal on the dashboard `/settings` settlement card
  3. Audit log row for every withdrawal (out-of-band paper trail)

Backend

Endpoint

```http
POST /v1/merchants/me/settlement/withdraw
Authorization: Bearer
Content-Type: application/json

{
"destinationAddress": "G…", // valid Stellar G-strkey
"amount": "100.50", // string decimal; "all" to drain
"asset": "USDC" // only USDC in v1
}
```

Behavior

  1. Validate `destinationAddress` is a valid Stellar G-strkey (use `StrKey.isValidEd25519PublicKey`)
  2. Load `MerchantSettlementKey` row for the authenticated merchant; decrypt the seed via `MerchantSettlementService.decryptSeed` (already implemented in PR Phase 1: hosted checkout, CCTP V2 cutover, managed Stellar settlement #136)
  3. Load the managed account on Horizon → read USDC balance
  4. If `amount === "all"`, withdraw `balance` (the merchant can't drain XLM reserves — only USDC)
  5. Validate `amount <= balance` and `amount > 0`
  6. Refuse if destinationAddress has no USDC trustline: pre-flight check on Horizon. Surface a clear error: "The destination address must have a USDC trustline. Help →"
  7. Build + sign + submit a Stellar payment op (USDC, source = managed account, destination = destinationAddress)
  8. Write a `SettlementWithdrawal` audit row: `{ merchantId, amount, destinationAddress, stellarTxHash, status: 'submitted', createdAt }` (new table — add migration)
  9. Return `{ stellarTxHash, amount, asset, destinationAddress, submittedAt }`

Rate limiting

3 withdrawals per merchant per hour. Use `@nestjs/throttler` with a named bucket (`withdraw`).

Tests

  • Happy path returns `stellarTxHash`
  • Missing trustline returns 422 with clear error
  • Amount > balance returns 400
  • Invalid destination returns 400
  • Rate limit hit returns 429
  • Audit row exists after successful withdrawal

Frontend

Where it lives

`apps/dashboard/src/app/(dashboard)/settings/page.tsx` — extend the existing "Settlement wallet" card from PR 7.9b.

UI

In the "Settlement active" state, add below the address:

```
Balance: 124.50 USDC (refreshes every 30s)
[Withdraw to my wallet]
```

Button opens a modal:

```
Withdraw USDC

Destination Stellar address [G____________________]
↳ Must have a USDC trustline. How to add one →

Amount [____________] USDC [Max]
Available: 124.50 USDC

[Cancel] [Send 100.00 USDC]
```

Behavior

  • Address field validates strkey shape client-side (56 chars, starts with G)
  • "Max" button fills the amount with current balance
  • Submit POSTs to the endpoint, shows the resulting `stellarTxHash` with a "View on Stellar.Expert" link in a success toast
  • On error, show the API `error.message` inline above the form

Balance polling

Add `GET /v1/merchants/me/settlement/balance` → returns `{ asset: "USDC", balance: "124.50", lastFetchedAt }` (reads Horizon, caches for 30s in Redis to avoid hammering Horizon)

Files to read first

  • `apps/api/src/modules/merchant/merchant-settlement.service.ts` — already has `decryptSeed`
  • `apps/api/src/modules/merchant/merchant.controller.ts` — add the new endpoint here
  • `apps/api/src/modules/stellar/stellar.service.ts` — has the Horizon client + tx submission helpers
  • `apps/dashboard/src/app/(dashboard)/settings/page.tsx` — existing settlement card
  • `apps/dashboard/src/hooks/useSettings.ts` — add `useWithdrawSettlement` + `useSettlementBalance` here

Acceptance criteria

  • `POST /v1/merchants/me/settlement/withdraw` works on testnet (verified by issue PR 10 — E2E crypto smoke: Base Sepolia → Stellar testnet round-trip + runbook #146's smoke runbook)
  • `GET /v1/merchants/me/settlement/balance` returns the live USDC balance
  • Trustline pre-flight rejects withdrawals to addresses without a USDC trustline
  • Audit row written for every withdrawal (`SettlementWithdrawal` table)
  • Rate limit: 3/hour per merchant
  • Dashboard "Withdraw to my wallet" button shows in the settlement card
  • Modal validates strkey + amount client-side
  • Success toast links to stellar.expert
  • Tsc clean, jest green, manual testnet withdrawal verified end-to-end

Out of scope (future work)

  • Recurring/scheduled withdrawals (cron-style)
  • Multi-asset withdrawals (only USDC for v1)
  • Withdrawals to EVM chains via CCTP (would require a reverse-bridge flow; defer)
  • Per-merchant withdrawal limits set by KYB tier (defer)
  • 2FA on withdrawal (defer — flag in the issue for follow-up once we have TOTP support)

Estimated effort

~1 day (full-stack), assuming familiarity with the existing Stellar service helpers.

Metadata

Metadata

Assignees

No one assigned

    Labels

    backendBackend API workdashboardMerchant Dashboard productenhancementNew feature or requestfrontendFrontend/UI workstellarStellar blockchain integration

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions