@uuaid/pillar 0.2.0-alpha.8 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/HOSTING-LIMITS.md +432 -0
  2. package/INTERACTION-CONVENTION.md +222 -1
  3. package/README.md +371 -17
  4. package/bin/carrier.mjs +7 -1
  5. package/bin/pillar-ask.mjs +1 -0
  6. package/bin/pillar-blob.mjs +139 -0
  7. package/bin/pillar-directory.mjs +232 -0
  8. package/bin/pillar-escrow.mjs +675 -0
  9. package/bin/pillar-responder.mjs +47 -1
  10. package/bin/pillar-seed.mjs +113 -13
  11. package/bin/pillar.mjs +345 -4
  12. package/package.json +30 -7
  13. package/src/blob/blob.mjs +510 -0
  14. package/src/blob/broker.mjs +129 -0
  15. package/src/blob/http.mjs +334 -0
  16. package/src/blob/store.mjs +55 -0
  17. package/src/escrow/box.mjs +43 -0
  18. package/src/escrow/escrow-service.mjs +538 -0
  19. package/src/escrow/rails/evm-rail.mjs +427 -0
  20. package/src/escrow/rails/index.mjs +41 -0
  21. package/src/escrow/rails/zilligon-rail.mjs +73 -0
  22. package/src/escrow/terms.mjs +368 -0
  23. package/src/escrow/token-registry.mjs +231 -0
  24. package/src/escrow/verify.mjs +119 -0
  25. package/src/escrow/zilligon.mjs +266 -0
  26. package/src/identity/keychain.mjs +27 -10
  27. package/src/identity/tier.mjs +130 -0
  28. package/src/identity/trust.mjs +63 -0
  29. package/src/index.mjs +28 -2
  30. package/src/mailbox/mailbox.mjs +41 -0
  31. package/src/net/carrier-client.mjs +93 -14
  32. package/src/net/carrier-server.mjs +212 -15
  33. package/src/net/delegation.mjs +209 -0
  34. package/src/net/envelope.mjs +81 -0
  35. package/src/net/link-offer.mjs +107 -0
  36. package/src/net/refusals.mjs +126 -0
  37. package/src/net/seed-registry.mjs +467 -0
  38. package/src/net/seed-tier.mjs +185 -0
  39. package/src/node-check.mjs +32 -0
  40. package/src/observability.mjs +141 -0
  41. package/src/pillar.mjs +206 -17
  42. package/src/vda/core.mjs +290 -0
  43. package/src/vda/directory.mjs +87 -0
  44. package/src/vda/instruments.mjs +246 -0
  45. package/src/vda/shims.mjs +77 -0
@@ -0,0 +1,538 @@
1
+ /**
2
+ * EscrowService — the blind-escrow state machine.
3
+ *
4
+ * OPEN --offer--> OPEN --accept--> ACCEPTED --paid+attest--> PAID
5
+ * --release--> RELEASED
6
+ *
7
+ * The escrow holds ONLY the 32-byte content key K (plus sha256(item) inside
8
+ * the signed terms) — it never sees the deliverable, which the seller sends
9
+ * to the buyer directly as a ciphertext box. Payment rides the Zilligon ZGN
10
+ * rail; the escrow attests the buyer's payment from ITS OWN wallet rows (an
11
+ * instrument independent of the buyer's claimed txId), then:
12
+ * 1. attests payment (zilligon.findPayment)
13
+ * 2. pays the seller (rail transfer of escrowSend)
14
+ * 3. releases K + buyer receipt to the buyer
15
+ * 4. sends the seller receipt
16
+ *
17
+ * Journal-first JSONL: every state change and every release step is appended
18
+ * to the journal BEFORE (or right after, with a recovery marker before) the
19
+ * irreversible rail action, so reconcile() can re-run release after a crash
20
+ * without double-paying: payout-attempt precedes the transfer, payout-sent
21
+ * records the rail txId, and a payout-attempt without payout-sent is de-duped
22
+ * against the escrow's own wallet rows before re-attempting.
23
+ *
24
+ * All replies are ordinary pillar envelopes (E2E-encrypted by the envelope
25
+ * layer — carriers stay payload-blind). Party pubkeys ride in the terms, so
26
+ * no directory lookups are needed.
27
+ */
28
+ import { appendFileSync, readFileSync, mkdirSync } from "node:fs";
29
+ import { dirname } from "node:path";
30
+ import {
31
+ validateTerms, verifyTermsSig, verifyReceiptSig,
32
+ PAID_GRACE_MS, makeReceipt, railViewOfTerms,
33
+ } from "./terms.mjs";
34
+ import { RailSet, ZilligonRail, railKeyOfTerms } from "./rails/index.mjs";
35
+ import { Observability } from "../observability.mjs";
36
+
37
+ const HEX64 = /^[0-9a-f]{64}$/i;
38
+
39
+ export const STATES = { OPEN: "open", ACCEPTED: "accepted", PAID: "paid", RELEASED: "released" };
40
+
41
+ export class EscrowService {
42
+ /**
43
+ * @param keychain escrow's Keychain (loaded identity; uuaid must equal the
44
+ * escrow named in incoming terms)
45
+ * @param pillar started Pillar instance (carrier-only in tests)
46
+ * @param zilligon ZilligonClient bound to the escrow's own @handle + apiKey
47
+ * @param journalPath JSONL journal file
48
+ * @param railHandle escrow's Zilligon handle (must match terms.escrowHandle)
49
+ */
50
+ constructor({ keychain, pillar, zilligon, rails, journalPath, railHandle, metrics = null, tokenRegistry = null }) {
51
+ this.keychain = keychain;
52
+ this.pillar = pillar;
53
+ this.zilligon = zilligon ?? null;
54
+ this.rails = rails instanceof RailSet ? rails : new RailSet(Array.isArray(rails) ? rails : rails ? [rails] : []);
55
+ if (zilligon && !this.rails.get("zilligon")) this.rails.add(new ZilligonRail({ client: zilligon }));
56
+ // Asset allowlist (token-registry.mjs). When wired, v2 offers naming an
57
+ // unregistered chain/token are rejected at the door — fail closed.
58
+ this.tokenRegistry = tokenRegistry;
59
+ this.journalPath = journalPath;
60
+ this.railHandle = railHandle ?? zilligon?.handle ?? null;
61
+ this.metrics = metrics ?? new Observability();
62
+ this.offers = new Map(); // offerId -> {state, terms, contentKeyHex, sellerSig, buyerSig, payTxId, releaseTxId, payoutAttempted}
63
+ // One on-chain payment attests ONE offer: rail-scoped keys of every
64
+ // attested payment, bound to the offer that consumed them. Consumption
65
+ // happens only on ATTESTED success (never on a mere claim — a bogus
66
+ // claim must not be able to squat a stranger's txId).
67
+ this.consumedPayTx = new Map(); // "railKey|tx|<txId>" / "railKey|src|<sourceId>" -> offerId
68
+ if (journalPath) {
69
+ mkdirSync(dirname(journalPath), { recursive: true });
70
+ this._loadJournal();
71
+ }
72
+ }
73
+
74
+ /* ------------------------------------------------ journal ------------ */
75
+
76
+ _append(event) {
77
+ if (!this.journalPath) return;
78
+ appendFileSync(this.journalPath, JSON.stringify({ ...event, at: new Date().toISOString() }) + "\n");
79
+ }
80
+
81
+ _loadJournal() {
82
+ let lines = "";
83
+ try {
84
+ lines = readFileSync(this.journalPath, "utf-8");
85
+ } catch (_e) {
86
+ return; // no journal yet — fresh escrow
87
+ }
88
+ for (const line of lines.split("\n")) {
89
+ if (!line.trim()) continue;
90
+ let ev;
91
+ try {
92
+ ev = JSON.parse(line);
93
+ } catch (_e) {
94
+ continue; // torn tail line from a crash — the next lines self-heal
95
+ }
96
+ if (!ev.event || !ev.offerId) continue;
97
+ const o = this.offers.get(ev.offerId) ?? { state: STATES.OPEN, terms: null, payoutAttempted: false, payoutDone: false };
98
+ switch (ev.event) {
99
+ case "offered":
100
+ Object.assign(o, { terms: ev.terms, contentKeyHex: ev.contentKeyHex, sellerSig: ev.sellerSig });
101
+ break;
102
+ case "accepted":
103
+ o.buyerSig = ev.buyerSig;
104
+ o.state = STATES.ACCEPTED;
105
+ break;
106
+ case "paid":
107
+ // Latest claim wins (corrected claims re-journal; payout-attempt pins)
108
+ o.payTxId = ev.txId;
109
+ o.paySourceId = ev.sourceId ?? undefined;
110
+ o.state = STATES.PAID;
111
+ break;
112
+ case "payout-attempt":
113
+ o.payoutAttempted = true;
114
+ if (ev.prepared) o.payoutPrepared = ev.prepared; // EVM: signed-but-maybe-unbroadcast tx
115
+ break;
116
+ case "payout-invalidated":
117
+ // The prepared bytes are provably dead (reverted / nonce superseded)
118
+ // — never re-broadcast them; the next payout-attempt re-prepares.
119
+ o.payoutPrepared = null;
120
+ break;
121
+ case "payout-sent":
122
+ o.releaseTxId = ev.releaseTxId;
123
+ if (ev.releaseSourceId) o.releaseSourceId = ev.releaseSourceId;
124
+ o.payoutDone = true;
125
+ break;
126
+ case "released":
127
+ o.state = STATES.RELEASED;
128
+ break;
129
+ case "key-sent":
130
+ break; // marker only; state stays PAID until "released"
131
+ case "pay-consumed":
132
+ for (const k of ev.keys ?? []) this.consumedPayTx.set(k, ev.offerId);
133
+ break;
134
+ }
135
+ this.offers.set(ev.offerId, o);
136
+ }
137
+ // Pre-fix journals carry no pay-consumed events. payout-attempt only
138
+ // ever follows a SUCCESSFUL attestation, so for those offers the claimed
139
+ // payment ids were genuinely consumed — backfill them. (Offers merely
140
+ // PAID are NOT backfilled: an unattested claim must never squat an id.)
141
+ for (const [offerId, o] of this.offers) {
142
+ if (!o.payoutAttempted || !o.terms) continue;
143
+ const railKey = railKeyOfTerms(o.terms);
144
+ if (!railKey) continue;
145
+ for (const k of this._payKeys(railKey, o.payTxId, o.paySourceId ?? null)) {
146
+ if (!this.consumedPayTx.has(k)) this.consumedPayTx.set(k, offerId);
147
+ }
148
+ }
149
+ this._gaugeOffers();
150
+ }
151
+
152
+ /** Rail-scoped consumption keys of a payment (txId + wallet row, if any). */
153
+ _payKeys(railKey, txId, sourceId) {
154
+ const keys = [];
155
+ if (txId) keys.push(`${railKey}|tx|${String(txId).toLowerCase()}`);
156
+ if (sourceId) keys.push(`${railKey}|src|${String(sourceId).toLowerCase()}`);
157
+ return keys;
158
+ }
159
+
160
+ /* ------------------------------------------------ routing ------------ */
161
+
162
+ attach() {
163
+ this.pillar.onEnvelope((env) => {
164
+ const kind = env.kind;
165
+ if (kind === "escrow.offer") return this.onOffer(env);
166
+ if (kind === "escrow.accept") return this.onAccept(env);
167
+ if (kind === "escrow.paid") return this.onPaid(env);
168
+ return undefined; // not ours
169
+ });
170
+ }
171
+
172
+ /** Reconcile poll: re-run release for every PAID offer (crash recovery). */
173
+ start({ reconcileMs = 30_000 } = {}) {
174
+ this.attach();
175
+ const t = setInterval(() => {
176
+ this.reconcileOnce().catch(() => {}); // never let the timer die
177
+ }, reconcileMs);
178
+ t.unref?.();
179
+ return { stop: () => clearInterval(t) };
180
+ }
181
+
182
+ async reconcileOnce() {
183
+ for (const [offerId, o] of this.offers) {
184
+ if (o.state === STATES.PAID) await this._release(offerId, o);
185
+ }
186
+ }
187
+
188
+ /* ------------------------------------------------ handlers ----------- */
189
+
190
+ async onOffer(env) {
191
+ const p = env.payload ?? {};
192
+ const { terms, sellerSig, contentKeyHex } = p;
193
+ // escrowHandle is rail-scoped: this escrow's identity on the rail the
194
+ // terms name (v1: the zilligon handle; v2 evm: the 0x address).
195
+ const rail = this.rails.for(terms ?? {});
196
+ const v = validateTerms(terms, { escrowUuaid: this.keychain._identity.uuaid, escrowHandle: rail?.handle ?? this.railHandle });
197
+ const senderOk = env.sender === terms?.seller;
198
+ const keyOk =
199
+ env.transportSignature?.publicKey != null && env.transportSignature.publicKey === terms?.sellerPubKeyHex;
200
+ const bad = (code, message) => this._errorTo(terms, "seller", code, message);
201
+ if (!terms || !v.ok) return bad(v?.reason ?? "bad-terms", "terms invalid");
202
+ if (!rail) return bad("rail-unsupported", "this escrow has no rail for these terms");
203
+ if (this.tokenRegistry) {
204
+ const pol = this.tokenRegistry.checkTerms(terms);
205
+ if (!pol.ok) return bad(pol.reason, pol.message);
206
+ }
207
+ if (!senderOk || !keyOk) return bad("identity-mismatch", "offer sender does not match signed terms");
208
+ if (!HEX64.test(contentKeyHex ?? "")) return bad("bad-key", "contentKeyHex must be 64 hex chars (32 bytes)");
209
+ if (!verifyTermsSig(terms.sellerPubKeyHex, terms, sellerSig)) return bad("bad-signature", "seller signature invalid");
210
+
211
+ const existing = this.offers.get(terms.offerId);
212
+ if (!existing) {
213
+ this.offers.set(terms.offerId, {
214
+ state: STATES.OPEN, terms, contentKeyHex, sellerSig, payoutAttempted: false, payoutDone: false,
215
+ });
216
+ this._append({ event: "offered", offerId: terms.offerId, terms, sellerSig, contentKeyHex });
217
+ this.metrics.inc("escrow.offer");
218
+ this._gaugeOffers();
219
+ }
220
+ await this._sendTo(terms, "seller", "escrow.offer-ack", { offerId: terms.offerId });
221
+ }
222
+
223
+ async onAccept(env) {
224
+ const p = env.payload ?? {};
225
+ const { offerId, buyerSig } = p;
226
+ const o = this.offers.get(offerId);
227
+ const bad = (code, message) => this._errorTo(o?.terms, "buyer", code, message);
228
+ if (!o || !o.terms) return bad("unknown-offer", "no such open offer");
229
+ const { terms } = o;
230
+ if (o.state !== STATES.OPEN) {
231
+ if (o.state === STATES.ACCEPTED) return this._ackAccept(o); // idempotent re-ack
232
+ return bad("not-open", `offer is ${o.state}`);
233
+ }
234
+ if (env.sender !== terms.buyer) return bad("identity-mismatch", "only the named buyer may accept");
235
+ if (env.transportSignature?.publicKey !== terms.buyerPubKeyHex) return bad("identity-mismatch", "accept key does not match terms");
236
+ if (!verifyTermsSig(terms.buyerPubKeyHex, terms, buyerSig)) return bad("bad-signature", "buyer signature invalid");
237
+ if (Date.now() > Date.parse(terms.expiresAt)) return bad("expired", "offer expired");
238
+
239
+ o.state = STATES.ACCEPTED;
240
+ o.buyerSig = buyerSig;
241
+ this._append({ event: "accepted", offerId, buyerSig });
242
+ this.metrics.inc("escrow.accept");
243
+ this._gaugeOffers();
244
+ await this._ackAccept(o);
245
+ }
246
+
247
+ async _ackAccept(o) {
248
+ await this._sendTo(o.terms, "buyer", "escrow.accept-ack", { offerId: o.terms.offerId });
249
+ }
250
+
251
+ async onPaid(env) {
252
+ const p = env.payload ?? {};
253
+ const { offerId, txId, sourceId } = p;
254
+ const o = this.offers.get(offerId);
255
+ const bad = (code, message) => this._errorTo(o?.terms, "buyer", code, message);
256
+ if (!o || !o.terms) return bad("unknown-offer", "no such offer");
257
+ const { terms } = o;
258
+ if (env.sender !== terms.buyer) return bad("identity-mismatch", "only the named buyer may claim payment");
259
+ if (typeof txId !== "string" || !txId) return bad("bad-txid", "txId required");
260
+ if (sourceId !== undefined && (typeof sourceId !== "string" || !sourceId)) return bad("bad-sourceid", "sourceId must be a non-empty string");
261
+ if (o.state === STATES.OPEN) return bad("not-accepted", "buyer must accept before paying");
262
+ if (o.state === STATES.RELEASED) return; // already done — nothing to do
263
+ if (Date.now() > Date.parse(terms.expiresAt) + PAID_GRACE_MS) return bad("expired", "paid-claim past grace window");
264
+
265
+ // A corrected claim may replace a wrong txId any time before the payout
266
+ // is attempted — after that the settled instrument is pinned for good.
267
+ const claimChanged = Boolean(o.payTxId) &&
268
+ (txId !== o.payTxId || (sourceId ?? null) !== (o.paySourceId ?? null));
269
+ if (o.state === STATES.ACCEPTED || !o.payTxId || (claimChanged && !o.payoutAttempted)) {
270
+ o.state = STATES.PAID;
271
+ o.payTxId = txId;
272
+ o.paySourceId = sourceId ?? undefined;
273
+ this._append({ event: "paid", offerId, txId, sourceId: sourceId ?? null });
274
+ this.metrics.inc("escrow.paid");
275
+ this._gaugeOffers();
276
+ }
277
+ await this._release(offerId, o);
278
+ }
279
+
280
+ /* ------------------------------------------------ release ------------- */
281
+
282
+ /**
283
+ * Per-offer in-flight guard. _release has awaits between "check
284
+ * payoutAttempted" and "set payoutAttempted"; unserialized entry points
285
+ * (onPaid, the reconcile timer, carrier redelivery) racing through that
286
+ * gap would each prepare a payout — at DISTINCT nonces now that the rail
287
+ * serializes preps — and pay the seller twice. The flag is set
288
+ * synchronously before the first await, so the single-threaded event loop
289
+ * makes the check-and-set atomic; a bounced caller simply leaves the offer
290
+ * to the run in flight (reconcile retries anything unfinished).
291
+ * Transient by design: never journaled, resets on restart.
292
+ */
293
+ async _release(offerId, o) {
294
+ if (o._releasing) return;
295
+ o._releasing = true;
296
+ try {
297
+ return await this._releaseLocked(offerId, o);
298
+ } finally {
299
+ o._releasing = false;
300
+ }
301
+ }
302
+
303
+ async _releaseLocked(offerId, o) {
304
+ const t0 = Date.now();
305
+ const { terms } = o;
306
+ const rail = this.rails.for(terms);
307
+ if (!rail) {
308
+ await this._errorTo(terms, "buyer", "rail-unsupported", "this escrow has no rail for these terms");
309
+ return;
310
+ }
311
+ const view = railViewOfTerms(terms); // decimal-string amounts, any version
312
+
313
+ // 1. attest the buyer's payment from the escrow's OWN instrument
314
+ // (zilligon: its wallet rows; evm: the chain's receipt at depth) —
315
+ // independent of the claimed txId. Not found -> tell the buyer; stay
316
+ // PAID so a later reconcile or a corrected claim can retry.
317
+ const tAttest = Date.now();
318
+ const hit = await rail.attestPayment({
319
+ fromHandle: terms.buyerHandle,
320
+ amount: view.price,
321
+ txId: o.payTxId,
322
+ sourceId: o.paySourceId ?? null,
323
+ asset: view.asset,
324
+ });
325
+ this.metrics.observe("escrow.attest.latencyMs", Date.now() - tAttest);
326
+ if (!hit) {
327
+ await this._errorTo(terms, "buyer", "payment-not-attested", "no matching payment on the escrow's rail instrument");
328
+ this.metrics.observe("escrow.release.latencyMs", Date.now() - t0);
329
+ return;
330
+ }
331
+ const payTxId = hit.txId ?? o.payTxId;
332
+ const paySourceId = hit.sourceId ?? o.paySourceId ?? null;
333
+
334
+ // Single-use payment: the attested instrument (txId, and the wallet row
335
+ // on rails that have one) is consumed by exactly one offer. Without
336
+ // this, one real payment could attest N same-priced offers and drain
337
+ // the escrow's rail balance via sock-puppet sellers.
338
+ const payKeys = this._payKeys(rail.railKey, payTxId, paySourceId);
339
+ const clash = payKeys.find((k) => this.consumedPayTx.get(k) !== undefined && this.consumedPayTx.get(k) !== offerId);
340
+ if (clash) {
341
+ this.metrics.inc("escrow.payment-already-consumed");
342
+ await this._errorTo(terms, "buyer", "payment-already-consumed", "this payment already settled a different offer");
343
+ this.metrics.observe("escrow.release.latencyMs", Date.now() - t0);
344
+ return;
345
+ }
346
+ if (payKeys.some((k) => !this.consumedPayTx.has(k))) {
347
+ for (const k of payKeys) this.consumedPayTx.set(k, offerId);
348
+ this._append({ event: "pay-consumed", offerId, keys: payKeys });
349
+ }
350
+
351
+ // 2. pay the seller (escrowSend recomputed from the signed terms — no
352
+ // in-memory state needed after a crash). Journal payout-attempt
353
+ // BEFORE the irreversible rail action; rails with a prepare step
354
+ // (evm) sign FIRST so the journaled attempt carries the exact bytes
355
+ // a recovery re-broadcasts (same nonce -> double-pay impossible).
356
+ // Rails without one (zilligon) de-dup on recovery via
357
+ // findPriorPayout's wallet scan. escrowSend can legitimately be 0
358
+ // (explicit zero payout): nothing to send, mark done.
359
+ if (!o.payoutDone) {
360
+ const escrowSend = view.escrowSend;
361
+ const zeroSend = BigInt(escrowSend) === 0n;
362
+ if (!o.payoutAttempted) {
363
+ let prepared = null;
364
+ if (!zeroSend) {
365
+ try {
366
+ prepared = await rail.preparePayout({ toHandle: terms.sellerHandle, amount: escrowSend, asset: view.asset });
367
+ } catch (e) {
368
+ this.metrics.inc("escrow.payout-failed");
369
+ await this._errorTo(terms, "seller", "payout-failed", String(e.message ?? e));
370
+ this.metrics.observe("escrow.release.latencyMs", Date.now() - t0);
371
+ return; // nothing journaled, nothing signed reached the rail
372
+ }
373
+ }
374
+ o.payoutAttempted = true;
375
+ o.payoutPrepared = prepared ?? null;
376
+ this._append({
377
+ event: "payout-attempt", offerId,
378
+ amount: view.v === 1 ? Number(escrowSend) : escrowSend,
379
+ ...(prepared ? { prepared } : {}),
380
+ });
381
+ this.metrics.inc("escrow.payout-attempt");
382
+ } else if (!zeroSend) {
383
+ // crashed between attempt and sent? ask the rail for the payout we
384
+ // may already have made (evm: exact receipt of the journaled tx;
385
+ // zilligon: wallet-row scan with retries)
386
+ const prior = await rail.findPriorPayout({
387
+ toHandle: terms.sellerHandle, amount: escrowSend,
388
+ prepared: o.payoutPrepared ?? null, asset: view.asset,
389
+ });
390
+ if (prior?.txId) {
391
+ o.releaseTxId = prior.txId;
392
+ o.releaseSourceId = prior.sourceId ?? null;
393
+ o.payoutDone = true;
394
+ this._append({ event: "payout-sent", offerId, releaseTxId: o.releaseTxId, releaseSourceId: o.releaseSourceId });
395
+ } else if (typeof rail.payoutStatus === "function") {
396
+ // Not landed. If the prepared bytes are provably DEAD (reverted,
397
+ // or the nonce was consumed at finalized depth by a different tx —
398
+ // e.g. a concurrent offer's payout), re-prepare with a fresh nonce.
399
+ // "pending" keeps re-broadcasting the same bytes — never two live
400
+ // signed payouts for one offer. A missing prepared (journal tail
401
+ // from a crash mid-recovery) is treated as invalidated: the bytes
402
+ // are unrecoverable and were never broadcast.
403
+ const st = !o.payoutPrepared
404
+ ? { state: "invalidated", reason: "missing-prepared" }
405
+ : await rail.payoutStatus({ prepared: o.payoutPrepared }).catch(() => ({ state: "pending" }));
406
+ if (st.state === "invalidated") {
407
+ // Re-prepare FIRST, then journal ONE combined event (the fresh
408
+ // attempt, carrying what it supersedes). A crash between the two
409
+ // replays to the OLD prepared bytes; payoutStatus re-derives
410
+ // "invalidated" and this branch re-runs — idempotent, and the
411
+ // lost signed bytes were never broadcast.
412
+ const dead = o.payoutPrepared;
413
+ let prepared;
414
+ try {
415
+ prepared = await rail.preparePayout({ toHandle: terms.sellerHandle, amount: escrowSend, asset: view.asset });
416
+ } catch (e) {
417
+ this.metrics.inc("escrow.payout-failed");
418
+ await this._errorTo(terms, "seller", "payout-failed", String(e.message ?? e));
419
+ this.metrics.observe("escrow.release.latencyMs", Date.now() - t0);
420
+ return; // nothing journaled, nothing broadcast; retry next reconcile
421
+ }
422
+ o.payoutPrepared = prepared ?? null;
423
+ this._append({
424
+ event: "payout-attempt", offerId,
425
+ amount: view.v === 1 ? Number(escrowSend) : escrowSend,
426
+ ...(prepared ? { prepared } : {}),
427
+ supersedes: { ...(dead ? { txId: dead.txId, nonce: dead.nonce } : {}), reason: st.reason },
428
+ });
429
+ this.metrics.inc("escrow.payout-invalidated");
430
+ this.metrics.inc("escrow.payout-attempt");
431
+ }
432
+ }
433
+ }
434
+ if (!o.payoutDone) {
435
+ if (!zeroSend) {
436
+ let res;
437
+ try {
438
+ res = await rail.sendPayout({
439
+ toHandle: terms.sellerHandle, amount: escrowSend,
440
+ prepared: o.payoutPrepared ?? null, asset: view.asset,
441
+ });
442
+ } catch (e) {
443
+ this.metrics.inc("escrow.payout-failed");
444
+ await this._errorTo(terms, "seller", "payout-failed", String(e.message ?? e));
445
+ this.metrics.observe("escrow.release.latencyMs", Date.now() - t0);
446
+ return; // stay PAID; reconcile will de-dup and retry
447
+ }
448
+ o.releaseTxId = res?.txId ?? null;
449
+ o.releaseSourceId = res?.sourceId ?? null;
450
+ } else {
451
+ o.releaseTxId = null; // zero payout — nothing to send on the rail
452
+ o.releaseSourceId = null;
453
+ this.metrics.inc("escrow.payout-zero");
454
+ }
455
+ o.payoutDone = true;
456
+ this._append({ event: "payout-sent", offerId, releaseTxId: o.releaseTxId, releaseSourceId: o.releaseSourceId ?? null });
457
+ this.metrics.inc("escrow.payout-sent");
458
+ }
459
+ }
460
+
461
+ // Receipt economics mirror the terms version: v1 keeps the Gons fields
462
+ // byte-compatible; v2 carries {rail, chainId, asset, price, sellerPayout}.
463
+ const econ = view.v === 2
464
+ ? { v: 2, rail: terms.rail, chainId: terms.chainId, asset: terms.asset, price: terms.price, sellerPayout: terms.sellerPayout }
465
+ : { priceGons: terms.priceGons, sellerPayoutGons: terms.sellerPayoutGons };
466
+
467
+ // 3. release K + buyer receipt to the buyer (re-sending is harmless —
468
+ // decryption is idempotent — but the marker keeps the journal tidy).
469
+ if (!o.keySent) {
470
+ const receipt = makeReceipt(this.keychain, {
471
+ offerId, role: "buyer", escrow: terms.escrow, buyer: terms.buyer, seller: terms.seller,
472
+ itemHash: terms.itemHash, ...econ,
473
+ payTxId, releaseTxId: o.releaseTxId,
474
+ paySourceId, releaseSourceId: o.releaseSourceId ?? null,
475
+ });
476
+ await this._sendTo(terms, "buyer", "escrow.key", {
477
+ offerId, contentKeyHex: o.contentKeyHex, receipt,
478
+ });
479
+ o.keySent = true;
480
+ this._append({ event: "key-sent", offerId });
481
+ this.metrics.inc("escrow.key-sent");
482
+ }
483
+
484
+ // 4. seller receipt — the seller's proof of completion.
485
+ if (o.state !== STATES.RELEASED) {
486
+ const receipt = makeReceipt(this.keychain, {
487
+ offerId, role: "seller", escrow: terms.escrow, buyer: terms.buyer, seller: terms.seller,
488
+ itemHash: terms.itemHash, ...econ,
489
+ payTxId, releaseTxId: o.releaseTxId,
490
+ paySourceId, releaseSourceId: o.releaseSourceId ?? null,
491
+ });
492
+ await this._sendTo(terms, "seller", "escrow.receipt", { offerId, receipt });
493
+ o.state = STATES.RELEASED;
494
+ this._append({ event: "released", offerId });
495
+ this.metrics.inc("escrow.released");
496
+ this._gaugeOffers();
497
+ }
498
+ this.metrics.observe("escrow.release.latencyMs", Date.now() - t0);
499
+ }
500
+
501
+ /* ------------------------------------------------ metrics ------------- */
502
+
503
+ /** Mirror offer-map state into gauges so dashboards see the escrow queue. */
504
+ _gaugeOffers() {
505
+ this.metrics.setGauge("escrow.offers.total", this.offers.size);
506
+ for (const state of Object.values(STATES)) {
507
+ let n = 0;
508
+ for (const o of this.offers.values()) if (o.state === state) n += 1;
509
+ this.metrics.setGauge(`escrow.offers.${state}`, n);
510
+ }
511
+ }
512
+
513
+ /* ------------------------------------------------ helpers ------------- */
514
+
515
+ async _sendTo(terms, party, kind, payload) {
516
+ if (!terms) return;
517
+ await this.pillar.send({
518
+ recipient: terms[party],
519
+ recipientPublicKey: terms[`${party}PubKeyHex`],
520
+ kind,
521
+ payload,
522
+ });
523
+ }
524
+
525
+ async _errorTo(terms, party, code, message) {
526
+ this.metrics.inc(`escrow.error.${code}`);
527
+ if (!terms) return;
528
+ try {
529
+ await this._sendTo(terms, party, "escrow.error", {
530
+ offerId: terms.offerId ?? null, code, message,
531
+ });
532
+ } catch (_e) {
533
+ /* error path is best-effort */
534
+ }
535
+ }
536
+ }
537
+
538
+ export { verifyReceiptSig };