|
| 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 | +} |
0 commit comments