7.8 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.
Optional 2-of-3 primitive
escrowP2PKOptions now accepts an optional arbiterPubkey and can express the
NUT-11 policy used by a future 2-of-3 escrow:
- normal path: seller + arbiter,
n_sigs=2; - refund path: buyer + arbiter,
n_sigs_refund=2; - no arbiter: the current 1-of-1 seller/buyer policy.
The primitive is unit-tested, but no UI or default flow uses it yet. There is no arbiter policy, bond, dispute process or appeal path, so exposing it would add friction and false confidence. Do not enable it for real money until those are defined and the flow is verified on-device.
Status
- ✅ Core (
src/lib/cashuEscrow.ts, unit-tested): pubkey normalisation,escrowP2PKOptions(1-of-1, optional 2-of-3), 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.