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.
7.2 KiB
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. SeeNIP.md→ Profile Metadata Extensions. Nostr x-only keys are accepted too (normalised with an02prefix, matching coco's owngetPublicKeyHex).
Flow
- Seller advertises
cashu_escrow(set when they touch their Cashu wallet). - 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)→ payrequestvia NWC/WebLN/Cashu meltmintProofs(amount, quote, { p2pk: { pubkey, locktime, refundKeys:[buyer] } })- encode → the
cashu_tokenon the31872confirmation 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.
- Seller sees the escrowed token on the request and redeems (
wallet.receive). - 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 injectablecreateEscrowTokenmint 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—31872request carriescashu_token.NIP.md— profile field + protocol notes.