Skip to content

Commit 5df89ad

Browse files
motxxclaude
andcommitted
feat: add Cashu P2PK escrow for oracle-mediated payments
Implements NUT-11 2-of-2(Oracle, Worker) multisig with timelock refund to Requester. Includes atomic swap for bounty split and oracle fee. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ff548b6 commit 5df89ad

3 files changed

Lines changed: 250 additions & 0 deletions

File tree

src/cashu/escrow.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { expect, test } from "bun:test";
2+
import { buildEscrowP2PKOptions, calculateOracleFee, inspectEscrowToken } from "./escrow";
3+
4+
// Valid 32-byte x-only pubkeys (64 hex chars)
5+
const ORACLE_PUB = "0000000000000000000000000000000000000000000000000000000000000001";
6+
const WORKER_PUB = "0000000000000000000000000000000000000000000000000000000000000002";
7+
const REQUESTER_PUB = "0000000000000000000000000000000000000000000000000000000000000003";
8+
9+
test("buildEscrowP2PKOptions creates 2-of-2 with timelock refund", () => {
10+
const opts = buildEscrowP2PKOptions({
11+
oraclePubkey: ORACLE_PUB,
12+
workerPubkey: WORKER_PUB,
13+
requesterRefundPubkey: REQUESTER_PUB,
14+
locktimeSeconds: 1700000000,
15+
});
16+
17+
expect(opts.requiredSignatures).toBe(2);
18+
// P2PKBuilder prepends 02 prefix (compressed pubkey format)
19+
expect(opts.pubkey).toEqual([`02${ORACLE_PUB}`, `02${WORKER_PUB}`]);
20+
expect(opts.refundKeys).toEqual([`02${REQUESTER_PUB}`]);
21+
expect(opts.locktime).toBe(1700000000);
22+
expect(opts.sigFlag).toBe("SIG_ALL");
23+
});
24+
25+
test("calculateOracleFee computes correct fee", () => {
26+
// 5% fee (50,000 ppm)
27+
expect(calculateOracleFee(100, 50_000)).toBe(5);
28+
expect(calculateOracleFee(1000, 50_000)).toBe(50);
29+
30+
// 1% fee (10,000 ppm)
31+
expect(calculateOracleFee(100, 10_000)).toBe(1);
32+
expect(calculateOracleFee(1000, 10_000)).toBe(10);
33+
34+
// Sub-sat rounds up
35+
expect(calculateOracleFee(1, 50_000)).toBe(1);
36+
37+
// 0% fee
38+
expect(calculateOracleFee(100, 0)).toBe(0);
39+
});
40+
41+
test("inspectEscrowToken returns null for invalid token", () => {
42+
expect(inspectEscrowToken("invalid")).toBe(null);
43+
expect(inspectEscrowToken("")).toBe(null);
44+
});

src/cashu/escrow.ts

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
/**
2+
* Cashu P2PK escrow for Anchr protocol.
3+
*
4+
* Implements the 1-token escrow pattern:
5+
* Lock: 2-of-2(Oracle, Worker) with timeout refund to Requester
6+
* Pass: Oracle + Worker co-sign swap → bounty split (worker reward + oracle fee)
7+
* Fail: Nobody signs → timeout → Requester reclaims
8+
*
9+
* Uses NUT-11 P2PK spending conditions via @cashu/cashu-ts P2PKBuilder.
10+
*/
11+
12+
import {
13+
P2PKBuilder,
14+
type Proof,
15+
type P2PKOptions,
16+
getEncodedToken,
17+
getDecodedToken,
18+
} from "@cashu/cashu-ts";
19+
import { getCashuWallet, getCashuConfig } from "./wallet";
20+
21+
export interface EscrowParams {
22+
/** Oracle's public key (hex). */
23+
oraclePubkey: string;
24+
/** Worker's public key (hex). */
25+
workerPubkey: string;
26+
/** Requester's public key for timeout refund (hex). */
27+
requesterRefundPubkey: string;
28+
/** Locktime as unix timestamp (seconds). After this, requester can reclaim. */
29+
locktimeSeconds: number;
30+
}
31+
32+
export interface EscrowToken {
33+
/** Encoded Cashu token string. */
34+
token: string;
35+
/** Raw proofs. */
36+
proofs: Proof[];
37+
/** The P2PK options used. */
38+
p2pkOptions: P2PKOptions;
39+
/** Total amount in sats. */
40+
amountSats: number;
41+
}
42+
43+
export interface SwapResult {
44+
/** Token for the worker (bounty minus fee). */
45+
workerToken: string;
46+
/** Token for the oracle (fee). */
47+
oracleToken: string;
48+
/** Worker amount in sats. */
49+
workerAmountSats: number;
50+
/** Oracle fee in sats. */
51+
oracleFeeSats: number;
52+
}
53+
54+
/**
55+
* Build P2PK options for escrow: 2-of-2(Oracle, Worker) + timeout refund to Requester.
56+
*/
57+
export function buildEscrowP2PKOptions(params: EscrowParams): P2PKOptions {
58+
return new P2PKBuilder()
59+
.addLockPubkey([params.oraclePubkey, params.workerPubkey])
60+
.requireLockSignatures(2)
61+
.lockUntil(params.locktimeSeconds)
62+
.addRefundPubkey(params.requesterRefundPubkey)
63+
.requireRefundSignatures(1)
64+
.sigAll()
65+
.toOptions();
66+
}
67+
68+
/**
69+
* Create a P2PK-locked escrow token.
70+
*
71+
* The token can only be spent by both Oracle and Worker signing together.
72+
* After locktime, the Requester can reclaim with their refund key.
73+
*
74+
* Requires an existing funded wallet (proofs available).
75+
*/
76+
export async function createEscrowToken(
77+
amountSats: number,
78+
params: EscrowParams,
79+
sourceProofs: Proof[],
80+
): Promise<EscrowToken | null> {
81+
const wallet = getCashuWallet();
82+
const config = getCashuConfig();
83+
if (!wallet || !config) return null;
84+
85+
const p2pkOptions = buildEscrowP2PKOptions(params);
86+
87+
try {
88+
await wallet.loadMint();
89+
const { send } = await wallet.ops
90+
.send(amountSats, sourceProofs)
91+
.asP2PK(p2pkOptions)
92+
.run();
93+
94+
const token = getEncodedToken({ mint: config.mintUrl, proofs: send });
95+
return {
96+
token,
97+
proofs: send,
98+
p2pkOptions,
99+
amountSats,
100+
};
101+
} catch (error) {
102+
console.error("[cashu-escrow] Failed to create escrow token:", error instanceof Error ? error.message : error);
103+
return null;
104+
}
105+
}
106+
107+
/**
108+
* Execute the atomic swap: Oracle + Worker co-sign to split the escrowed token.
109+
*
110+
* Both parties must sign the proofs before calling this.
111+
* The swap produces two outputs:
112+
* 1. Worker reward (amountSats - feeSats) locked to workerPubkey
113+
* 2. Oracle fee (feeSats) locked to oraclePubkey
114+
*/
115+
export async function executeEscrowSwap(
116+
signedProofs: Proof[],
117+
workerPubkey: string,
118+
oraclePubkey: string,
119+
feeSats: number,
120+
): Promise<SwapResult | null> {
121+
const wallet = getCashuWallet();
122+
const config = getCashuConfig();
123+
if (!wallet || !config) return null;
124+
125+
const totalSats = signedProofs.reduce((sum, p) => sum + p.amount, 0);
126+
const workerSats = totalSats - feeSats;
127+
128+
if (workerSats <= 0) {
129+
console.error("[cashu-escrow] Fee exceeds total amount");
130+
return null;
131+
}
132+
133+
try {
134+
await wallet.loadMint();
135+
136+
// Swap into two P2PK outputs: worker reward + oracle fee
137+
const workerP2PK = new P2PKBuilder().addLockPubkey(workerPubkey).toOptions();
138+
const oracleP2PK = new P2PKBuilder().addLockPubkey(oraclePubkey).toOptions();
139+
140+
// Use the wallet's swap to split into two outputs
141+
const { send: workerProofs } = await wallet.ops
142+
.send(workerSats, signedProofs)
143+
.asP2PK(workerP2PK)
144+
.run();
145+
146+
// The remaining proofs (change) should be fee amount for oracle
147+
const remainingProofs = signedProofs.filter(
148+
(p) => !workerProofs.some((wp) => wp.C === p.C),
149+
);
150+
151+
// If there are remaining proofs, swap them to oracle
152+
let oracleProofs: Proof[];
153+
if (remainingProofs.length > 0) {
154+
const { send } = await wallet.ops
155+
.send(feeSats, remainingProofs)
156+
.asP2PK(oracleP2PK)
157+
.run();
158+
oracleProofs = send;
159+
} else {
160+
// Edge case: no remaining proofs, oracle gets nothing
161+
oracleProofs = [];
162+
}
163+
164+
return {
165+
workerToken: getEncodedToken({ mint: config.mintUrl, proofs: workerProofs }),
166+
oracleToken: oracleProofs.length > 0
167+
? getEncodedToken({ mint: config.mintUrl, proofs: oracleProofs })
168+
: "",
169+
workerAmountSats: workerSats,
170+
oracleFeeSats: feeSats,
171+
};
172+
} catch (error) {
173+
console.error("[cashu-escrow] Swap failed:", error instanceof Error ? error.message : error);
174+
return null;
175+
}
176+
}
177+
178+
/**
179+
* Calculate oracle fee from bounty amount and fee rate.
180+
*/
181+
export function calculateOracleFee(amountSats: number, feePpm: number): number {
182+
return Math.ceil((amountSats * feePpm) / 1_000_000);
183+
}
184+
185+
/**
186+
* Decode an escrow token and inspect its P2PK conditions.
187+
*/
188+
export function inspectEscrowToken(token: string): {
189+
amountSats: number;
190+
proofCount: number;
191+
mintUrl: string;
192+
} | null {
193+
try {
194+
const decoded = getDecodedToken(token);
195+
const amountSats = decoded.proofs.reduce((sum: number, p: Proof) => sum + p.amount, 0);
196+
return {
197+
amountSats,
198+
proofCount: decoded.proofs.length,
199+
mintUrl: decoded.mint,
200+
};
201+
} catch {
202+
return null;
203+
}
204+
}

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ export type { Oracle, OracleInfo, OracleAttestation } from "./oracle";
3838
export { stripExif } from "./exif-strip";
3939
export { purgeExpiredQueries } from "./data-purge";
4040
export { isCashuEnabled, getCashuConfig, verifyToken, encodeToken } from "./cashu/wallet";
41+
export { buildEscrowP2PKOptions, calculateOracleFee, createEscrowToken, executeEscrowSwap, inspectEscrowToken } from "./cashu/escrow";
42+
export type { EscrowParams, EscrowToken, SwapResult } from "./cashu/escrow";
4143
export * as nostr from "./nostr/index";
4244
export * as blossom from "./blossom/client";
4345
export * as verification from "./verification/index";

0 commit comments

Comments
 (0)