SkateSpots/ESCROW.md
SkateSpots 0956b1368a docs(escrow): exact Android test-build steps for on-device verification
Add 'Producing a test build' (android:init -> android:sync -> assembleDebug ->
adb install) to the ESCROW.md checklist, plus the two-account requirement. The
mint-touching paths still need a real device run.
2026-09-26 23:19:47 +07:00

7.2 KiB
Raw Permalink Blame History

Cashu Escrow (marketplace)

Status: core implemented & unit-tested; wallet UI is native-only and awaits on-device verification. See "Status" at the bottom before trusting it with real money.

Goal

Let a buyer and seller close a marketplace deal where the buyer's funds are committed but recoverable, so neither side has to trust the other blindly — without a custodial third party and without an always-online oracle.

Model — time-locked P2PK

Cashu P2PK (NUT-11) supports a locktime and refund keys. We use exactly that:

buyer  ──locks funds to──▶  seller pubkey
                            locktime = now + N days
                            refund   = buyer pubkey
  • Seller delivered → redeems with their key (any time).
  • Seller vanished / deal fell through → after locktime, the buyer reclaims with their refund key.

This is real escrow (both sides protected) with no witness/oracle — the only thing "release on attestation" needs is the locktime, which the mint enforces.

Contrast with the naive option (lock to the seller, no expiry): that's prepayment, not escrow — the seller can take the money and never deliver — so we deliberately don't do it.

Keys

Cashu P2PK needs a secp256k1 key the wallet can sign a witness with. The Nostr signer (Amber / NIP-07) can't produce a bare schnorr signature, so we use the Cashu wallet's own keyring (coco.keyring), not the Nostr key.

Each user advertises one escrow pubkey for others to lock to:

  • kind 0 content field cashu_escrow — 33-byte compressed hex (02…), derived from the Cashu keyring. See NIP.md → Profile Metadata Extensions. Nostr x-only keys are accepted too (normalised with an 02 prefix, matching coco's own getPublicKeyHex).

Flow

  1. Seller advertises cashu_escrow (set when they touch their Cashu wallet).
  2. Buyer reads the seller's pubkey, then funds escrow by paying the mint's Lightning invoice and mints fresh proofs locked to the seller:
    • createMintQuote(amount) → pay request via NWC/WebLN/Cashu melt
    • mintProofs(amount, quote, { p2pk: { pubkey, locktime, refundKeys:[buyer] } })
    • encode → the cashu_token on the 31872 confirmation request.
    • Funding by mint (not by spending the coco balance) keeps this independent of the coco wallet's proof state — no state bridging, no counter desync.
  3. Seller sees the escrowed token on the request and redeems (wallet.receive).
  4. If not delivered by locktime, the buyer refunds.

Protocol

The escrow token rides the Attestations NIP's existing optional ["cashu_token", "<token>"] tag on the kind 31872 request (NIP.md → Trade Attestations). The lock is what makes publishing it safe: a plain (unlocked) token in a public event is spendable by anyone who sees it.

Threat model / limitations

  • Mint trust: P2PK keeps the mint from spending your funds, but you still trust the mint to be solvent and redeemable. Use a reputable mint.
  • No delivery proof on-chain: "delivered" is the seller's word; the buyer's protection is the locktime refund, and the marketplace reputation system (31871) records the outcome.
  • Locktime is unilateral: the buyer can always refund after the deadline even if the seller did deliver but was slow to redeem — so redemption should happen promptly, and lock windows should be generous.
  • Native only: the app's Cashu wallet is Capacitor-only, so escrow is too.

Status

  • ✅ Core (src/lib/cashuEscrow.ts, unit-tested): pubkey normalisation, escrowP2PKOptions, offline token inspection/parseEscrowToken, locktime/refund policy, and the injectable createEscrowToken mint flow.
  • 🟡 Wallet UI (native-only): implement → verify with the On-device test checklist below before treating as production. Do not use real money until every box passes.

On-device test checklist (Android)

Escrow is native-only and its mint paths are unverified — run this on a real device with tiny amounts before trusting it. Check every box; anything that fails is a bug to fix (or a mint that doesn't support P2PK — note which).

Producing a test build

# First time only — generates android/ (gitignored)
npm run android:init

# Build the web assets + sync into the native project
npm run android:sync

# Debug APK (debug keystore — no release secrets needed)
cd android && ./gradlew assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apk

To test both sides you need two accounts — easiest is one device (this build) plus one other client/device with a different key.

Preconditions

  • Latest Android build installed (npm run android:build), logged in.
  • Cashu wallet initialised and a reachable, trusted mint added (Wallet).
  • A funded NWC connection (Wallet → NWC) to pay the mint invoice.
  • Two accounts (seller + buyer) on two devices/profiles.
  • First pass: 10 sats, 1‑day lock.

1. Seller advertises the escrow key

  • Wallet → Escrow → Publish escrow key → toast "Escrow key published".
  • Badge flips to "Escrow key active"; persists after reload.
  • The kind 0 event on a relay contains cashu_escrow (66 hex chars, 02…).

2. Buyer creates escrow

  • Buyer sees Escrow & request on the seller's listing (native only).
  • Enter 10 sats / 1 day → Lock & request.
  • NWC pays the mint invoice; toast "Escrow created".
  • Buyer balance drops by ~10 sats + mint/Lightning fees.

3. Token looks right

  • DM thread shows the escrow panel: "Escrowed 10 sats", mint host, refund date ≈ +1 day.
  • Buyer can still retrieve the token (needed for the refund test).

4. Seller redeems on delivery

  • Seller's DM shows Confirm this sale? + panel + Redeem escrow.
  • Redeem → toast "Escrow redeemed"; seller balance rises ~10 sats.
  • Confirm sale publishes the 31871; buyer then sees Confirm this trade.
  • Buyer confirms → both profiles show 1 verified trade (trust-weighted).

5. Refund path (seller never delivers)

  • New escrow, 1‑day lock; seller does not redeem.
  • Before the deadline: no "Refund escrow" on the buyer's card (expected).
  • After the deadline: Refund escrow appears → tap → balance returns.
  • A second redeem of the same token fails cleanly (already spent).

6. Guard rails

  • Web build: escrow action hidden; panel is read-only if a token exists.
  • No NWC/WebLN → "No Lightning wallet available…" on lock.
  • No mint → "No Cashu mint available…".
  • Seller hasn't published cashu_escrow → buyer only sees the plain request.

Watch for

  • Mint rejects P2PK (some disable it) → creation errors; record the mint.
  • coco can't redeem P2PK (witness signing) → redeem/refund errors.
  • Clock skew — keep lock windows generous.

Files

  • src/lib/cashuEscrow.ts (+ .test.ts) — pure escrow policy & inspection.
  • src/hooks/wallet/useCashu.ts — Cashu wallet (native-only), keyring source.
  • src/hooks/useTradeAttestations.ts — 31872 request carries cashu_token.
  • NIP.md — profile field + protocol notes.