@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.
@@ -0,0 +1,368 @@
1
+ /**
2
+ * Escrow terms, signatures, and receipts — the signed agreements of a
3
+ * blind-escrow exchange on the Zilligon ZGN rail.
4
+ *
5
+ * A deal is a TERMS object signed by the seller, then counter-signed by the
6
+ * buyer (acceptance), then executed by an escrow agent that:
7
+ * 1. holds the 32-byte content key K (blind to the payload itself),
8
+ * 2. attests the buyer's payment from ITS OWN wallet transactions,
9
+ * 3. pays the seller, releases K to the buyer, and issues a signed
10
+ * completion RECEIPT to each side.
11
+ *
12
+ * offerId is derived: sha256(jcs(terms-minus-offerId))[:16] — any party can
13
+ * recompute it, and the escrow keys its state by it. All signatures are
14
+ * Ed25519 over RFC-8785 JCS (same discipline as tier grants).
15
+ *
16
+ * The rail's tip fee stays OUTSIDE the two-party agreement: signed terms carry
17
+ * only fee-free numbers (priceGons, sellerPayoutGons), and who absorbs the
18
+ * rail fee (fee(amount) = floor(amount * 0.005), mirroring the rewards
19
+ * service config.ts tipFeePercent) is the parties' own deal — allocateFee
20
+ * offers buyer / seller / split-50-50 modes, or "mutual" where the parties
21
+ * pick explicit numbers themselves. The escrow NEVER derives a payout
22
+ * default: no explicit allocation, no signed terms.
23
+ *
24
+ * Escrow-side rail mechanics remain: feeFor mirrors the rail exactly;
25
+ * smallestSendFor finds the MINIMAL transfer whose receiver nets a promised
26
+ * amount; validatePayout checks a payout is affordable from the escrow's
27
+ * receipt (price - fee(price)).
28
+ */
29
+ import { createHash } from "node:crypto";
30
+ import { jcs } from "../identity/jcs.mjs";
31
+ import { Keychain } from "../identity/keychain.mjs";
32
+
33
+ export const TERMS_VERSION = 1;
34
+ export const TERMS_VERSION_2 = 2; // multi-rail terms: string base-unit amounts
35
+ export const RAIL = "zilligon";
36
+ export const DEFAULT_TIP_FEE = 0.005;
37
+ export const TERMS_MAX_LIFETIME_MS = 30 * 24 * 3600 * 1000; // bound the escrow's liability window
38
+ export const PAID_GRACE_MS = 15 * 60 * 1000; // accept a paid-claim this late after expiry (clock skew)
39
+
40
+ const HEX64 = /^[0-9a-f]{64}$/i;
41
+ // v2 amounts are DECIMAL STRINGS of rail base units (wei never fits a JS
42
+ // number); canonical form only — no leading zeros, no sign, no decimals.
43
+ const DEC_STR = /^(0|[1-9][0-9]*)$/;
44
+ const MAX_UINT256 = (1n << 256n) - 1n;
45
+ const EVM_ADDR = /^0x[0-9a-fA-F]{40}$/;
46
+ const EVM_ASSET = /^(native|erc20:0x[0-9a-fA-F]{40})$/;
47
+ /** Rails v2 terms may name today. zilligon stays v1-only: its fee economics
48
+ * (validatePayout) are baked into the v1 shape and the live path stays
49
+ * byte-compatible. */
50
+ export const V2_RAILS = ["evm"];
51
+
52
+ /** Zilligon fee on an amount, in Gons: floor(amount * tipFeePercent). */
53
+ export function feeFor(amount, tipFeePercent = DEFAULT_TIP_FEE) {
54
+ return Math.floor(amount * tipFeePercent);
55
+ }
56
+
57
+ /**
58
+ * Minimal transfer X such that the recipient nets >= payout Gons after the
59
+ * rail fee. Ascending search bounded by the proven ceiling: net(x) =
60
+ * x - floor(x*r) >= x - x*r = x*(1-r), so x >= payout/(1-r) always suffices
61
+ * (r must be < 1 — otherwise no finite X can ever satisfy it).
62
+ *
63
+ * At DEFAULT_TIP_FEE the overshoot is ~payout/199, e.g. smallestSendFor(999)
64
+ * = 1004 (999 nets 995; 1004 nets 999 exactly).
65
+ */
66
+ export function smallestSendFor(payout, tipFeePercent = DEFAULT_TIP_FEE) {
67
+ if (!(tipFeePercent >= 0 && tipFeePercent < 1)) throw new Error("smallestSendFor: tipFeePercent must be in [0, 1)");
68
+ const max = Math.ceil(payout / (1 - tipFeePercent));
69
+ for (let x = payout; x <= max; x++) {
70
+ if (x - feeFor(x, tipFeePercent) >= payout) return x;
71
+ }
72
+ return max; // exact at the boundary: net(max) >= payout
73
+ }
74
+
75
+ /**
76
+ * Split the rail's tip fee between the two parties — or don't. The signed
77
+ * terms stay fee-free (priceGons + sellerPayoutGons); this helper only
78
+ * computes the numbers each allocation implies:
79
+ * "seller" — former alpha formula, kept as an EXPLICIT choice: payout =
80
+ * net(net(price)) (seller eats the fee on both rail hops);
81
+ * "buyer" — buyer grosses the transfer up so the escrow receives exactly
82
+ * priceGons (s = smallestSendFor(price); buyer sends
83
+ * smallestSendFor(s); seller gets priceGons);
84
+ * "split" — 50/50: buyer sends x = smallestSendFor(price) (escrow
85
+ * receives exactly price), seller's payout y = 2*price - x, so
86
+ * each side absorbs exactly x - price. Feasible by
87
+ * construction: y <= net(price) implies smallestSendFor(y) <=
88
+ * price (verified 2026-08-21: price 1000 -> {1005, 995},
89
+ * price 200 -> {201, 199}, price 999 -> {1004, 994}).
90
+ * "mutual" — the parties agree explicit numbers themselves; returns null
91
+ * (the CLI then REQUIRES --payout).
92
+ */
93
+ export function allocateFee(priceGons, allocation = "mutual", tipFeePercent = DEFAULT_TIP_FEE) {
94
+ if (!Number.isInteger(priceGons) || priceGons < 1) throw new Error("allocateFee: priceGons must be an integer >= 1");
95
+ switch (allocation) {
96
+ case "seller": {
97
+ const escrowReceived = priceGons - feeFor(priceGons, tipFeePercent);
98
+ return { priceGons, sellerPayoutGons: escrowReceived - feeFor(escrowReceived, tipFeePercent) };
99
+ }
100
+ case "buyer": {
101
+ const s = smallestSendFor(priceGons, tipFeePercent);
102
+ return { priceGons: smallestSendFor(s, tipFeePercent), sellerPayoutGons: priceGons };
103
+ }
104
+ case "split": {
105
+ const x = smallestSendFor(priceGons, tipFeePercent);
106
+ return { priceGons: x, sellerPayoutGons: 2 * priceGons - x };
107
+ }
108
+ case "mutual":
109
+ return null;
110
+ default:
111
+ throw new Error(`allocateFee: unknown allocation "${allocation}"`);
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Check that a payout is affordable from the escrow's receipt, and compute
117
+ * the exact transfer it must make. escrowReceived = price - fee(price);
118
+ * escrow sends escrowSend, seller nets sellerPayoutGons, escrow keeps the
119
+ * (usually 0) remainder.
120
+ */
121
+ export function validatePayout(priceGons, sellerPayoutGons, tipFeePercent = DEFAULT_TIP_FEE) {
122
+ const escrowReceived = priceGons - feeFor(priceGons, tipFeePercent);
123
+ const escrowSend = smallestSendFor(sellerPayoutGons, tipFeePercent);
124
+ if (escrowSend > escrowReceived) {
125
+ return { ok: false, reason: "payout-exceeds-received", escrowReceived, escrowSend };
126
+ }
127
+ return { ok: true, escrowReceived, escrowSend, commission: escrowReceived - escrowSend };
128
+ }
129
+
130
+ /** offerId = sha256(jcs(terms without offerId))[:16 hex] — self-binding. */
131
+ export function fingerprint(terms) {
132
+ const { offerId, ...rest } = terms;
133
+ return createHash("sha256").update(jcs(rest)).digest("hex").slice(0, 16);
134
+ }
135
+
136
+ /**
137
+ * Assemble a terms object. All three parties' UUAIDs AND their Zilligon
138
+ * handles AND their ed25519 pubkeys are signed in — the escrow needs the
139
+ * handles to attest/pay on the rail, and the pubkeys to reply without a
140
+ * directory lookup.
141
+ */
142
+ export function makeTerms({
143
+ escrow, escrowPubKeyHex, escrowHandle,
144
+ seller, sellerPubKeyHex, sellerHandle,
145
+ buyer, buyerPubKeyHex, buyerHandle,
146
+ itemHash, priceGons, sellerPayoutGons,
147
+ expiresAt, createdAt = new Date().toISOString(),
148
+ }) {
149
+ const terms = {
150
+ v: TERMS_VERSION,
151
+ escrow, escrowPubKeyHex, escrowHandle,
152
+ seller, sellerPubKeyHex, sellerHandle,
153
+ buyer, buyerPubKeyHex, buyerHandle,
154
+ itemHash, priceGons, sellerPayoutGons,
155
+ rail: RAIL, expiresAt, createdAt,
156
+ };
157
+ terms.offerId = fingerprint(terms);
158
+ return terms;
159
+ }
160
+
161
+ /**
162
+ * Assemble a v2 (multi-rail) terms object. Amounts are decimal strings of
163
+ * base units; handles are rail addresses (0x… for EVM). The rail fee model
164
+ * lives OUTSIDE the signed numbers, exactly like v1's tip fee: on EVM the
165
+ * escrow pays gas from its commission (price - sellerPayout) — the parties
166
+ * price that in when they set sellerPayout.
167
+ */
168
+ export function makeTermsV2({
169
+ escrow, escrowPubKeyHex, escrowHandle,
170
+ seller, sellerPubKeyHex, sellerHandle,
171
+ buyer, buyerPubKeyHex, buyerHandle,
172
+ itemHash, rail = "evm", chainId, asset = "native",
173
+ price, sellerPayout,
174
+ expiresAt, createdAt = new Date().toISOString(),
175
+ }) {
176
+ const terms = {
177
+ v: TERMS_VERSION_2,
178
+ escrow, escrowPubKeyHex, escrowHandle,
179
+ seller, sellerPubKeyHex, sellerHandle,
180
+ buyer, buyerPubKeyHex, buyerHandle,
181
+ itemHash, rail, chainId, asset,
182
+ price, sellerPayout,
183
+ expiresAt, createdAt,
184
+ };
185
+ terms.offerId = fingerprint(terms);
186
+ return terms;
187
+ }
188
+
189
+ /** Seller / buyer signatures over jcs(terms). */
190
+ export function signTerms(keychain, terms) {
191
+ return keychain.sign(Buffer.from(jcs(terms), "utf-8")).toString("hex");
192
+ }
193
+
194
+ export function verifyTermsSig(publicKeyHex, terms, sig) {
195
+ try {
196
+ return Keychain.verifyDetached(publicKeyHex, Buffer.from(jcs(terms), "utf-8"), Buffer.from(sig, "hex"));
197
+ } catch (_e) {
198
+ return false;
199
+ }
200
+ }
201
+
202
+ function isUuaid(s) {
203
+ return typeof s === "string" && s.startsWith("uuaid:") && s.split(":").length === 4;
204
+ }
205
+
206
+ /**
207
+ * Structural + economic validation a receiving escrow runs on any terms
208
+ * object before acting. escrowUuaid/escrowHandle must be THIS escrow.
209
+ */
210
+ export function validateTerms(terms, opts = {}) {
211
+ if (terms?.v === TERMS_VERSION_2) return validateTermsV2(terms, opts);
212
+ return validateTermsV1(terms, opts);
213
+ }
214
+
215
+ function validateTermsV1(terms, { now = Date.now(), escrowUuaid, escrowHandle } = {}) {
216
+ if (!terms || terms.v !== TERMS_VERSION) return { ok: false, reason: "bad-version" };
217
+ // v1 IS the zilligon rail; a stray rail label is a signature-bound lie —
218
+ // routing ignores it (railKeyOfTerms pins v1 to zilligon), but reject it
219
+ // anyway so no receipt can carry a mislabel.
220
+ if (terms.rail != null && terms.rail !== RAIL) return { ok: false, reason: "bad-rail" };
221
+ // Field checks BEFORE the fingerprint: a hostile terms object can carry
222
+ // non-integer numbers (which JCS refuses to canonicalize), and the specific
223
+ // field reason is more useful than bad-offerId for a multi-defect object.
224
+ for (const f of ["escrow", "seller", "buyer"]) {
225
+ if (!isUuaid(terms[f])) return { ok: false, reason: `bad-${f}-uuaid` };
226
+ if (!HEX64.test(terms[`${f}PubKeyHex`] ?? "")) return { ok: false, reason: `bad-${f}-pubkey` };
227
+ if (typeof terms[`${f}Handle`] !== "string" || !terms[`${f}Handle`]) return { ok: false, reason: `bad-${f}-handle` };
228
+ }
229
+ if (escrowUuaid && terms.escrow !== escrowUuaid) return { ok: false, reason: "not-my-offer" };
230
+ if (escrowHandle && terms.escrowHandle !== escrowHandle) return { ok: false, reason: "not-my-rail-handle" };
231
+ if (typeof terms.itemHash !== "string" || !HEX64.test(terms.itemHash)) return { ok: false, reason: "bad-itemHash" };
232
+ if (!Number.isInteger(terms.priceGons) || terms.priceGons < 1) return { ok: false, reason: "bad-price" };
233
+ if (!Number.isInteger(terms.sellerPayoutGons) || terms.sellerPayoutGons < 0) return { ok: false, reason: "bad-payout" };
234
+ let fp;
235
+ try {
236
+ fp = fingerprint(terms);
237
+ } catch (_e) {
238
+ // JCS throws on non-integers anywhere (e.g. a float expiresAt) — an
239
+ // object our own serializer can't canonicalize can never be a valid
240
+ // offer, but it must REJECT, not crash the caller's handler.
241
+ return { ok: false, reason: "bad-offerId" };
242
+ }
243
+ if (typeof terms.offerId !== "string" || terms.offerId !== fp) return { ok: false, reason: "bad-offerId" };
244
+ const pay = validatePayout(terms.priceGons, terms.sellerPayoutGons);
245
+ if (!pay.ok) return { ok: false, reason: pay.reason };
246
+ const exp = Date.parse(terms.expiresAt);
247
+ if (!Number.isFinite(exp) || exp <= now) return { ok: false, reason: "expired" };
248
+ const created = Date.parse(terms.createdAt);
249
+ if (!Number.isFinite(created) || exp - created > TERMS_MAX_LIFETIME_MS) return { ok: false, reason: "lifetime-too-long" };
250
+ return { ok: true, ...pay };
251
+ }
252
+
253
+ /** Canonical uint256 decimal string -> BigInt, or null. */
254
+ function decStr(v) {
255
+ if (typeof v !== "string" || !DEC_STR.test(v)) return null;
256
+ const n = BigInt(v);
257
+ return n <= MAX_UINT256 ? n : null;
258
+ }
259
+
260
+ function validateTermsV2(terms, { now = Date.now(), escrowUuaid, escrowHandle } = {}) {
261
+ for (const f of ["escrow", "seller", "buyer"]) {
262
+ if (!isUuaid(terms[f])) return { ok: false, reason: `bad-${f}-uuaid` };
263
+ if (!HEX64.test(terms[`${f}PubKeyHex`] ?? "")) return { ok: false, reason: `bad-${f}-pubkey` };
264
+ if (typeof terms[`${f}Handle`] !== "string" || !terms[`${f}Handle`]) return { ok: false, reason: `bad-${f}-handle` };
265
+ }
266
+ if (escrowUuaid && terms.escrow !== escrowUuaid) return { ok: false, reason: "not-my-offer" };
267
+ if (!V2_RAILS.includes(terms.rail)) return { ok: false, reason: "bad-rail" };
268
+ if (terms.rail === "evm") {
269
+ if (!Number.isInteger(terms.chainId) || terms.chainId < 1) return { ok: false, reason: "bad-chainid" };
270
+ if (typeof terms.asset !== "string" || !EVM_ASSET.test(terms.asset)) return { ok: false, reason: "bad-asset" };
271
+ for (const f of ["escrow", "seller", "buyer"]) {
272
+ if (!EVM_ADDR.test(terms[`${f}Handle`])) return { ok: false, reason: `bad-${f}-handle` };
273
+ }
274
+ }
275
+ // rail addresses are case-insensitive — compare lowercased
276
+ if (escrowHandle && terms.escrowHandle.toLowerCase() !== escrowHandle.toLowerCase()) {
277
+ return { ok: false, reason: "not-my-rail-handle" };
278
+ }
279
+ if (typeof terms.itemHash !== "string" || !HEX64.test(terms.itemHash)) return { ok: false, reason: "bad-itemHash" };
280
+ const price = decStr(terms.price);
281
+ if (price === null || price < 1n) return { ok: false, reason: "bad-price" };
282
+ const payout = decStr(terms.sellerPayout);
283
+ if (payout === null) return { ok: false, reason: "bad-payout" };
284
+ // The whole fee model of v2: the rail's costs (gas) live OUTSIDE the signed
285
+ // numbers; the escrow's only economic invariant is payout <= price.
286
+ if (payout > price) return { ok: false, reason: "payout-exceeds-received" };
287
+ let fp;
288
+ try {
289
+ fp = fingerprint(terms);
290
+ } catch (_e) {
291
+ return { ok: false, reason: "bad-offerId" };
292
+ }
293
+ if (typeof terms.offerId !== "string" || terms.offerId !== fp) return { ok: false, reason: "bad-offerId" };
294
+ const exp = Date.parse(terms.expiresAt);
295
+ if (!Number.isFinite(exp) || exp <= now) return { ok: false, reason: "expired" };
296
+ const created = Date.parse(terms.createdAt);
297
+ if (!Number.isFinite(created) || exp - created > TERMS_MAX_LIFETIME_MS) return { ok: false, reason: "lifetime-too-long" };
298
+ return { ok: true, escrowReceived: terms.price, escrowSend: terms.sellerPayout, commission: (price - payout).toString() };
299
+ }
300
+
301
+ /**
302
+ * Uniform rail-facing view of any terms version — what the escrow service
303
+ * feeds the rail interface. Amounts are decimal strings; escrowSend is the
304
+ * exact transfer the escrow must make (v1: post-fee smallestSendFor; v2: the
305
+ * signed sellerPayout — gas rides outside).
306
+ */
307
+ export function railViewOfTerms(terms) {
308
+ if (terms.v === TERMS_VERSION_2) {
309
+ return {
310
+ v: 2, rail: terms.rail, chainId: terms.chainId, asset: terms.asset,
311
+ price: terms.price, sellerPayout: terms.sellerPayout, escrowSend: terms.sellerPayout,
312
+ };
313
+ }
314
+ const pay = validatePayout(terms.priceGons, terms.sellerPayoutGons);
315
+ return {
316
+ v: 1, rail: RAIL, chainId: null, asset: null,
317
+ price: String(terms.priceGons), sellerPayout: String(terms.sellerPayoutGons),
318
+ escrowSend: String(pay.escrowSend),
319
+ };
320
+ }
321
+
322
+ /**
323
+ * Completion receipt, signed by the escrow. The buyer's receipt travels
324
+ * WITH the released key; the seller's with the payout transfer id. Both
325
+ * are independently checkable by the end users (and any third party):
326
+ * verifyReceiptSig with the escrow's pubkey from the signed terms.
327
+ */
328
+ export function makeReceipt(keychain, {
329
+ v = TERMS_VERSION,
330
+ offerId, role, escrow, buyer, seller,
331
+ itemHash, priceGons, sellerPayoutGons,
332
+ rail = RAIL, chainId = null, asset = null, price = null, sellerPayout = null,
333
+ payTxId, releaseTxId,
334
+ paySourceId = null, releaseSourceId = null,
335
+ deliveredAt = new Date().toISOString(),
336
+ }) {
337
+ if (!["buyer", "seller"].includes(role)) throw new Error(`bad receipt role "${role}"`);
338
+ if (keychain._identity.uuaid !== escrow) throw new Error("receipt: escrow must be the signing identity");
339
+ const receipt = v === TERMS_VERSION_2
340
+ ? {
341
+ v: TERMS_VERSION_2,
342
+ offerId, role, escrow, buyer, seller,
343
+ itemHash, rail, chainId, asset, price, sellerPayout,
344
+ payTxId, releaseTxId,
345
+ paySourceId, releaseSourceId,
346
+ deliveredAt,
347
+ }
348
+ : {
349
+ v: TERMS_VERSION,
350
+ offerId, role, escrow, buyer, seller,
351
+ itemHash, priceGons, sellerPayoutGons,
352
+ payTxId, releaseTxId,
353
+ paySourceId, releaseSourceId,
354
+ rail: RAIL, deliveredAt,
355
+ };
356
+ receipt.sig = keychain.sign(Buffer.from(jcs(receipt), "utf-8")).toString("hex");
357
+ return receipt;
358
+ }
359
+
360
+ export function verifyReceiptSig(publicKeyHex, receipt) {
361
+ try {
362
+ const { sig, ...rest } = receipt;
363
+ if (![TERMS_VERSION, TERMS_VERSION_2].includes(receipt.v) || typeof sig !== "string") return false;
364
+ return Keychain.verifyDetached(publicKeyHex, Buffer.from(jcs(rest), "utf-8"), Buffer.from(sig, "hex"));
365
+ } catch (_e) {
366
+ return false;
367
+ }
368
+ }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Token / chain registry — the escrow's asset allowlist, fed by an OPEN
3
+ * APPLICATION PROCESS.
4
+ *
5
+ * Any EVM-chain or token developer may APPLY to have their chain / ERC-20
6
+ * included; nothing enters the allowlist without an explicit reviewer
7
+ * decision. The escrow enforces the registry FAIL-CLOSED at offer time:
8
+ * when a registry is wired, v2 terms naming an unregistered chain or token
9
+ * are rejected before any state is created ("chain-not-registered" /
10
+ * "token-not-registered"). Without a registry (tests, private bilateral
11
+ * deployments) behavior is unchanged.
12
+ *
13
+ * Why an allowlist at all: exact-amount attestation assumes STANDARD
14
+ * transfer semantics. Fee-on-transfer, rebasing, or hook-bearing tokens
15
+ * break "receipt shows exactly `price`" in ways no static check can catch —
16
+ * inclusion is a human vetting decision, and the reviewer's attestations are
17
+ * RECORDED here, not verified here.
18
+ *
19
+ * On disk (one directory):
20
+ * registry.json — the approved set {chains, tokens}, provenance on
21
+ * every entry (applicationId, reviewer, approvedAt)
22
+ * applications/<id>.json — every application ever filed, decision in place
23
+ *
24
+ * Application ids are content-derived — sha256(jcs(submission))[:16] — so a
25
+ * duplicate filing is the SAME application (idempotent, spam-resistant), and
26
+ * any party can recompute the id from the submitted fields.
27
+ *
28
+ * What is checked mechanically at filing time (offline-checkable facts only):
29
+ * shapes, ranges, EIP-55 checksum when the address is mixed-case, duplicates
30
+ * against the registry and pending queue. What is NOT checked here: source
31
+ * verification, transfer semantics, liquidity — the reviewer attests those.
32
+ */
33
+ import { createHash } from "node:crypto";
34
+ import { readFileSync, writeFileSync, mkdirSync, readdirSync } from "node:fs";
35
+ import { join } from "node:path";
36
+ import { keccak_256 } from "@noble/hashes/sha3.js";
37
+ import { jcs } from "../identity/jcs.mjs";
38
+ import { ADDRESS_RE, parseAsset } from "./rails/evm-rail.mjs";
39
+
40
+ const SYMBOL_RE = /^[A-Za-z0-9$._-]{1,20}$/;
41
+ const NAME_RE = /^[\x20-\x7E]{1,80}$/; // printable ASCII, bounded
42
+ const URL_RE = /^https?:\/\/[^\s]{1,300}$/;
43
+
44
+ /** EIP-55 checksum form of a 0x address (also the canonical display form). */
45
+ export function toChecksumAddress(addr) {
46
+ const hex = addr.slice(2).toLowerCase();
47
+ const hash = Buffer.from(keccak_256(Buffer.from(hex, "ascii"))).toString("hex");
48
+ let out = "0x";
49
+ for (let i = 0; i < 40; i++) out += parseInt(hash[i], 16) >= 8 ? hex[i].toUpperCase() : hex[i];
50
+ return out;
51
+ }
52
+
53
+ /** A mixed-case address must BE its own EIP-55 form; all-lower/all-upper pass. */
54
+ function checksumOk(addr) {
55
+ const body = addr.slice(2);
56
+ if (body === body.toLowerCase() || body === body.toUpperCase()) return true;
57
+ return addr === toChecksumAddress(addr);
58
+ }
59
+
60
+ function fingerprintApplication(sub) {
61
+ return createHash("sha256").update(jcs(sub)).digest("hex").slice(0, 16);
62
+ }
63
+
64
+ const tokenKey = (chainId, address) => `${chainId}:${address.toLowerCase()}`;
65
+
66
+ /**
67
+ * Validate an application submission (mechanical checks only).
68
+ * kind "chain": { kind, chainId, name, nativeSymbol, nativeDecimals,
69
+ * rpcUrl?, explorerUrl?, applicant, contact?, evidence? }
70
+ * kind "token": { kind, chainId, address, symbol, name, decimals,
71
+ * applicant, contact?, evidence? }
72
+ * Returns { ok } or { ok:false, reason }.
73
+ */
74
+ export function validateApplication(sub) {
75
+ if (!sub || typeof sub !== "object") return { ok: false, reason: "bad-application" };
76
+ if (sub.kind !== "chain" && sub.kind !== "token") return { ok: false, reason: "bad-kind" };
77
+ if (!Number.isInteger(sub.chainId) || sub.chainId < 1) return { ok: false, reason: "bad-chainid" };
78
+ if (typeof sub.applicant !== "string" || !sub.applicant.trim()) return { ok: false, reason: "bad-applicant" };
79
+ if (sub.contact != null && (typeof sub.contact !== "string" || sub.contact.length > 200)) return { ok: false, reason: "bad-contact" };
80
+ if (sub.evidence != null) {
81
+ if (!Array.isArray(sub.evidence) || sub.evidence.length > 10 || !sub.evidence.every((u) => typeof u === "string" && URL_RE.test(u))) {
82
+ return { ok: false, reason: "bad-evidence" };
83
+ }
84
+ }
85
+ if (typeof sub.name !== "string" || !NAME_RE.test(sub.name)) return { ok: false, reason: "bad-name" };
86
+ if (sub.kind === "chain") {
87
+ if (typeof sub.nativeSymbol !== "string" || !SYMBOL_RE.test(sub.nativeSymbol)) return { ok: false, reason: "bad-symbol" };
88
+ if (!Number.isInteger(sub.nativeDecimals) || sub.nativeDecimals < 0 || sub.nativeDecimals > 36) return { ok: false, reason: "bad-decimals" };
89
+ for (const f of ["rpcUrl", "explorerUrl"]) {
90
+ if (sub[f] != null && !(typeof sub[f] === "string" && URL_RE.test(sub[f]))) return { ok: false, reason: `bad-${f}` };
91
+ }
92
+ } else {
93
+ if (typeof sub.address !== "string" || !ADDRESS_RE.test(sub.address)) return { ok: false, reason: "bad-address" };
94
+ if (!checksumOk(sub.address)) return { ok: false, reason: "bad-checksum" };
95
+ if (typeof sub.symbol !== "string" || !SYMBOL_RE.test(sub.symbol)) return { ok: false, reason: "bad-symbol" };
96
+ if (!Number.isInteger(sub.decimals) || sub.decimals < 0 || sub.decimals > 36) return { ok: false, reason: "bad-decimals" };
97
+ }
98
+ return { ok: true };
99
+ }
100
+
101
+ export class TokenRegistry {
102
+ /** @param dir directory holding registry.json + applications/ */
103
+ constructor({ dir }) {
104
+ if (!dir) throw new Error("TokenRegistry: dir required");
105
+ this.dir = dir;
106
+ this.appsDir = join(dir, "applications");
107
+ mkdirSync(this.appsDir, { recursive: true });
108
+ this.registryPath = join(dir, "registry.json");
109
+ this._load();
110
+ }
111
+
112
+ _load() {
113
+ let data = null;
114
+ try {
115
+ data = JSON.parse(readFileSync(this.registryPath, "utf-8"));
116
+ } catch (_e) { /* fresh registry */ }
117
+ this.chains = data?.chains ?? {}; // "<chainId>" -> entry
118
+ this.tokens = data?.tokens ?? {}; // "<chainId>:0x<lower>" -> entry
119
+ }
120
+
121
+ _saveRegistry() {
122
+ writeFileSync(this.registryPath, JSON.stringify({ v: 1, chains: this.chains, tokens: this.tokens }, null, 2) + "\n");
123
+ }
124
+
125
+ _appPath(id) { return join(this.appsDir, `${id}.json`); }
126
+
127
+ _readApp(id) {
128
+ try {
129
+ return JSON.parse(readFileSync(this._appPath(id), "utf-8"));
130
+ } catch (_e) { return null; }
131
+ }
132
+
133
+ /* ------------------------------------------------ enforcement -------- */
134
+
135
+ isChainApproved(chainId) { return Boolean(this.chains[String(chainId)]); }
136
+
137
+ isAssetApproved(chainId, asset) {
138
+ if (!this.isChainApproved(chainId)) return false;
139
+ let a;
140
+ try { a = parseAsset(asset); } catch (_e) { return false; }
141
+ if (a.kind === "native") return true;
142
+ return Boolean(this.tokens[tokenKey(chainId, a.token)]);
143
+ }
144
+
145
+ /**
146
+ * Escrow-side policy gate for a validated terms object. v1 terms (the
147
+ * zilligon rail) are out of this registry's scope and always pass.
148
+ */
149
+ checkTerms(terms) {
150
+ if (terms?.v !== 2) return { ok: true };
151
+ if (!this.isChainApproved(terms.chainId)) {
152
+ return { ok: false, reason: "chain-not-registered", message: `chain ${terms.chainId} is not in this escrow's registry (apply via token-registry)` };
153
+ }
154
+ if (!this.isAssetApproved(terms.chainId, terms.asset)) {
155
+ return { ok: false, reason: "token-not-registered", message: `asset ${terms.asset} is not registered on chain ${terms.chainId} (apply via token-registry)` };
156
+ }
157
+ return { ok: true };
158
+ }
159
+
160
+ /* ------------------------------------------------ applications ------- */
161
+
162
+ /**
163
+ * File an application. Idempotent: identical submissions share one id.
164
+ * Returns { ok, id, status } or { ok:false, reason }.
165
+ */
166
+ submitApplication(sub) {
167
+ const v = validateApplication(sub);
168
+ if (!v.ok) return v;
169
+ if (sub.kind === "chain" && this.isChainApproved(sub.chainId)) return { ok: false, reason: "already-registered" };
170
+ if (sub.kind === "token" && this.tokens[tokenKey(sub.chainId, sub.address)]) return { ok: false, reason: "already-registered" };
171
+ const id = fingerprintApplication(sub);
172
+ const existing = this._readApp(id);
173
+ if (existing) return { ok: true, id, status: existing.status, duplicate: true };
174
+ const app = { id, status: "pending", submittedAt: new Date().toISOString(), submission: sub };
175
+ writeFileSync(this._appPath(id), JSON.stringify(app, null, 2) + "\n");
176
+ return { ok: true, id, status: "pending" };
177
+ }
178
+
179
+ /** All applications, newest first; optionally filtered by status. */
180
+ listApplications(status = null) {
181
+ const out = [];
182
+ for (const f of readdirSync(this.appsDir)) {
183
+ if (!f.endsWith(".json")) continue;
184
+ const app = this._readApp(f.slice(0, -5));
185
+ if (app && (!status || app.status === status)) out.push(app);
186
+ }
187
+ return out.sort((a, b) => (b.submittedAt ?? "").localeCompare(a.submittedAt ?? ""));
188
+ }
189
+
190
+ getApplication(id) { return this._readApp(id); }
191
+
192
+ /**
193
+ * Decide a pending application. Approval writes the registry entry with
194
+ * full provenance; the decision is annotated into the application file in
195
+ * place (never deleted). A token approval requires its chain approved
196
+ * first — the ordering the enforcement gate assumes.
197
+ * Returns { ok, id, status } or { ok:false, reason }.
198
+ */
199
+ decideApplication(id, { approve, reviewer, notes = "" }) {
200
+ if (typeof reviewer !== "string" || !reviewer.trim()) return { ok: false, reason: "bad-reviewer" };
201
+ const app = this._readApp(id);
202
+ if (!app) return { ok: false, reason: "unknown-application" };
203
+ if (app.status !== "pending") return { ok: false, reason: "already-decided", status: app.status };
204
+ const sub = app.submission;
205
+ if (approve && sub.kind === "token" && !this.isChainApproved(sub.chainId)) {
206
+ return { ok: false, reason: "chain-not-registered", message: "approve the chain application first" };
207
+ }
208
+ app.status = approve ? "approved" : "rejected";
209
+ app.decidedAt = new Date().toISOString();
210
+ app.reviewer = reviewer;
211
+ if (notes) app.notes = notes;
212
+ writeFileSync(this._appPath(id), JSON.stringify(app, null, 2) + "\n");
213
+ if (approve) {
214
+ const provenance = { applicationId: id, reviewer, approvedAt: app.decidedAt };
215
+ if (sub.kind === "chain") {
216
+ this.chains[String(sub.chainId)] = {
217
+ name: sub.name, nativeSymbol: sub.nativeSymbol, nativeDecimals: sub.nativeDecimals,
218
+ ...(sub.rpcUrl ? { rpcUrl: sub.rpcUrl } : {}), ...(sub.explorerUrl ? { explorerUrl: sub.explorerUrl } : {}),
219
+ ...provenance,
220
+ };
221
+ } else {
222
+ this.tokens[tokenKey(sub.chainId, sub.address)] = {
223
+ address: toChecksumAddress(sub.address), symbol: sub.symbol, name: sub.name, decimals: sub.decimals,
224
+ ...provenance,
225
+ };
226
+ }
227
+ this._saveRegistry();
228
+ }
229
+ return { ok: true, id, status: app.status };
230
+ }
231
+ }