@uuaid/pillar 0.2.0-alpha.11 → 0.2.0-alpha.12

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.
@@ -0,0 +1,674 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * pillar-escrow — ZGN token exchange WITHOUT crypto exchanges: a blind
4
+ * escrow on the UUAID pillar + Zilligon ZGN rail.
5
+ *
6
+ * Roles (three ordinary identities; the escrow holds only a 32-byte key):
7
+ *
8
+ * seller ──offer(signed terms + K)──▶ escrow
9
+ * seller ◀─────────offer-ack───────── escrow
10
+ * seller ──deliverable(ciphertext)──▶ buyer (escrow never sees it)
11
+ * buyer ───accept(signed terms)─────▶ escrow
12
+ * buyer ◀────────accept-ack────────── escrow
13
+ * buyer ──rail transfer of price───▶ escrowHandle (Zilligon ZGN rail)
14
+ * buyer ─────paid {offerId,txId}───▶ escrow
15
+ * escrow attests from its own wallet, then:
16
+ * rail transfer of payout ─▶ sellerHandle
17
+ * key {K, receipt} ────────▶ buyer (buyer opens box, checks hash)
18
+ * receipt ─────────────────▶ seller
19
+ *
20
+ * Subcommands:
21
+ * init create a home identity (+ zilligon.json template)
22
+ * run-escrow run the escrow service (journal-first, crash-recoverable)
23
+ * sell one-shot seller flow (needs buyer+escrow identities, no rail creds)
24
+ * buy one-shot buyer flow (pays on the rail, then opens the box)
25
+ *
26
+ * Carrier-only by default (outbound HTTPS only). Secrets: the Zilligon
27
+ * apiKey is read from a keyfile and NEVER printed.
28
+ *
29
+ * pillar-escrow init --home ~/.pillar-escrow
30
+ * pillar-escrow run-escrow --home ~/.pillar-escrow --carrier https://carrier.example
31
+ * pillar-escrow sell --home ~/.pillar-seller --file deliverable.bin \
32
+ * --buyer uuaid:... --buyer-pubkey <hex> --buyer-handle @b \
33
+ * --escrow uuaid:... --escrow-pubkey <hex> --escrow-handle @e \
34
+ * --seller-handle @s --price 100 --fee-allocation split \
35
+ * --carrier https://carrier.example
36
+ * pillar-escrow buy --home ~/.pillar-buyer --terms terms.json \
37
+ * --zilligon-keyfile ~/.pillar-buyer/zilligon.json \
38
+ * --out received.bin --carrier https://carrier.example
39
+ *
40
+ * The rail's tip fee is OUTSIDE the two-party agreement: sell derives the
41
+ * payout ONLY from an explicit choice — --fee-allocation seller|buyer|split
42
+ * (who absorbs the fee: the seller, the buyer, or 50/50) or --payout N (the
43
+ * parties' own number; a "mutual" agreement). No default is ever derived.
44
+ */
45
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
46
+ import { join, dirname } from "node:path";
47
+ import { homedir } from "node:os";
48
+ import { argv, exit } from "node:process";
49
+ import { randomBytes } from "node:crypto";
50
+ import { Keychain } from "../src/identity/keychain.mjs";
51
+ import { Pillar } from "../src/pillar.mjs";
52
+ import { ZilligonClient, txIdOf } from "../src/escrow/zilligon.mjs";
53
+ import { EscrowService } from "../src/escrow/escrow-service.mjs";
54
+ import { EvmRail } from "../src/escrow/rails/evm-rail.mjs";
55
+ import { RailSet } from "../src/escrow/rails/index.mjs";
56
+ import { TokenRegistry } from "../src/escrow/token-registry.mjs";
57
+ import { TransferVerifier } from "../src/escrow/verify.mjs";
58
+ import { verifyReceipt, quorumReport, verifyEnvelope, envelopeSha256, RECEIPT_KIND, VDA_V } from "../src/vda/core.mjs";
59
+ import { VerifierDirectory } from "../src/vda/directory.mjs";
60
+ import { makeDeliveryAck, attestDataDelivery, contentHashOf } from "../src/vda/instruments.mjs";
61
+ import {
62
+ makeTerms, signTerms, verifyReceiptSig, validatePayout, fingerprint, allocateFee,
63
+ } from "../src/escrow/terms.mjs";
64
+ import { sealBox, openBox, sha256Hex } from "../src/escrow/box.mjs";
65
+
66
+ const MAX_CONTENT_BYTES = 64 * 1024; // alpha cap
67
+ const HEX64 = /^[0-9a-f]{64}$/i;
68
+ const DEFAULT_OFFER_TTL_MS = 24 * 3600_000;
69
+
70
+ function parseArgs(argv) {
71
+ const args = { _: [] };
72
+ for (let i = 0; i < argv.length; i++) {
73
+ const a = argv[i];
74
+ if (a.startsWith("--")) {
75
+ const [k, ...rest] = a.slice(2).split("=");
76
+ args[k] = rest.length ? rest.join("=") : (argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : true);
77
+ } else {
78
+ args._.push(a);
79
+ }
80
+ }
81
+ return args;
82
+ }
83
+
84
+ function homePath(args) {
85
+ return args.home || process.env.PILLAR_HOME || join(homedir(), ".pillar");
86
+ }
87
+
88
+ function loadKeychain(home, args) {
89
+ const kcPath = join(home, "kc.json");
90
+ if (!existsSync(kcPath)) {
91
+ console.error(`no pillar identity at ${home}. run: pillar-escrow init --home ${home}`);
92
+ exit(2);
93
+ }
94
+ const kc = new Keychain(kcPath);
95
+ kc.load({ passphrase: process.env.PILLAR_PASSPHRASE ?? "" });
96
+ return kc;
97
+ }
98
+
99
+ function carriersOf(args, cfg) {
100
+ return args.carrier
101
+ ? [args.carrier]
102
+ : (process.env.PILLAR_CARRIERS ? process.env.PILLAR_CARRIERS.split(",") : (cfg.carriers ?? []));
103
+ }
104
+
105
+ function requireCarriers(carriers) {
106
+ if (!carriers.length) {
107
+ console.error("needs at least one carrier: --carrier URL, PILLAR_CARRIERS, or carriers[] in config.json");
108
+ exit(2);
109
+ }
110
+ }
111
+
112
+ async function makePillar(home, kc, carriers) {
113
+ const p = new Pillar({
114
+ keychain: kc,
115
+ mailboxPath: join(home, "mailbox.db"),
116
+ carriers,
117
+ enableLibp2p: false,
118
+ });
119
+ await p.start();
120
+ return p;
121
+ }
122
+
123
+ function readZilligonKeyfile(path) {
124
+ if (!existsSync(path)) {
125
+ console.error(`zilligon keyfile not found: ${path} (run init — it writes a template; put the apiKey inside)`);
126
+ exit(2);
127
+ }
128
+ const f = JSON.parse(readFileSync(path, "utf-8"));
129
+ if (!f.apiKey) {
130
+ console.error(`zilligon keyfile ${path} has no apiKey field`);
131
+ exit(2);
132
+ }
133
+ return f; // { apiKey, handle? } — values never printed
134
+ }
135
+
136
+ const offerIdOf = (env) => env.payload?.offerId ?? env.payload?.receipt?.offerId;
137
+
138
+ /**
139
+ * Wait for any of the given kinds carrying a matching offerId, polling the
140
+ * carrier inbox. Racing the success kind against "escrow.error" means a
141
+ * failure surfaces as soon as it arrives instead of after the full timeout.
142
+ * Returns {kind, env} or null on timeout.
143
+ */
144
+ function waiter(pillar) {
145
+ const q = new Map(); // kind -> [env]
146
+ pillar.onEnvelope((env) => {
147
+ if (!q.has(env.kind)) q.set(env.kind, []);
148
+ q.get(env.kind).push(env);
149
+ });
150
+ return async function waitEither(offerId, kinds, timeoutMs) {
151
+ const deadline = Date.now() + timeoutMs;
152
+ for (;;) {
153
+ for (const kind of kinds) {
154
+ const hit = (q.get(kind) ?? []).find((env) => offerIdOf(env) === offerId);
155
+ if (hit) return { kind, env: hit };
156
+ }
157
+ if (Date.now() > deadline) return null;
158
+ await pillar.drainCarrierInbox();
159
+ await new Promise((r) => setTimeout(r, 1000));
160
+ }
161
+ };
162
+ }
163
+
164
+ const die = (msg) => { console.error(msg); exit(2); };
165
+ const say = (msg) => console.error(`[pillar-escrow] ${msg}`); // stderr: progress; stdout: machine results
166
+ const escrowErr = (r) => `escrow error: ${r.env.payload.code} — ${r.env.payload.message}`;
167
+
168
+ /* ------------------------------------------------ init ------------------- */
169
+
170
+ async function cmdInit(args) {
171
+ const home = homePath(args);
172
+ const kcPath = join(home, "kc.json");
173
+ if (existsSync(kcPath)) die(`identity already exists at ${kcPath}`);
174
+ mkdirSync(home, { recursive: true });
175
+ const kc = new Keychain(kcPath);
176
+ kc.save(Keychain.generate(), { passphrase: process.env.PILLAR_PASSPHRASE ?? "" });
177
+ kc.load({ passphrase: process.env.PILLAR_PASSPHRASE ?? "" });
178
+ const zf = join(home, "zilligon.json");
179
+ if (!existsSync(zf)) {
180
+ writeFileSync(zf, JSON.stringify({ apiKey: "", handle: "" }, null, 2) + "\n", { mode: 0o600 });
181
+ }
182
+ const cfg = join(home, "config.json");
183
+ if (!existsSync(cfg)) writeFileSync(cfg, JSON.stringify({ carriers: [] }, null, 2) + "\n");
184
+ console.log(JSON.stringify({
185
+ home,
186
+ uuaid: kc._identity.uuaid,
187
+ publicKeyHex: kc._identity.publicKeyHex,
188
+ next: [
189
+ "register a handle on zilligon.com if you don't have one",
190
+ `put {apiKey, handle} into ${zf} (chmod 600)`,
191
+ "for a carrier: pillar-escrow run-escrow --carrier <url>",
192
+ ],
193
+ }, null, 2));
194
+ }
195
+
196
+ /* ------------------------------------------------ run-escrow ------------- */
197
+
198
+ async function cmdRunEscrow(args) {
199
+ const home = homePath(args);
200
+ const cfg = existsSync(join(home, "config.json")) ? JSON.parse(readFileSync(join(home, "config.json"), "utf-8")) : {};
201
+ const kc = loadKeychain(home, args);
202
+ const carriers = carriersOf(args, cfg);
203
+ requireCarriers(carriers);
204
+ const zf = readZilligonKeyfile(args["zilligon-keyfile"] ?? join(home, "zilligon.json"));
205
+ const railHandle = args["zilligon-handle"] ?? zf.handle ?? cfg.zilligon?.handle;
206
+ if (!railHandle) die("escrow needs a Zilligon handle (--zilligon-handle or handle in the keyfile)");
207
+ const zilligon = new ZilligonClient({ apiKey: zf.apiKey, handle: railHandle, baseUrl: args["zilligon-base"] ?? "https://zilligon.com" });
208
+
209
+ // Optional EVM rail (v2 terms): --evm-rpc + --evm-chain + --evm-address
210
+ // (or cfg.evm / PILLAR_EVM_RPC, PILLAR_EVM_CHAIN, PILLAR_EVM_ADDRESS).
211
+ // The signing key is NEVER a flag or a config value: EvmRail reads
212
+ // PILLAR_EVM_KEY at signing time (or a custom provider in library use).
213
+ const rails = [];
214
+ const evmRpc = args["evm-rpc"] ?? process.env.PILLAR_EVM_RPC ?? cfg.evm?.rpcUrl;
215
+ if (evmRpc) {
216
+ const evmChain = Number(args["evm-chain"] ?? process.env.PILLAR_EVM_CHAIN ?? cfg.evm?.chainId);
217
+ const evmAddress = args["evm-address"] ?? process.env.PILLAR_EVM_ADDRESS ?? cfg.evm?.address;
218
+ if (!Number.isInteger(evmChain) || !evmAddress) die("evm rail needs --evm-chain and --evm-address alongside --evm-rpc");
219
+ rails.push(new EvmRail({
220
+ rpcUrl: evmRpc, chainId: evmChain, handle: evmAddress,
221
+ confirmations: Number(args["evm-confirmations"] ?? cfg.evm?.confirmations ?? 15),
222
+ }));
223
+ }
224
+
225
+ // Asset allowlist: when the EVM rail is on, the token registry is wired by
226
+ // DEFAULT (fail closed — unregistered chains/tokens are rejected at offer
227
+ // time). --no-token-registry opts a private bilateral escrow out.
228
+ let tokenRegistry = null;
229
+ if (rails.length && !args["no-token-registry"]) {
230
+ tokenRegistry = new TokenRegistry({ dir: registryDir(args, home, cfg) });
231
+ }
232
+
233
+ const pillar = await makePillar(home, kc, carriers);
234
+ const service = new EscrowService({
235
+ keychain: kc, pillar, zilligon, rails,
236
+ journalPath: join(home, "escrow-journal.jsonl"),
237
+ railHandle, tokenRegistry,
238
+ });
239
+ service.start({ reconcileMs: Number(args["reconcile-ms"] ?? 30_000) });
240
+ pillar.startCarrierPolling({ waitS: Number(args["poll-wait"] ?? 20) });
241
+ console.error(`escrow up: ${kc._identity.uuaid} | rail @${railHandle}${rails.length ? ` + ${rails.map((r) => r.railKey).join(", ")}` : ""} | journal ${home}/escrow-journal.jsonl`);
242
+ console.error(`carriers: ${carriers.join(", ")}`);
243
+ }
244
+
245
+ /* ------------------------------------------------ sell ------------------- */
246
+
247
+ async function cmdSell(args) {
248
+ const home = homePath(args);
249
+ const cfg = existsSync(join(home, "config.json")) ? JSON.parse(readFileSync(join(home, "config.json"), "utf-8")) : {};
250
+ const kc = loadKeychain(home, args);
251
+ const carriers = carriersOf(args, cfg);
252
+ requireCarriers(carriers);
253
+ const file = args.file;
254
+ const price = Number(args.price);
255
+ if (!file || !existsSync(file)) die("sell needs --file <path>");
256
+ if (!Number.isInteger(price) || price < 1) die("--price must be an integer >= 1 Gons");
257
+ if (!args["seller-handle"]) die("sell needs --seller-handle (your Zilligon @handle — the escrow pays it)");
258
+ for (const k of ["buyer", "escrow"]) {
259
+ if (!args[k] || !args[`${k}-pubkey`] || !args[`${k}-handle`]) die(`sell needs --${k}, --${k}-pubkey, --${k}-handle`);
260
+ if (!HEX64.test(args[`${k}-pubkey`])) die(`--${k}-pubkey must be 64 hex chars`);
261
+ }
262
+ const buf = readFileSync(file);
263
+ if (buf.length > MAX_CONTENT_BYTES) die(`file larger than ${MAX_CONTENT_BYTES} bytes (alpha cap)`);
264
+ // Fee allocation is the PARTIES' deal, never the escrow's: an explicit
265
+ // --payout (mutual agreement) or an explicit --fee-allocation — no default.
266
+ let finalPrice = price;
267
+ let sellerPayout;
268
+ const allocation = args["fee-allocation"];
269
+ if (args.payout !== undefined) {
270
+ if (allocation !== undefined) die("--payout and --fee-allocation are mutually exclusive");
271
+ sellerPayout = Number(args.payout);
272
+ if (!Number.isInteger(sellerPayout) || sellerPayout < 0) die("--payout must be an integer >= 0");
273
+ } else if (allocation !== undefined) {
274
+ if (!["seller", "buyer", "split", "mutual"].includes(allocation)) {
275
+ die(`--fee-allocation must be seller|buyer|split|mutual, got "${allocation}"`);
276
+ }
277
+ const alloc = allocateFee(price, allocation);
278
+ if (alloc === null) die('--fee-allocation mutual requires explicit --payout (the parties agree the number themselves)');
279
+ finalPrice = alloc.priceGons;
280
+ sellerPayout = alloc.sellerPayoutGons;
281
+ } else {
282
+ die("sell needs --fee-allocation seller|buyer|split (who absorbs the rail tip fee) or an explicit --payout (a mutual agreement) — no default is derived");
283
+ }
284
+ const pay = validatePayout(finalPrice, sellerPayout);
285
+ if (!pay.ok) die(`infeasible payout: ${pay.reason} (escrow receives ${pay.escrowReceived}, would send ${pay.escrowSend})`);
286
+
287
+ const K = randomBytes(32);
288
+ const box = sealBox(K, buf);
289
+ const itemHash = sha256Hex(buf);
290
+ const terms = makeTerms({
291
+ escrow: args.escrow, escrowPubKeyHex: args["escrow-pubkey"], escrowHandle: args["escrow-handle"],
292
+ seller: kc._identity.uuaid, sellerPubKeyHex: kc._identity.publicKeyHex, sellerHandle: args["seller-handle"],
293
+ buyer: args.buyer, buyerPubKeyHex: args["buyer-pubkey"], buyerHandle: args["buyer-handle"],
294
+ itemHash, priceGons: finalPrice, sellerPayoutGons: sellerPayout,
295
+ expiresAt: args.expires ?? new Date(Date.now() + DEFAULT_OFFER_TTL_MS).toISOString(),
296
+ });
297
+ const sellerSig = signTerms(kc, terms);
298
+ const pillar = await makePillar(home, kc, carriers);
299
+ const waitEither = waiter(pillar);
300
+ const send = async (recipient, pubkey, kind, payload) => pillar.send({ recipient, recipientPublicKey: pubkey, kind, payload });
301
+
302
+ say(`offer ${terms.offerId} — ${buf.length} bytes, price ${finalPrice} Gons`);
303
+ await send(terms.escrow, terms.escrowPubKeyHex, "escrow.offer", { terms, sellerSig, contentKeyHex: K.toString("hex") });
304
+ const ack = await waitEither(terms.offerId, ["escrow.offer-ack", "escrow.error"], 90_000);
305
+ if (!ack) die(`no escrow ack within 90s (escrow ${terms.escrow} unreachable?)`);
306
+ if (ack.kind === "escrow.error") die(escrowErr(ack));
307
+ say("escrow accepted the offer — sending deliverable to buyer");
308
+ await send(terms.buyer, terms.buyerPubKeyHex, "escrow.deliverable", { offerId: terms.offerId, box, itemHash, terms });
309
+
310
+ const done = await waitEither(terms.offerId, ["escrow.receipt", "escrow.error"], 10 * 60_000);
311
+ if (!done) die("no receipt within 10 min — buyer never completed (offer still open; re-run to re-offer)");
312
+ if (done.kind === "escrow.error") die(escrowErr(done));
313
+ const { receipt } = done.env.payload;
314
+ if (!verifyReceiptSig(terms.escrowPubKeyHex, receipt)) die("RECEIPT SIGNATURE INVALID — do not trust this completion");
315
+ const out = args["receipt-out"] ?? join(home, "sell-receipts", `${terms.offerId}.json`);
316
+ mkdirSync(dirname(out), { recursive: true });
317
+ writeFileSync(out, JSON.stringify(receipt, null, 2) + "\n");
318
+ console.log(JSON.stringify({
319
+ ok: true, offerId: terms.offerId, role: "seller",
320
+ priceGons: finalPrice, payoutGons: sellerPayout, feeAllocation: allocation ?? "mutual",
321
+ receiptPath: out,
322
+ }, null, 2));
323
+ }
324
+
325
+ /* ------------------------------------------------ buy -------------------- */
326
+
327
+ async function cmdBuy(args) {
328
+ const home = homePath(args);
329
+ const cfg = existsSync(join(home, "config.json")) ? JSON.parse(readFileSync(join(home, "config.json"), "utf-8")) : {};
330
+ const kc = loadKeychain(home, args);
331
+ const carriers = carriersOf(args, cfg);
332
+ requireCarriers(carriers);
333
+ const termsPath = args.terms;
334
+ const out = args.out;
335
+ if (!termsPath || !existsSync(termsPath)) die("buy needs --terms <terms.json> (from the seller, out of band)");
336
+ if (!out) die("buy needs --out <file> for the decrypted deliverable");
337
+ const terms = JSON.parse(readFileSync(termsPath, "utf-8"));
338
+ if (terms.offerId !== fingerprint(terms)) die("terms tampered: offerId does not match contents");
339
+ if (terms.buyer !== kc._identity.uuaid) die(`terms are for buyer ${terms.buyer}, this identity is ${kc._identity.uuaid}`);
340
+ if (terms.buyerPubKeyHex !== kc._identity.publicKeyHex) die("terms.buyerPubKeyHex does not match this identity's key — escrow would reject the accept");
341
+ if (Date.now() > Date.parse(terms.expiresAt)) die("offer expired");
342
+ const zf = readZilligonKeyfile(args["zilligon-keyfile"] ?? join(home, "zilligon.json"));
343
+ if (!zf.handle || zf.handle !== terms.buyerHandle) die(`keyfile handle ${zf.handle} does not match terms.buyerHandle ${terms.buyerHandle}`);
344
+
345
+ const pillar = await makePillar(home, kc, carriers);
346
+ const waitEither = waiter(pillar);
347
+ const send = async (recipient, pubkey, kind, payload) => pillar.send({ recipient, recipientPublicKey: pubkey, kind, payload });
348
+
349
+ const buyerSig = signTerms(kc, terms);
350
+ say(`accepting offer ${terms.offerId} (price ${terms.priceGons} Gons)`);
351
+ await send(terms.escrow, terms.escrowPubKeyHex, "escrow.accept", { offerId: terms.offerId, buyerSig });
352
+ const ack = await waitEither(terms.offerId, ["escrow.accept-ack", "escrow.error"], 90_000);
353
+ if (!ack) die("no accept-ack within 90s");
354
+ if (ack.kind === "escrow.error") die(escrowErr(ack));
355
+ say("accepted — waiting for the deliverable box");
356
+ const dv = await waitEither(terms.offerId, ["escrow.deliverable", "escrow.error"], 90_000);
357
+ if (!dv) die("no deliverable within 90s (seller gone?)");
358
+ if (dv.kind === "escrow.error") die(escrowErr(dv));
359
+
360
+ // Money moves here — on the rail, directly to the escrow's handle.
361
+ const zilligon = new ZilligonClient({
362
+ apiKey: zf.apiKey, handle: terms.buyerHandle, baseUrl: args["zilligon-base"] ?? "https://zilligon.com",
363
+ });
364
+ say(`paying ${terms.priceGons} Gons to @${terms.escrowHandle} on the Zilligon rail`);
365
+ const res = await zilligon.transfer({
366
+ recipientHandle: terms.escrowHandle, amount: terms.priceGons, message: `escrow ${terms.offerId.slice(0, 8)}`,
367
+ });
368
+ const txId = txIdOf(res?.data?.transaction ?? res?.data ?? {});
369
+ say(`rail transfer done (tx ${txId ?? "unknown-id"}) — claiming payment with the escrow`);
370
+ // The transfer response's transactionId IS both wallet rows' sourceId
371
+ // (verified live 2026-08-21) — claim it so the escrow can counterparty-bind.
372
+ await send(terms.escrow, terms.escrowPubKeyHex, "escrow.paid", {
373
+ offerId: terms.offerId, txId, ...(txId ? { sourceId: txId } : {}),
374
+ });
375
+
376
+ const keyEnv = await waitEither(terms.offerId, ["escrow.key", "escrow.error"], 10 * 60_000);
377
+ if (!keyEnv) die("no key within 10 min (escrow could not attest the payment?)");
378
+ if (keyEnv.kind === "escrow.error") die(escrowErr(keyEnv));
379
+ const { contentKeyHex, receipt } = keyEnv.env.payload;
380
+ if (!verifyReceiptSig(terms.escrowPubKeyHex, receipt)) die("RECEIPT SIGNATURE INVALID — do not trust this completion");
381
+ const plain = openBox(Buffer.from(contentKeyHex, "hex"), dv.env.payload.box);
382
+ if (sha256Hex(plain) !== terms.itemHash) die("deliverable hash mismatch — box does not match the signed terms");
383
+ writeFileSync(out, plain);
384
+ const receiptOut = args["receipt-out"] ?? join(home, "buy-receipts", `${terms.offerId}.json`);
385
+ mkdirSync(dirname(receiptOut), { recursive: true });
386
+ writeFileSync(receiptOut, JSON.stringify(receipt, null, 2) + "\n");
387
+ console.log(JSON.stringify({ ok: true, offerId: terms.offerId, role: "buyer", priceGons: terms.priceGons, file: out, receiptPath: receiptOut }, null, 2));
388
+ }
389
+
390
+ /* ------------------------------------------------ token registry --------- */
391
+
392
+ function registryDir(args, home, cfg = {}) {
393
+ return args["token-registry"] ?? cfg.tokenRegistry ?? join(home, "token-registry");
394
+ }
395
+
396
+ function openRegistry(args) {
397
+ const home = homePath(args);
398
+ const cfg = existsSync(join(home, "config.json")) ? JSON.parse(readFileSync(join(home, "config.json"), "utf-8")) : {};
399
+ return new TokenRegistry({ dir: registryDir(args, home, cfg) });
400
+ }
401
+
402
+ /**
403
+ * File a chain or token inclusion application. Open to any developer — the
404
+ * application is untrusted input by design; the reviewer decision is the gate.
405
+ */
406
+ async function cmdTokenApply(args) {
407
+ const reg = openRegistry(args);
408
+ const kind = args._[0] === "chain-apply" ? "chain" : "token";
409
+ const sub = { kind, chainId: Number(args.chain), name: args.name, applicant: args.applicant };
410
+ if (args.contact) sub.contact = args.contact;
411
+ if (args.evidence) sub.evidence = String(args.evidence).split(",").map((s) => s.trim()).filter(Boolean);
412
+ if (kind === "chain") {
413
+ sub.nativeSymbol = args["native-symbol"];
414
+ sub.nativeDecimals = Number(args["native-decimals"] ?? 18);
415
+ if (args.rpc) sub.rpcUrl = args.rpc;
416
+ if (args.explorer) sub.explorerUrl = args.explorer;
417
+ } else {
418
+ sub.address = args.address;
419
+ sub.symbol = args.symbol;
420
+ sub.decimals = Number(args.decimals ?? 18);
421
+ }
422
+ const r = reg.submitApplication(sub);
423
+ if (!r.ok) die(`application rejected: ${r.reason}`);
424
+ console.log(JSON.stringify(r, null, 2));
425
+ }
426
+
427
+ async function cmdTokenList(args) {
428
+ const reg = openRegistry(args);
429
+ const status = typeof args.status === "string" ? args.status : null;
430
+ console.log(JSON.stringify({
431
+ chains: reg.chains, tokens: reg.tokens,
432
+ applications: reg.listApplications(status).map(({ id, status: s, submittedAt, reviewer, submission }) => ({
433
+ id, status: s, submittedAt, ...(reviewer ? { reviewer } : {}),
434
+ kind: submission.kind, chainId: submission.chainId,
435
+ ...(submission.address ? { address: submission.address } : {}),
436
+ ...(submission.symbol ? { symbol: submission.symbol } : {}),
437
+ name: submission.name, applicant: submission.applicant,
438
+ })),
439
+ }, null, 2));
440
+ }
441
+
442
+ /** Operator-side decision. Approval writes the allowlist entry with provenance. */
443
+ async function cmdTokenReview(args) {
444
+ const reg = openRegistry(args);
445
+ const id = args._[1];
446
+ if (!id) die("token-review needs an application id (see token-list)");
447
+ const approve = Boolean(args.approve);
448
+ if (!approve && !args.reject) die("token-review needs --approve or --reject");
449
+ const reviewer = typeof args.reviewer === "string" ? args.reviewer : null;
450
+ if (!reviewer) die("token-review needs --reviewer <name>");
451
+ const r = reg.decideApplication(id, { approve, reviewer, notes: typeof args.notes === "string" ? args.notes : "" });
452
+ if (!r.ok) die(`review failed: ${r.reason}${r.message ? ` — ${r.message}` : ""}`);
453
+ console.log(JSON.stringify(r, null, 2));
454
+ }
455
+
456
+ /* ------------------------------------------------ verify ----------------- */
457
+
458
+ /**
459
+ * Standalone transfer verification: attest ANY (from, to, amount, asset)
460
+ * transfer from the chain's own record and emit a signed, content-addressed
461
+ * attestation any holder can check offline (verify-attestation). No carrier,
462
+ * no escrow, no rail credentials — read-only RPC + the local identity.
463
+ *
464
+ * pillar-escrow verify --tx 0x... --to 0x... --amount 15000000000000000 \
465
+ * --chain 137 [--from 0x...] [--asset erc20:0x...] [--evm-rpc URL] \
466
+ * [--confirmations 15] [--out att.json] [--no-sign] [--no-token-registry]
467
+ * pillar-escrow verify-attestation --file att.json --pubkey <hex>
468
+ *
469
+ * Exit code mirrors the verdict: 0 verified, 3 pending, 1 anything else —
470
+ * scriptable as a settlement gate.
471
+ */
472
+ async function cmdVerify(args) {
473
+ const home = homePath(args);
474
+ const cfg = existsSync(join(home, "config.json")) ? JSON.parse(readFileSync(join(home, "config.json"), "utf-8")) : {};
475
+ const chainId = Number(args.chain ?? process.env.PILLAR_EVM_CHAIN ?? cfg.evm?.chainId);
476
+ if (!Number.isInteger(chainId)) die("verify needs --chain <id>");
477
+ const registry = args["no-token-registry"] ? null : new TokenRegistry({ dir: registryDir(args, home, cfg) });
478
+ const rpcUrl = args["evm-rpc"] ?? process.env.PILLAR_EVM_RPC ?? cfg.evm?.rpcUrl ?? registry?.chains?.[String(chainId)]?.rpcUrl;
479
+ if (!rpcUrl) die("verify needs an rpc (--evm-rpc, PILLAR_EVM_RPC, config, or a registered chain with rpcUrl)");
480
+ if (typeof args.to !== "string" || typeof args.tx !== "string" || args.amount == null) {
481
+ die("verify needs --tx <hash> --to <0xaddr> --amount <base-units>");
482
+ }
483
+ // handle is only used as the rail's self-address; standalone claims never
484
+ // reference it, so a syntactically valid placeholder is fine.
485
+ const rail = new EvmRail({
486
+ rpcUrl, chainId, handle: "0x" + "00".repeat(20),
487
+ confirmations: Number(args.confirmations ?? cfg.evm?.confirmations ?? 15),
488
+ });
489
+ const kcPath = join(home, "kc.json");
490
+ const kc = !args["no-sign"] && existsSync(kcPath) ? loadKeychain(home, args) : null;
491
+ const verifier = new TransferVerifier({ rails: new RailSet([rail]), keychain: kc, tokenRegistry: registry });
492
+ const att = await verifier.verify({
493
+ rail: "evm", chainId, txId: args.tx,
494
+ from: typeof args.from === "string" ? args.from : null,
495
+ to: args.to, amount: String(args.amount),
496
+ asset: typeof args.asset === "string" ? args.asset : "native",
497
+ });
498
+ if (typeof args.out === "string") {
499
+ mkdirSync(dirname(args.out), { recursive: true });
500
+ writeFileSync(args.out, JSON.stringify(att, null, 2) + "\n");
501
+ }
502
+ console.log(JSON.stringify(att, null, 2));
503
+ exit(att.verdict === "verified" ? 0 : att.verdict === "pending" ? 3 : 1);
504
+ }
505
+
506
+ /** Offline check of a signed attestation against the verifier's pubkey. */
507
+ async function cmdVerifyAttestation(args) {
508
+ if (typeof args.file !== "string" || typeof args.pubkey !== "string") {
509
+ die("verify-attestation needs --file <attestation.json> --pubkey <verifier-ed25519-hex>");
510
+ }
511
+ const att = JSON.parse(readFileSync(args.file, "utf-8"));
512
+ // verifyEnvelope handles both VDA-1 (full-hash id + claim-conformance) and
513
+ // legacy transfer-attestation (16-hex id) envelopes.
514
+ const r = verifyEnvelope(args.pubkey, att);
515
+ console.log(JSON.stringify({ ...r, verdict: att.verdict, attestationId: att.attestationId, verifier: att.verifier }, null, 2));
516
+ exit(r.ok ? 0 : 1);
517
+ }
518
+
519
+ /* ------------------------------------------------ vda -------------------- */
520
+
521
+ function loadDirectory(args, { warnUnpinned = false } = {}) {
522
+ if (typeof args.directory !== "string") return null;
523
+ const expectedId = typeof args["directory-id"] === "string" ? args["directory-id"] : null;
524
+ if (!expectedId && warnUnpinned) {
525
+ // spec §8: the directory is the identity root; an unpinned load is
526
+ // trust-on-first-use of whatever file is at the path
527
+ console.error("[pillar-escrow] WARNING: --directory loaded WITHOUT --directory-id — identity root is unpinned (TOFU); supply the out-of-band directoryId for a conformant verification");
528
+ }
529
+ return VerifierDirectory.fromFile(args.directory, { expectedId });
530
+ }
531
+
532
+ function directoryOrPubkeyResolver(args) {
533
+ const dir = loadDirectory(args, { warnUnpinned: true });
534
+ if (dir) return dir.resolver();
535
+ if (typeof args.pubkey === "string") return () => args.pubkey;
536
+ return null;
537
+ }
538
+
539
+ /**
540
+ * Recipient side of data-delivery: sign an acknowledgment for received bytes.
541
+ * pillar-escrow ack --file <bytes> --from <uuaid> [--envelope-id ID] [--out ack.json]
542
+ */
543
+ async function cmdAck(args) {
544
+ const home = homePath(args);
545
+ const kc = loadKeychain(home, args);
546
+ if (typeof args.file !== "string" || typeof args.from !== "string") {
547
+ die("ack needs --file <received-bytes> --from <sender-uuaid>");
548
+ }
549
+ const bytes = readFileSync(args.file);
550
+ const r = makeDeliveryAck({
551
+ keychain: kc, contentHash: contentHashOf(bytes),
552
+ from: args.from, to: kc._identity.uuaid,
553
+ envelopeId: typeof args["envelope-id"] === "string" ? args["envelope-id"] : null,
554
+ });
555
+ const doc = { v: 1, kind: "vda-delivery-ack-bundle", ...r };
556
+ if (typeof args.out === "string") {
557
+ mkdirSync(dirname(args.out), { recursive: true });
558
+ writeFileSync(args.out, JSON.stringify(doc, null, 2) + "\n");
559
+ }
560
+ console.log(JSON.stringify(doc, null, 2));
561
+ }
562
+
563
+ /**
564
+ * Attest a data delivery from a signed ack (spec §6.2), optionally binding
565
+ * the settlement attestation into a dual-proof receipt (spec §7).
566
+ * pillar-escrow attest-delivery --ack ack.json --content-hash H --from U1 --to U2 \
567
+ * [--directory dir.json] [--binds attId,attId] [--bundle settlement.json] [--out receipt.json]
568
+ */
569
+ async function cmdAttestDelivery(args) {
570
+ const home = homePath(args);
571
+ const kc = existsSync(join(home, "kc.json")) && !args["no-sign"] ? loadKeychain(home, args) : null;
572
+ if (typeof args.ack !== "string" || typeof args["content-hash"] !== "string" || typeof args.from !== "string" || typeof args.to !== "string") {
573
+ die("attest-delivery needs --ack <ack.json> --content-hash <sha256> --from <uuaid> --to <uuaid>");
574
+ }
575
+ const ackDoc = JSON.parse(readFileSync(args.ack, "utf-8"));
576
+ const binds = typeof args.binds === "string" ? args.binds.split(",").map((s) => s.trim()).filter(Boolean) : [];
577
+ const att = attestDataDelivery({
578
+ claim: {
579
+ contentHash: args["content-hash"], from: args.from, to: args.to,
580
+ recipientPubKey: typeof args["recipient-pubkey"] === "string" ? args["recipient-pubkey"] : (ackDoc.recipientPubKey ?? null),
581
+ envelopeId: typeof args["envelope-id"] === "string" ? args["envelope-id"] : null,
582
+ },
583
+ ack: ackDoc.ack, ackSig: ackDoc.ackSig, recipientPubKey: ackDoc.recipientPubKey ?? null,
584
+ directory: loadDirectory(args), keychain: kc, binds: binds.length ? binds : null,
585
+ });
586
+ const final = att;
587
+ let out = final;
588
+ if (typeof args.bundle === "string") {
589
+ const others = JSON.parse(readFileSync(args.bundle, "utf-8"));
590
+ const list = Array.isArray(others) ? others : others?.kind === RECEIPT_KIND ? others.attestations : [others];
591
+ const attestations = [...list, final];
592
+ // manifest declares the full member set by ENVELOPE hash — subsetting is detectable (spec §7)
593
+ out = { v: VDA_V, kind: RECEIPT_KIND, manifest: attestations.map((a) => envelopeSha256(a)), attestations };
594
+ }
595
+ if (typeof args.out === "string") {
596
+ mkdirSync(dirname(args.out), { recursive: true });
597
+ writeFileSync(args.out, JSON.stringify(out, null, 2) + "\n");
598
+ }
599
+ console.log(JSON.stringify(out, null, 2));
600
+ exit(final.verdict === "verified" ? 0 : final.verdict === "pending" ? 3 : 1);
601
+ }
602
+
603
+ /**
604
+ * Offline composite verification of a vda-receipt bundle (spec §7).
605
+ * pillar-escrow verify-receipt --file receipt.json (--directory dir.json | --pubkey HEX)
606
+ */
607
+ async function cmdVerifyReceipt(args) {
608
+ if (typeof args.file !== "string") die("verify-receipt needs --file <receipt.json>");
609
+ const resolver = directoryOrPubkeyResolver(args);
610
+ if (!resolver) die("verify-receipt needs --directory <dir.json> or --pubkey <hex>");
611
+ const receipt = JSON.parse(readFileSync(args.file, "utf-8"));
612
+ const r = verifyReceipt(receipt, resolver);
613
+ console.log(JSON.stringify(r, null, 2));
614
+ exit(r.ok && r.verdict === "verified" ? 0 : 1);
615
+ }
616
+
617
+ /**
618
+ * M-of-N quorum over independent attestations of one claim (spec §9).
619
+ * pillar-escrow verify-quorum --files a.json,b.json,c.json --require 2 \
620
+ * (--directory dir.json | --pubkey HEX)
621
+ */
622
+ async function cmdVerifyQuorum(args) {
623
+ if (typeof args.files !== "string") die("verify-quorum needs --files <att.json,att.json,…>");
624
+ const resolver = directoryOrPubkeyResolver(args);
625
+ if (!resolver) die("verify-quorum needs --directory <dir.json> or --pubkey <hex>");
626
+ const atts = args.files.split(",").map((s) => s.trim()).filter(Boolean).map((f) => JSON.parse(readFileSync(f, "utf-8")));
627
+ const r = quorumReport(atts, resolver, { require: Number(args.require ?? 2) });
628
+ console.log(JSON.stringify(r, null, 2));
629
+ exit(r.ok ? 0 : 1);
630
+ }
631
+
632
+ /**
633
+ * Verifier key directory maintenance (spec §8): append-only add/revoke/show.
634
+ * pillar-escrow directory --dir dir.json add --uuaid U --pubkey HEX --valid-from ISO
635
+ * pillar-escrow directory --dir dir.json revoke --uuaid U --pubkey HEX --revoked-at ISO
636
+ * pillar-escrow directory --dir dir.json show
637
+ */
638
+ async function cmdDirectory(args) {
639
+ if (typeof args.dir !== "string") die("directory needs --dir <dir.json>");
640
+ const sub = args._[1];
641
+ const d = VerifierDirectory.fromFile(args.dir);
642
+ if (sub === "add") {
643
+ d.add({ uuaid: args.uuaid, publicKeyHex: args.pubkey, validFrom: args["valid-from"] ?? new Date().toISOString() });
644
+ d.toFile(args.dir);
645
+ } else if (sub === "revoke") {
646
+ d.revoke({ uuaid: args.uuaid, publicKeyHex: args.pubkey, revokedAt: args["revoked-at"] ?? new Date().toISOString() });
647
+ d.toFile(args.dir);
648
+ } else if (sub !== "show") {
649
+ die("directory needs a subcommand: add | revoke | show");
650
+ }
651
+ console.log(JSON.stringify({ directoryId: d.id(), records: d.records }, null, 2));
652
+ }
653
+
654
+ /* ------------------------------------------------ main ------------------- */
655
+
656
+ const args = parseArgs(argv.slice(2));
657
+ const cmd = args._[0];
658
+ const handlers = {
659
+ init: cmdInit, "run-escrow": cmdRunEscrow, sell: cmdSell, buy: cmdBuy,
660
+ "token-apply": cmdTokenApply, "chain-apply": cmdTokenApply,
661
+ "token-list": cmdTokenList, "token-review": cmdTokenReview,
662
+ verify: cmdVerify, "verify-attestation": cmdVerifyAttestation,
663
+ ack: cmdAck, "attest-delivery": cmdAttestDelivery,
664
+ "verify-receipt": cmdVerifyReceipt, "verify-quorum": cmdVerifyQuorum,
665
+ directory: cmdDirectory,
666
+ };
667
+ if (!handlers[cmd]) {
668
+ console.error("usage: pillar-escrow <init|run-escrow|sell|buy|token-apply|chain-apply|token-list|token-review|verify|verify-attestation|ack|attest-delivery|verify-receipt|verify-quorum|directory> [--home DIR] [--carrier URL] ...");
669
+ exit(2);
670
+ }
671
+ handlers[cmd](args).catch((e) => {
672
+ console.error(`[pillar-escrow] ${cmd} failed: ${e?.message ?? e}`);
673
+ exit(1);
674
+ });