feat(marketplace): Cashu escrow core (time-locked P2PK) + design
Real, oracle-free escrow built on Cashu P2PK with a locktime + refund key:
buyer locks funds to the seller; seller redeems on delivery; the buyer can
reclaim after the deadline. Locked (not plain) tokens are what make the
cashu_token on a public 31872 safe.
- src/lib/cashuEscrow.ts: pubkey normalisation (x-only -> compressed), P2PK
policy builder, offline token inspection (amount/mint/locktime/refund), and
an injectable createEscrowToken mint flow (quote -> pay -> mintProofs{p2pk})
- 15 unit tests incl. the mint orchestration against a fake wallet
- Protocol: advertise a 'cashu_escrow' pubkey in kind 0 (NIP.md); token rides
the existing 31872 cashu_token tag
- ESCROW.md: design, threat model, status, limitations
- AGENTS.md: project map + docs index updated
Wallet UI (cashu-ts + payment rail + profile publish) is native-only and still
needs on-device verification.
This commit is contained in:
parent
8255d9d5e8
commit
26e951710e
5 changed files with 621 additions and 19 deletions
|
|
@ -46,6 +46,11 @@ session with **no other memory of this project**. So:
|
|||
- **Marketplace**: `GearSwapPage` + `MarketplaceItemCard`. Buyer/seller trade
|
||||
attestations build mutual, WoT-weighted reputation. **No payments are
|
||||
implemented** — "Buy" is intentionally absent; only Contact/Offer/Trade.
|
||||
- **Cashu escrow** (marketplace): time-locked **P2PK** (lock to seller + buyer
|
||||
refund key + locktime) — see **`ESCROW.md`** and `src/lib/cashuEscrow.ts`
|
||||
(unit-tested). Protocol: `cashu_token` on the `31872`; each user advertises a
|
||||
`cashu_escrow` pubkey in kind 0. **Native-only**; wallet UI still needs
|
||||
on-device verification.
|
||||
- **Wallet**: `useCashu` (coco-cashu) is **native-only** — it returns early on
|
||||
web (mints are CORS-blocked). Zap rails are NWC + WebLN + Cashu melt (`useZaps`).
|
||||
- **Group DMs**: NIP-17 (`useDirectMessages`, `GroupConversationPage`).
|
||||
|
|
@ -166,7 +171,7 @@ Rules:
|
|||
- `/src/contexts/`: React context providers (AppContext, LanguageContext, NWCContext)
|
||||
- `NIP.md`: **source of truth** for every custom/adopted Nostr kind and its schema
|
||||
- `AGENTS.md` (this file): the only cross-session memory — keep it current
|
||||
- `UX_AUDIT.md`, `GROUP_MESSAGING.md`: feature decision/backlog docs
|
||||
- `UX_AUDIT.md`, `GROUP_MESSAGING.md`, `ESCROW.md`: feature decision/backlog docs
|
||||
- `/src/test/`: Testing utilities including TestApp component
|
||||
- `/public/`: Static assets
|
||||
- `App.tsx`: Main app component with provider setup (**CRITICAL**: this file is **already configured** with `QueryClientProvider`, `NostrProvider`, `UnheadProvider` and other important providers - **read this file before making changes**. Changes are usually not necessary unless adding new providers. Changing this file may break the application)
|
||||
|
|
|
|||
93
ESCROW.md
Normal file
93
ESCROW.md
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# 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): needs a real mint + Android device to verify;
|
||||
do not treat as production until exercised end-to-end on-device.
|
||||
|
||||
## 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.
|
||||
34
NIP.md
34
NIP.md
|
|
@ -15,6 +15,7 @@ other clients, and preserved on edit:
|
|||
| `bitcoin_silent_payment` | string | Bitcoin Silent Payment address (BIP-352, `sp1…`) — a reusable, private on-chain address. |
|
||||
| `sponsors` | string | Freeform highlight of the skater's sponsors / supporters (brands, shops, people). |
|
||||
| `cordn` | string | A Cordn (cordn.net) contact URL for private, end-to-end encrypted messaging via a self-hosted coordinator. |
|
||||
| `cashu_escrow` | string | Cashu escrow pubkey (33-byte compressed secp256k1 hex, `02…`) used as the P2PK lock target for marketplace escrow. Derived from the Cashu wallet keyring. See `ESCROW.md`. |
|
||||
|
||||
`lud16` (Lightning address) is standard NIP-01 but is also editable directly in
|
||||
the SkateSpots profile form for convenience.
|
||||
|
|
@ -3164,27 +3165,24 @@ answered (they have a `31871` for that listing).
|
|||
- Consumers **trust-weight counterparties** with the existing web-of-trust score
|
||||
(as with Scene vouches) so fresh-account rings cannot inflate reputation.
|
||||
|
||||
## Escrow (planned — not implemented)
|
||||
## Escrow (Cashu `cashu_token`)
|
||||
|
||||
The NIP allows a `31872` to carry a `["cashu_token", "<token>"]`: a Cashu token
|
||||
locked to spending conditions (P2PK/HTLC) so funds release when the requested
|
||||
attestation is observed. **We do not implement this yet**, and the client accepts
|
||||
an optional `cashu_token` only as a passthrough (no locking). Fully shipping it
|
||||
needs:
|
||||
A `31872` may carry `["cashu_token", "<token>"]`. We use Cashu **P2PK with a
|
||||
locktime and a refund key**, funded by paying the mint's Lightning invoice:
|
||||
|
||||
1. **Locked tokens** — `@cashu/cashu-ts` can create P2PK/HTLC outputs, but our
|
||||
wallet (`coco-cashu-core`) only exposes plain `send()`. Escrow would bypass
|
||||
coco and use `cashu-ts` directly with the same seed.
|
||||
2. **Native only** — the bundled Cashu wallet is gated to Capacitor builds
|
||||
(`useCashu` returns early on web, where mints are CORS-blocked), so an escrow
|
||||
UI can only run in the app.
|
||||
3. **A release witness** — Cashu conditions lock to a key or a hash preimage, not
|
||||
to "a Nostr event was seen". Releasing *on attestation* requires a trusted
|
||||
witness/oracle that watches for the `31871` and releases. Without one, a token
|
||||
locked to the seller's key is prepayment, not escrow.
|
||||
- buyer locks funds to the **seller's** escrow pubkey (`cashu_escrow` on the
|
||||
seller's profile), with `locktime = now + N days` and the **buyer** as the
|
||||
refund key;
|
||||
- seller delivers → redeems; if not, the buyer refunds after the locktime.
|
||||
|
||||
Until all three exist, escrow stays off to avoid a payment flow that looks
|
||||
trustless but isn't.
|
||||
This is real escrow (no oracle needed) because the mint enforces the locktime —
|
||||
locking to the seller *without* an expiry would be prepayment, not escrow, so we
|
||||
don't. A plain (unlocked) token must **never** be put in a public event: anyone
|
||||
who sees it can spend it; the lock is what makes publishing safe.
|
||||
|
||||
Full design, threat model and status: **`ESCROW.md`**. Core logic lives in
|
||||
`src/lib/cashuEscrow.ts` (unit-tested). Escrow is **native-only** (the Cashu
|
||||
wallet is Capacitor-gated).
|
||||
|
||||
## Future (from the NIP)
|
||||
|
||||
|
|
|
|||
221
src/lib/cashuEscrow.test.ts
Normal file
221
src/lib/cashuEscrow.test.ts
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { getEncodedToken, type Proof } from '@cashu/cashu-ts';
|
||||
import {
|
||||
normalizeCashuPubkey,
|
||||
escrowLocktime,
|
||||
escrowP2PKOptions,
|
||||
secretPolicy,
|
||||
parseEscrowToken,
|
||||
createEscrowToken,
|
||||
type EscrowP2PKOptions,
|
||||
SECONDS_PER_DAY,
|
||||
ESCROW_MIN_LOCK_DAYS,
|
||||
} from './cashuEscrow';
|
||||
|
||||
const SELLER_XONLY = 'ab'.repeat(32);
|
||||
const SELLER = `02${SELLER_XONLY}`;
|
||||
const BUYER = `02${'cd'.repeat(32)}`;
|
||||
|
||||
function toHex(text: string): string {
|
||||
return [...new TextEncoder().encode(text)]
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
function p2pkSecret(overrides: Record<string, string> = {}): string {
|
||||
return toHex(
|
||||
JSON.stringify({
|
||||
nonce: '00'.repeat(8),
|
||||
data: SELLER,
|
||||
tags: [
|
||||
['sigflag', 'SIG_INPUTS'],
|
||||
['n_sigs', '1'],
|
||||
['locktime', String(overrides.locktime ?? '4102444800')],
|
||||
['refund', BUYER],
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function token(secret: string, amount: number): string {
|
||||
return getEncodedToken({
|
||||
mint: 'https://mint.example',
|
||||
unit: 'sat',
|
||||
proofs: [{ id: '009a1f293253e41e', amount, secret, C: `02${'00'.repeat(32)}` }],
|
||||
});
|
||||
}
|
||||
|
||||
function lockedProofs(options: EscrowP2PKOptions, amount = 1000): Proof[] {
|
||||
const secret = toHex(
|
||||
JSON.stringify({
|
||||
nonce: '00'.repeat(8),
|
||||
data: options.pubkey,
|
||||
tags: [
|
||||
['sigflag', 'SIG_INPUTS'],
|
||||
['n_sigs', String(options.requiredSignatures)],
|
||||
['locktime', String(options.locktime)],
|
||||
['refund', ...options.refundKeys],
|
||||
],
|
||||
}),
|
||||
);
|
||||
return [{ id: '009a1f293253e41e', amount, secret, C: `02${'00'.repeat(32)}` }];
|
||||
}
|
||||
|
||||
describe('normalizeCashuPubkey', () => {
|
||||
it('prefixes 32-byte x-only keys with 02', () => {
|
||||
expect(normalizeCashuPubkey(SELLER_XONLY)).toBe(SELLER);
|
||||
});
|
||||
|
||||
it('leaves compressed keys unchanged', () => {
|
||||
expect(normalizeCashuPubkey(SELLER)).toBe(SELLER);
|
||||
});
|
||||
|
||||
it('rejects non-hex and wrong-length input', () => {
|
||||
expect(normalizeCashuPubkey('nope')).toBeNull();
|
||||
expect(normalizeCashuPubkey('1234')).toBeNull();
|
||||
expect(normalizeCashuPubkey(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('escrowLocktime', () => {
|
||||
it('adds the requested number of days', () => {
|
||||
const from = 1_000_000_000_000; // ms
|
||||
expect(escrowLocktime(3, from)).toBe(Math.floor(from / 1000) + 3 * SECONDS_PER_DAY);
|
||||
});
|
||||
|
||||
it('enforces a minimum lock of one day', () => {
|
||||
const from = 0;
|
||||
expect(escrowLocktime(0, from)).toBe((from / 1000) + ESCROW_MIN_LOCK_DAYS * SECONDS_PER_DAY);
|
||||
});
|
||||
});
|
||||
|
||||
describe('secretPolicy', () => {
|
||||
it('parses a P2PK secret into a policy', () => {
|
||||
const policy = secretPolicy(p2pkSecret());
|
||||
expect(policy?.pubkey).toBe(SELLER);
|
||||
expect(policy?.refundKeys).toContain(BUYER);
|
||||
expect(policy?.locktime).toBe(4102444800);
|
||||
expect(policy?.requiredSignatures).toBe(1);
|
||||
});
|
||||
|
||||
it('returns null for non-locked secrets', () => {
|
||||
expect(secretPolicy('00'.repeat(32))).toBeNull();
|
||||
expect(secretPolicy(toHex('not json'))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('escrowP2PKOptions', () => {
|
||||
it('locks to the seller with the buyer as refund key', () => {
|
||||
const opts = escrowP2PKOptions({
|
||||
sellerPubkey: SELLER_XONLY,
|
||||
buyerPubkey: BUYER,
|
||||
lockDays: 3,
|
||||
from: 1_000_000_000_000,
|
||||
});
|
||||
expect(opts.pubkey).toBe(SELLER);
|
||||
expect(opts.refundKeys).toEqual([BUYER]);
|
||||
expect(opts.locktime).toBe(Math.floor(1_000_000_000_000 / 1000) + 3 * SECONDS_PER_DAY);
|
||||
expect(opts.requiredRefundSignatures).toBe(1);
|
||||
});
|
||||
|
||||
it('rejects invalid keys', () => {
|
||||
expect(() => escrowP2PKOptions({ sellerPubkey: 'bad', buyerPubkey: BUYER })).toThrow();
|
||||
expect(() => escrowP2PKOptions({ sellerPubkey: SELLER, buyerPubkey: '' })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseEscrowToken', () => {
|
||||
it('reads amount, mint and lock policy', () => {
|
||||
const info = parseEscrowToken(token(p2pkSecret(), 2100));
|
||||
expect(info.amount).toBe(2100);
|
||||
expect(info.mint).toBe('https://mint.example');
|
||||
expect(info.locked).toBe(true);
|
||||
expect(info.policy?.locktime).toBe(4102444800);
|
||||
expect(info.refundable).toBe(false);
|
||||
});
|
||||
|
||||
it('marks past locktimes as refundable', () => {
|
||||
const info = parseEscrowToken(token(p2pkSecret({ locktime: '1' }), 21));
|
||||
expect(info.refundable).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a plain token as unlocked', () => {
|
||||
const plain = getEncodedToken({
|
||||
mint: 'https://mint.example',
|
||||
unit: 'sat',
|
||||
proofs: [{ id: '009a1f293253e41e', amount: 5, secret: '00'.repeat(32), C: `02${'00'.repeat(32)}` }],
|
||||
});
|
||||
const info = parseEscrowToken(plain);
|
||||
expect(info.locked).toBe(false);
|
||||
expect(info.refundable).toBe(false);
|
||||
});
|
||||
|
||||
it('throws on a malformed token', () => {
|
||||
expect(() => parseEscrowToken('not-a-token')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createEscrowToken', () => {
|
||||
it('pays the invoice, mints locked proofs, and returns a parsable token', async () => {
|
||||
const paid: string[] = [];
|
||||
let seen: EscrowP2PKOptions | null = null;
|
||||
|
||||
const wallet = {
|
||||
async createMintQuote(amount: number) {
|
||||
expect(amount).toBe(1000);
|
||||
return { quote: 'q1', request: 'lnbc1fake' };
|
||||
},
|
||||
async checkMintQuote() {
|
||||
return { state: 'PAID' };
|
||||
},
|
||||
async mintProofs(amount: number, quote: string, options: { p2pk: EscrowP2PKOptions }) {
|
||||
expect(amount).toBe(1000);
|
||||
expect(quote).toBe('q1');
|
||||
seen = options.p2pk;
|
||||
return lockedProofs(options.p2pk);
|
||||
},
|
||||
};
|
||||
|
||||
const result = await createEscrowToken(
|
||||
{ wallet, payInvoice: async (bolt11) => { paid.push(bolt11); }, pollMs: 0 },
|
||||
{
|
||||
mintUrl: 'https://mint.example',
|
||||
amountSats: 1000,
|
||||
sellerPubkey: SELLER_XONLY,
|
||||
buyerPubkey: BUYER,
|
||||
lockDays: 2,
|
||||
},
|
||||
);
|
||||
|
||||
expect(paid).toEqual(['lnbc1fake']);
|
||||
expect(result.amount).toBe(1000);
|
||||
expect(seen).toMatchObject({ pubkey: SELLER, refundKeys: [BUYER] });
|
||||
|
||||
const info = parseEscrowToken(result.token);
|
||||
expect(info.locked).toBe(true);
|
||||
expect(info.policy?.pubkey).toBe(SELLER);
|
||||
expect(info.policy?.refundKeys).toContain(BUYER);
|
||||
expect(info.refundable).toBe(false);
|
||||
});
|
||||
|
||||
it('throws when the invoice is never paid (and never mints)', async () => {
|
||||
const wallet = {
|
||||
async createMintQuote() {
|
||||
return { quote: 'q', request: 'r' };
|
||||
},
|
||||
async checkMintQuote() {
|
||||
return { state: 'UNPAID' };
|
||||
},
|
||||
async mintProofs(): Promise<Proof[]> {
|
||||
throw new Error('should not mint');
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
createEscrowToken(
|
||||
{ wallet, payInvoice: async () => {}, maxWaitMs: 5, pollMs: 1 },
|
||||
{ mintUrl: 'https://mint.example', amountSats: 10, sellerPubkey: SELLER, buyerPubkey: BUYER },
|
||||
),
|
||||
).rejects.toThrow(/never confirmed/);
|
||||
});
|
||||
});
|
||||
285
src/lib/cashuEscrow.ts
Normal file
285
src/lib/cashuEscrow.ts
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
import { getDecodedToken, getEncodedToken, type Proof } from '@cashu/cashu-ts';
|
||||
|
||||
/**
|
||||
* Cashu escrow helpers (time-locked P2PK).
|
||||
*
|
||||
* Marketplace escrow uses Cashu **P2PK with a locktime and refund key**:
|
||||
* the buyer locks funds to the seller's escrow pubkey, and — after the
|
||||
* locktime — the buyer can reclaim them via the refund key. That gives real,
|
||||
* oracle-free escrow:
|
||||
*
|
||||
* • seller delivered → redeems with their key
|
||||
* • seller vanished → buyer refunds after the deadline
|
||||
*
|
||||
* NUT-11 P2PK secrets are a JSON blob hex-encoded into the proof `secret`:
|
||||
* {"nonce":"…","data":"<pubkey>","tags":[["sigflag","SIG_INPUTS"],
|
||||
* ["n_sigs","1"],["locktime","<unix>"],["refund","<pubkey>"]]}
|
||||
*
|
||||
* This module is pure/offline (no mint, no network) so it can be unit-tested
|
||||
* and safely used anywhere. Creating/redeeming lives in the wallet layer.
|
||||
*/
|
||||
|
||||
export const SECONDS_PER_DAY = 86_400;
|
||||
export const ESCROW_MIN_LOCK_DAYS = 1;
|
||||
export const ESCROW_DEFAULT_LOCK_DAYS = 7;
|
||||
|
||||
export interface EscrowPolicy {
|
||||
/** Seller pubkey the funds are locked to (compressed hex). */
|
||||
pubkey: string;
|
||||
/** Unix seconds; at/after this the refund keys may spend. */
|
||||
locktime: number;
|
||||
/** Buyer pubkey(s) allowed to refund after the locktime. */
|
||||
refundKeys: string[];
|
||||
/** Signatures required to spend (seller side). */
|
||||
requiredSignatures: number;
|
||||
/** Signatures required to refund. */
|
||||
requiredRefundSignatures: number;
|
||||
}
|
||||
|
||||
export interface EscrowTokenInfo {
|
||||
mint: string;
|
||||
unit: string;
|
||||
amount: number;
|
||||
/** True when the token carries P2PK/HTLC spending conditions. */
|
||||
locked: boolean;
|
||||
/** Parsed P2PK policy when present. */
|
||||
policy: Partial<EscrowPolicy> | null;
|
||||
/** True when the locktime has passed (the buyer can reclaim). */
|
||||
refundable: boolean;
|
||||
}
|
||||
|
||||
const HEX_RE = /^[0-9a-f]+$/i;
|
||||
|
||||
function isHex(value: string): boolean {
|
||||
return value.length > 0 && value.length % 2 === 0 && HEX_RE.test(value);
|
||||
}
|
||||
|
||||
function hexToUtf8(hex: string): string {
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||
}
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a Cashu P2PK pubkey to 33-byte compressed hex.
|
||||
*
|
||||
* Nostr pubkeys are 32-byte **x-only**; Cashu expects the 33-byte compressed
|
||||
* form with an `02` prefix (matching coco's own `getPublicKeyHex`). Already
|
||||
* compressed keys are returned unchanged. Returns null for anything else.
|
||||
*/
|
||||
export function normalizeCashuPubkey(value: string | undefined): string | null {
|
||||
if (!value) return null;
|
||||
const v = value.trim().toLowerCase();
|
||||
if (!HEX_RE.test(v)) return null;
|
||||
if (v.length === 66 && (v.startsWith('02') || v.startsWith('03'))) return v;
|
||||
if (v.length === 64) return `02${v}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Unix seconds `days` from `from` (defaults to now). */
|
||||
export function escrowLocktime(days: number, from: number = Date.now()): number {
|
||||
const safeDays = Math.max(ESCROW_MIN_LOCK_DAYS, Math.floor(days));
|
||||
return Math.floor(from / 1000) + safeDays * SECONDS_PER_DAY;
|
||||
}
|
||||
|
||||
export interface EscrowP2PKOptions {
|
||||
pubkey: string;
|
||||
locktime: number;
|
||||
refundKeys: string[];
|
||||
requiredSignatures: number;
|
||||
requiredRefundSignatures: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `p2pk` options for a cashu-ts `mintProofs`/`send` call: funds are
|
||||
* locked to the **seller**, and the **buyer** (refund key) may reclaim them once
|
||||
* the locktime passes. Both keys are normalised to compressed hex.
|
||||
*/
|
||||
export function escrowP2PKOptions({
|
||||
sellerPubkey,
|
||||
buyerPubkey,
|
||||
lockDays = ESCROW_DEFAULT_LOCK_DAYS,
|
||||
from = Date.now(),
|
||||
}: {
|
||||
sellerPubkey: string;
|
||||
buyerPubkey: string;
|
||||
lockDays?: number;
|
||||
from?: number;
|
||||
}): EscrowP2PKOptions {
|
||||
const seller = normalizeCashuPubkey(sellerPubkey);
|
||||
if (!seller) throw new Error('Seller key is not a valid Cashu pubkey.');
|
||||
const buyer = normalizeCashuPubkey(buyerPubkey);
|
||||
if (!buyer) throw new Error('Your escrow key is not a valid Cashu pubkey.');
|
||||
|
||||
return {
|
||||
pubkey: seller,
|
||||
locktime: escrowLocktime(lockDays, from),
|
||||
refundKeys: [buyer],
|
||||
requiredSignatures: 1,
|
||||
requiredRefundSignatures: 1,
|
||||
};
|
||||
}
|
||||
|
||||
interface Nuts10Tags {
|
||||
data?: unknown;
|
||||
tags?: unknown;
|
||||
}
|
||||
|
||||
/** Extract a P2PK policy from a proof secret, or null when it isn't locked. */
|
||||
export function secretPolicy(secret: string): Partial<EscrowPolicy> | null {
|
||||
if (!isHex(secret)) return null;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(hexToUtf8(secret));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
|
||||
const obj = parsed as Nuts10Tags;
|
||||
const tags: string[][] = Array.isArray(obj.tags)
|
||||
? (obj.tags.filter((t) => Array.isArray(t)) as string[][])
|
||||
: [];
|
||||
const values = (name: string): string[] =>
|
||||
tags.find((t) => t[0] === name)?.slice(1) ?? [];
|
||||
|
||||
const primary = typeof obj.data === 'string' ? [obj.data] : [];
|
||||
const pubkeys = [...primary, ...values('pubkeys')];
|
||||
const refund = values('refund');
|
||||
const locktime = Number(values('locktime')[0] ?? 0);
|
||||
|
||||
if (pubkeys.length === 0 && refund.length === 0 && !locktime) return null;
|
||||
|
||||
const nSigs = Number(values('n_sigs')[0] ?? 1);
|
||||
const nSigsRefund = Number(values('n_sigs_refund')[0] ?? 1);
|
||||
|
||||
return {
|
||||
pubkey: pubkeys[0] ? (normalizeCashuPubkey(pubkeys[0]) ?? pubkeys[0]) : undefined,
|
||||
locktime: Number.isFinite(locktime) && locktime > 0 ? locktime : undefined,
|
||||
refundKeys: refund
|
||||
.map((k) => normalizeCashuPubkey(k) ?? k)
|
||||
.filter((k): k is string => !!k),
|
||||
requiredSignatures: Number.isFinite(nSigs) && nSigs > 0 ? nSigs : 1,
|
||||
requiredRefundSignatures:
|
||||
Number.isFinite(nSigsRefund) && nSigsRefund > 0 ? nSigsRefund : 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect a Cashu token string offline. Throws on malformed input so callers
|
||||
* can surface a friendly error.
|
||||
*/
|
||||
export function parseEscrowToken(token: string): EscrowTokenInfo {
|
||||
let decoded: ReturnType<typeof getDecodedToken>;
|
||||
try {
|
||||
decoded = getDecodedToken(token.trim());
|
||||
} catch {
|
||||
throw new Error('That is not a valid Cashu token.');
|
||||
}
|
||||
|
||||
const proofs = decoded.proofs ?? [];
|
||||
const amount = proofs.reduce((sum, p) => sum + p.amount, 0);
|
||||
|
||||
let policy: Partial<EscrowPolicy> | null = null;
|
||||
for (const proof of proofs) {
|
||||
const found = secretPolicy(proof.secret);
|
||||
if (found) {
|
||||
policy = { ...(policy ?? {}), ...found };
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const locktime = policy?.locktime ?? 0;
|
||||
return {
|
||||
mint: decoded.mint,
|
||||
unit: decoded.unit,
|
||||
amount,
|
||||
locked: !!policy,
|
||||
policy,
|
||||
refundable: !!locktime && Math.floor(Date.now() / 1000) >= locktime,
|
||||
};
|
||||
}
|
||||
|
||||
/** The subset of the cashu-ts `Wallet`/`CashuMint` API escrow creation uses. */
|
||||
export interface EscrowMintWallet {
|
||||
createMintQuote(amount: number): Promise<{ quote: string; request: string }>;
|
||||
checkMintQuote(quote: string): Promise<{ state?: string }>;
|
||||
mintProofs(
|
||||
amount: number,
|
||||
quote: string,
|
||||
options: { p2pk: EscrowP2PKOptions },
|
||||
): Promise<Proof[]>;
|
||||
}
|
||||
|
||||
export const MINT_QUOTE_PAID = 'PAID';
|
||||
|
||||
export interface CreateEscrowDeps {
|
||||
/** A cashu-ts Wallet (or compatible fake in tests) for the chosen mint. */
|
||||
wallet: EscrowMintWallet;
|
||||
/** Pay the mint's Lightning invoice (NWC / WebLN / Cashu melt). */
|
||||
payInvoice: (bolt11: string) => Promise<void>;
|
||||
/** Poll a mint quote until paid. Defaults to `wallet.checkMintQuote`. */
|
||||
isPaid?: (quote: string) => Promise<boolean>;
|
||||
/** How long to wait for the invoice to be paid. */
|
||||
maxWaitMs?: number;
|
||||
/** Override the poll interval (tests pass 0). */
|
||||
pollMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an escrowed Cashu token: mint fresh proofs locked to the seller with
|
||||
* the buyer as the refund key, funded by paying the mint's Lightning invoice.
|
||||
*
|
||||
* Funding by mint (rather than spending an existing wallet balance) keeps this
|
||||
* independent of the coco wallet's proof state. Dependencies are injected so
|
||||
* the whole flow can be unit-tested without a mint.
|
||||
*/
|
||||
export async function createEscrowToken(
|
||||
deps: CreateEscrowDeps,
|
||||
params: {
|
||||
mintUrl: string;
|
||||
amountSats: number;
|
||||
sellerPubkey: string;
|
||||
buyerPubkey: string;
|
||||
lockDays?: number;
|
||||
},
|
||||
): Promise<{ token: string; amount: number; locktime: number }> {
|
||||
const amountSats = Math.floor(params.amountSats);
|
||||
if (!Number.isFinite(amountSats) || amountSats <= 0) {
|
||||
throw new Error('Enter a positive escrow amount.');
|
||||
}
|
||||
|
||||
const p2pk = escrowP2PKOptions({
|
||||
sellerPubkey: params.sellerPubkey,
|
||||
buyerPubkey: params.buyerPubkey,
|
||||
lockDays: params.lockDays,
|
||||
});
|
||||
|
||||
const quote = await deps.wallet.createMintQuote(amountSats);
|
||||
await deps.payInvoice(quote.request);
|
||||
|
||||
const isPaid =
|
||||
deps.isPaid ??
|
||||
(async (q: string) => (await deps.wallet.checkMintQuote(q))?.state === MINT_QUOTE_PAID);
|
||||
|
||||
const pollMs = deps.pollMs ?? 1500;
|
||||
const deadline = Date.now() + (deps.maxWaitMs ?? 120_000);
|
||||
let paid = false;
|
||||
while (Date.now() < deadline) {
|
||||
if (await isPaid(quote.quote)) {
|
||||
paid = true;
|
||||
break;
|
||||
}
|
||||
if (pollMs > 0) await new Promise((resolve) => setTimeout(resolve, pollMs));
|
||||
}
|
||||
if (!paid) {
|
||||
throw new Error('The mint never confirmed your payment — the mint quote expired.');
|
||||
}
|
||||
|
||||
const proofs = await deps.wallet.mintProofs(amountSats, quote.quote, { p2pk });
|
||||
const token = getEncodedToken({ mint: params.mintUrl, unit: 'sat', proofs });
|
||||
return { token, amount: amountSats, locktime: p2pk.locktime };
|
||||
}
|
||||
Loading…
Reference in a new issue