Skip to content

PR 7.9c — Passkey-derived self-custody settlement wallet (Approach B) #150

@0xdevcollins

Description

@0xdevcollins

Why

PR #136 shipped Approach A: Useroutr auto-provisions a managed Stellar wallet at signup, holds the seed encrypted under `SETTLEMENT_KEY_KEK`. This is the right onboarding model (zero friction, matches Stripe-style UX), but it's custodial-flavored.

The plan doc at `apps/api/docs/architecture/merchant-settlement-onboarding.md` proposes Approach B as the medium-term upgrade: a passkey-derived wallet where the merchant's WebAuthn credential signs Stellar transactions and Useroutr never sees the seed.

This is what unwinds the "we manage your keys" caveat and lets the marketing claim graduate from "managed by default, self-custody when you want" to "self-custody by default, managed as fallback."

Reading first

Scope

Three deliverables:

  1. Schema + service — extend `MerchantSettlementKey` to support `managed=false` rows with a `passkeyCredentialId` instead of an `encryptedSeed`
  2. Onboarding flow — UI on dashboard `/settings` to upgrade from managed → passkey
  3. Signing path — every place that currently decrypts the managed seed needs a fork that asks the passkey to sign instead

Design sketch (high-level)

Provisioning

  1. Merchant clicks "Upgrade to passkey wallet" in settings
  2. Browser prompts WebAuthn (Touch ID / Face ID / Windows Hello / YubiKey)
  3. Backend gets the public key + credential ID; uses Passkey Kit to deploy a Soroban smart wallet controlled by that credential
  4. Mark the new `MerchantSettlementKey` row with `managed=false`, `passkeyCredentialId`, `smartWalletAddress`
  5. (If migrating from a managed wallet) Move the USDC balance from the old managed account → new smart wallet, then delete the encrypted seed
  6. Old `Merchant.settlementAddress` mirror updates to the new smart-wallet address

Signing

Every place the current code calls `MerchantSettlementService.decryptSeed(row)` to sign needs a branch:

```ts
if (row.managed) {
// existing flow: decrypt + sign server-side
} else {
// new flow: build the tx, return XDR to the frontend,
// have the merchant sign with their passkey, return signed XDR
}
```

The only place this matters today is withdrawal (#147) — every other "settlement" interaction (receiving USDC from a CCTP V2 mint, viewing balance, etc.) doesn't need the seed at all. So passkey signing is gated on the withdrawal flow.

Withdrawal flow with passkey

  1. Merchant clicks "Withdraw" → fills the modal
  2. Frontend POSTs `/v1/merchants/me/settlement/withdraw/prepare` → backend builds the unsigned XDR
  3. Frontend prompts passkey + signs the XDR (using Passkey Kit's signing helper)
  4. Frontend POSTs `/v1/merchants/me/settlement/withdraw/submit { signedXdr }` → backend submits to Horizon
  5. Same response shape as the managed flow

Risks + mitigations

Risk Mitigation
Merchant loses every device with the passkey → wallet is gone Passkey Kit's "secondary device" pattern. Add a `POST /v1/merchants/me/settlement/passkey/add-device` flow.
WebAuthn browser support varies (Safari iOS, older Android) Detect support before showing the upgrade CTA. Fall back to "stay on managed."
Soroban smart wallet has its own audit / deployment cost Passkey Kit handles deployment. Reserves on mainnet are still ~3 XLM (we sponsor).
Migration from managed → passkey loses funds if anything goes wrong Do the migration as a single atomic submit: payment op from managed → new wallet, signed by the still-existing managed seed. If it fails, both wallets exist; merchant can retry. Add a confirmation gate before the migration.

Out of scope

  • Recovery via social login / email (defer; rely on multi-device passkey first)
  • Multi-sig / threshold wallets (defer; passkey + secondary device is enough for v1)
  • Mainnet rollout (build on testnet first; flip mainnet per-merchant after smoke tests)

Acceptance criteria

  • Merchant can click "Upgrade to passkey wallet" in settings → goes through WebAuthn → Soroban smart wallet deployed → `MerchantSettlementKey` updated with `managed=false`
  • Balance from old managed wallet migrated to new smart wallet
  • Encrypted seed deleted from the old row (or the row deleted entirely; either works)
  • Withdrawal flow works with a passkey-signed tx (two-step prepare → submit)
  • Receiving CCTP V2 mints still works (no signing needed on the destination side; only the address matters)
  • Marketing copy in `apps/www` updates from "managed by default" to honest reflection: "Self-custody by default via passkey; managed fallback if your browser doesn't support WebAuthn or you opt out"
  • Runbook at `apps/api/docs/operations/passkey-settlement.md` covers: new-merchant flow, existing-merchant upgrade flow, lost-device recovery, debugging signing failures

Estimated effort

~3–5 days. The Passkey Kit integration is the unknown — could be smoother than expected if it ships good React/wagmi-like helpers, or rougher if we need to build wrappers.

Reading material for whoever picks this up

  • Passkey Kit demo: https://passkey-kit-demo.fly.dev/
  • Talk from Meridian 2024 on passkey wallets: search "Meridian passkey wallet stellar"
  • The Useroutr settlement onboarding plan: `apps/api/docs/architecture/merchant-settlement-onboarding.md`

Metadata

Metadata

Assignees

No one assigned

    Labels

    backendBackend API workenhancementNew feature or requestfrontendFrontend/UI worksecuritySecurity fix or hardeningstellarStellar blockchain integration

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions