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.
160 lines
7.2 KiB
Markdown
160 lines
7.2 KiB
Markdown
# 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
|
||
```bash
|
||
# 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.
|