@uuaid/pillar 0.2.0-alpha.11 → 0.2.0-alpha.13
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.
- package/INTERACTION-CONVENTION.md +24 -0
- package/bin/pillar-escrow.mjs +674 -0
- package/package.json +13 -3
- package/src/escrow/box.mjs +43 -0
- package/src/escrow/escrow-service.mjs +538 -0
- package/src/escrow/rails/evm-rail.mjs +427 -0
- package/src/escrow/rails/index.mjs +41 -0
- package/src/escrow/rails/zilligon-rail.mjs +73 -0
- package/src/escrow/terms.mjs +368 -0
- package/src/escrow/token-registry.mjs +231 -0
- package/src/escrow/verify.mjs +119 -0
- package/src/escrow/zilligon.mjs +266 -0
- package/src/identity/keychain.mjs +27 -10
- package/src/index.mjs +1 -1
- package/src/mailbox/mailbox.mjs +10 -0
- package/src/net/carrier-client.mjs +4 -2
- package/src/observability.mjs +141 -0
- package/src/pillar.mjs +56 -10
- package/src/vda/core.mjs +290 -0
- package/src/vda/directory.mjs +87 -0
- package/src/vda/instruments.mjs +246 -0
- package/src/vda/shims.mjs +77 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uuaid/pillar",
|
|
3
|
-
"version": "0.2.0-alpha.
|
|
3
|
+
"version": "0.2.0-alpha.13",
|
|
4
4
|
"description": "Pillar (Protocol for Inter-agent Linked Ledger And Relay) — the secure delivery layer of UACP, the Universal Agent Communication Protocol. UACP = UUAID (identity) + Envelope (signed, encrypted container) + Pillar (relay, mailbox, routing, store-and-forward). IAASO-3050 reference implementation: one command to mint an identity and join the network.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -10,10 +10,18 @@
|
|
|
10
10
|
"pillar-carrier": "bin/carrier.mjs",
|
|
11
11
|
"pillar-ask": "bin/pillar-ask.mjs",
|
|
12
12
|
"pillar-responder": "bin/pillar-responder.mjs",
|
|
13
|
-
"pillar-seed": "bin/pillar-seed.mjs"
|
|
13
|
+
"pillar-seed": "bin/pillar-seed.mjs",
|
|
14
|
+
"pillar-escrow": "bin/pillar-escrow.mjs"
|
|
14
15
|
},
|
|
15
16
|
"exports": {
|
|
16
|
-
".":
|
|
17
|
+
".": "./src/index.mjs",
|
|
18
|
+
"./keychain": "./src/identity/keychain.mjs",
|
|
19
|
+
"./jcs": "./src/identity/jcs.mjs",
|
|
20
|
+
"./tier": "./src/identity/tier.mjs",
|
|
21
|
+
"./envelope": "./src/net/envelope.mjs",
|
|
22
|
+
"./e2e": "./src/crypto/e2e.mjs",
|
|
23
|
+
"./carrier-client":"./src/net/carrier-client.mjs",
|
|
24
|
+
"./package.json": "./package.json"
|
|
17
25
|
},
|
|
18
26
|
"files": [
|
|
19
27
|
"src",
|
|
@@ -33,6 +41,8 @@
|
|
|
33
41
|
},
|
|
34
42
|
"dependencies": {
|
|
35
43
|
"@chainsafe/libp2p-noise": "^17.0.0",
|
|
44
|
+
"@noble/curves": "^2.3.0",
|
|
45
|
+
"@noble/hashes": "^2.3.0",
|
|
36
46
|
"@chainsafe/libp2p-yamux": "^8.0.1",
|
|
37
47
|
"@libp2p/autonat": "^3.0.26",
|
|
38
48
|
"@libp2p/bootstrap": "^12.0.29",
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content box — symmetric AES-256-GCM under the escrowed 32-byte content
|
|
3
|
+
* key K. The seller seals the deliverable with K and sends the box directly
|
|
4
|
+
* to the buyer; the escrow holds only K (and the sha256 of the plaintext, in
|
|
5
|
+
* the signed terms) — it never sees the payload. The buyer can only open
|
|
6
|
+
* the box after K is released, and the GCM tag makes any tamper or wrong key
|
|
7
|
+
* fail loudly.
|
|
8
|
+
*/
|
|
9
|
+
import { randomBytes, createCipheriv, createDecipheriv, createHash } from "node:crypto";
|
|
10
|
+
|
|
11
|
+
export const BOX_ALG = "aes-256-gcm";
|
|
12
|
+
|
|
13
|
+
export function sealBox(key32, plaintext) {
|
|
14
|
+
if (!Buffer.isBuffer(key32) || key32.length !== 32) throw new Error("sealBox: key must be 32 bytes");
|
|
15
|
+
const iv = randomBytes(12);
|
|
16
|
+
const c = createCipheriv(BOX_ALG, key32, iv);
|
|
17
|
+
const ct = Buffer.concat([c.update(plaintext), c.final()]);
|
|
18
|
+
return {
|
|
19
|
+
alg: BOX_ALG,
|
|
20
|
+
iv: iv.toString("hex"),
|
|
21
|
+
ct: Buffer.concat([ct, c.getAuthTag()]).toString("hex"), // tag appended to ct
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Throws on wrong key, tamper, or malformed box — never returns garbage. */
|
|
26
|
+
export function openBox(key32, box) {
|
|
27
|
+
if (!Buffer.isBuffer(key32) || key32.length !== 32) throw new Error("openBox: key must be 32 bytes");
|
|
28
|
+
if (!box || box.alg !== BOX_ALG || typeof box.iv !== "string" || typeof box.ct !== "string") {
|
|
29
|
+
throw new Error("openBox: bad box");
|
|
30
|
+
}
|
|
31
|
+
const iv = Buffer.from(box.iv, "hex");
|
|
32
|
+
const buf = Buffer.from(box.ct, "hex");
|
|
33
|
+
if (iv.length !== 12 || buf.length < 16) throw new Error("openBox: bad lengths");
|
|
34
|
+
const ct = buf.subarray(0, buf.length - 16);
|
|
35
|
+
const tag = buf.subarray(buf.length - 16);
|
|
36
|
+
const d = createDecipheriv(BOX_ALG, key32, iv);
|
|
37
|
+
d.setAuthTag(tag);
|
|
38
|
+
return Buffer.concat([d.update(ct), d.final()]);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function sha256Hex(buf) {
|
|
42
|
+
return createHash("sha256").update(buf).digest("hex");
|
|
43
|
+
}
|
|
@@ -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 };
|