@uuaid/pillar 0.2.0-alpha.9 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/HOSTING-LIMITS.md +432 -0
  2. package/INTERACTION-CONVENTION.md +222 -1
  3. package/README.md +358 -17
  4. package/bin/carrier.mjs +7 -1
  5. package/bin/pillar-ask.mjs +1 -0
  6. package/bin/pillar-blob.mjs +139 -0
  7. package/bin/pillar-directory.mjs +232 -0
  8. package/bin/pillar-escrow.mjs +675 -0
  9. package/bin/pillar-responder.mjs +47 -1
  10. package/bin/pillar-seed.mjs +113 -13
  11. package/bin/pillar.mjs +299 -7
  12. package/package.json +30 -7
  13. package/src/blob/blob.mjs +510 -0
  14. package/src/blob/broker.mjs +129 -0
  15. package/src/blob/http.mjs +334 -0
  16. package/src/blob/store.mjs +55 -0
  17. package/src/escrow/box.mjs +43 -0
  18. package/src/escrow/escrow-service.mjs +538 -0
  19. package/src/escrow/rails/evm-rail.mjs +427 -0
  20. package/src/escrow/rails/index.mjs +41 -0
  21. package/src/escrow/rails/zilligon-rail.mjs +73 -0
  22. package/src/escrow/terms.mjs +368 -0
  23. package/src/escrow/token-registry.mjs +231 -0
  24. package/src/escrow/verify.mjs +119 -0
  25. package/src/escrow/zilligon.mjs +266 -0
  26. package/src/identity/keychain.mjs +27 -10
  27. package/src/identity/tier.mjs +130 -0
  28. package/src/identity/trust.mjs +63 -0
  29. package/src/index.mjs +28 -2
  30. package/src/mailbox/mailbox.mjs +33 -2
  31. package/src/net/carrier-client.mjs +93 -14
  32. package/src/net/carrier-server.mjs +212 -15
  33. package/src/net/delegation.mjs +209 -0
  34. package/src/net/envelope.mjs +81 -0
  35. package/src/net/link-offer.mjs +107 -0
  36. package/src/net/refusals.mjs +126 -0
  37. package/src/net/seed-registry.mjs +467 -0
  38. package/src/net/seed-tier.mjs +185 -0
  39. package/src/node-check.mjs +32 -0
  40. package/src/observability.mjs +141 -0
  41. package/src/pillar.mjs +180 -17
  42. package/src/vda/core.mjs +290 -0
  43. package/src/vda/directory.mjs +87 -0
  44. package/src/vda/instruments.mjs +246 -0
  45. package/src/vda/shims.mjs +77 -0
@@ -0,0 +1,427 @@
1
+ /**
2
+ * EVM rail — native coin (POL/ETH) and ERC-20 payments for the blind escrow.
3
+ *
4
+ * Same security posture as the Zilligon rail, mapped onto chain instruments:
5
+ * - ATTESTATION is escrow-side and independent of the buyer's story: the
6
+ * claimed txId is only a LOOKUP KEY — what is verified is the chain's own
7
+ * receipt (status 1, to == escrow, from == buyer, exact amount, and a
8
+ * configurable confirmation depth) read over the escrow's OWN rpc.
9
+ * - PAYOUT is sign-then-journal-then-broadcast: preparePayout() signs a
10
+ * complete tx (fixed nonce) and returns {txId, rawTx} for the journal
11
+ * BEFORE anything hits the chain. Crash recovery re-broadcasts the SAME
12
+ * bytes — same nonce, same hash — so a double-spend is impossible by
13
+ * construction (the chain accepts one tx per nonce). findPriorPayout()
14
+ * is an exact receipt lookup, not a heuristic wallet scan.
15
+ * - The signing key is never stored on this object and never logged: a
16
+ * privateKeyProvider callback is invoked per signature and the result
17
+ * dropped. Production custody: provider fetches from a secrets manager;
18
+ * the default reads process.env.PILLAR_EVM_KEY at call time.
19
+ *
20
+ * Crypto pipeline (keccak-256, RLP, EIP-1559 payload, recovered-signature
21
+ * layout, address derivation) was verified byte-exact against live Polygon
22
+ * transactions on 2026-08-26 — the unit test embeds one as a fixture:
23
+ * hash match AND sender recovery on tx 0x7f299a0c…d78456 (block 92702633).
24
+ *
25
+ * Amounts cross this interface as DECIMAL STRINGS of base units (wei); all
26
+ * arithmetic is BigInt. 1 POL = 10^18 wei never fits a JS number.
27
+ *
28
+ * assets: "native" or "erc20:0x<tokenAddress>".
29
+ */
30
+ import { keccak_256 } from "@noble/hashes/sha3.js";
31
+ import { secp256k1 } from "@noble/curves/secp256k1.js";
32
+
33
+ /* ------------------------------------------------ bytes / hex ---------- */
34
+
35
+ const strip0x = (h) => (typeof h === "string" && h.startsWith("0x") ? h.slice(2) : h ?? "");
36
+
37
+ export function hexToBytes(h) {
38
+ let s = strip0x(h);
39
+ if (s.length % 2) s = "0" + s;
40
+ return Uint8Array.from(Buffer.from(s, "hex"));
41
+ }
42
+
43
+ const bytesToHex = (b) => "0x" + Buffer.from(b).toString("hex");
44
+
45
+ /** bigint -> minimal big-endian bytes (0n -> empty), the RLP integer form. */
46
+ function bnToBytes(v) {
47
+ if (v === 0n) return new Uint8Array(0);
48
+ let h = v.toString(16);
49
+ if (h.length % 2) h = "0" + h;
50
+ return Uint8Array.from(Buffer.from(h, "hex"));
51
+ }
52
+
53
+ /** hex quantity ("0x1a") -> minimal bytes. */
54
+ const qty = (h) => bnToBytes(BigInt(h ?? "0x0"));
55
+
56
+ const pad32 = (b) => Buffer.concat([Buffer.alloc(32 - b.length), Buffer.from(b)]);
57
+
58
+ const DEC = /^(0|[1-9][0-9]*)$/;
59
+ export const MAX_UINT256 = (1n << 256n) - 1n;
60
+
61
+ /** Decimal base-unit string -> BigInt, rejecting anything non-canonical. */
62
+ export function toWei(amount) {
63
+ if (typeof amount === "bigint") return amount;
64
+ if (typeof amount === "string" && DEC.test(amount)) {
65
+ const v = BigInt(amount);
66
+ if (v <= MAX_UINT256) return v;
67
+ }
68
+ throw new Error(`evm: amount must be a canonical decimal base-unit string, got ${JSON.stringify(amount)}`);
69
+ }
70
+
71
+ export const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
72
+ const sameAddr = (a, b) => typeof a === "string" && typeof b === "string" && a.toLowerCase() === b.toLowerCase();
73
+
74
+ /* ------------------------------------------------ RLP ------------------ */
75
+
76
+ /** Minimal RLP encoder (Uint8Array items and nested arrays only). */
77
+ export function rlpEncode(item) {
78
+ if (Array.isArray(item)) {
79
+ const parts = item.map(rlpEncode);
80
+ const total = parts.reduce((n, p) => n + p.length, 0);
81
+ let head;
82
+ if (total <= 55) head = Uint8Array.of(0xc0 + total);
83
+ else {
84
+ const lb = bnToBytes(BigInt(total));
85
+ head = Buffer.concat([Uint8Array.of(0xf7 + lb.length), lb]);
86
+ }
87
+ return Buffer.concat([head, ...parts]);
88
+ }
89
+ const b = item;
90
+ if (b.length === 1 && b[0] <= 0x7f) return Buffer.from(b);
91
+ if (b.length <= 55) return Buffer.concat([Uint8Array.of(0x80 + b.length), b]);
92
+ const lb = bnToBytes(BigInt(b.length));
93
+ return Buffer.concat([Uint8Array.of(0xb7 + lb.length), lb, b]);
94
+ }
95
+
96
+ /* ------------------------------------------------ EIP-1559 signing ----- */
97
+
98
+ export function addressOfPrivateKey(privHex) {
99
+ const pub = secp256k1.getPublicKey(hexToBytes(privHex), false); // uncompressed 65B
100
+ return bytesToHex(keccak_256(pub.slice(1)).slice(12));
101
+ }
102
+
103
+ /**
104
+ * Serialize + sign an EIP-1559 tx. All numeric fields BigInt.
105
+ * Returns { txId, rawTx } — public-after-broadcast data, journal-safe.
106
+ */
107
+ export function signEip1559(tx, privHex) {
108
+ const unsigned = [
109
+ bnToBytes(tx.chainId), bnToBytes(tx.nonce),
110
+ bnToBytes(tx.maxPriorityFeePerGas), bnToBytes(tx.maxFeePerGas),
111
+ bnToBytes(tx.gasLimit), hexToBytes(tx.to), bnToBytes(tx.value),
112
+ hexToBytes(tx.data ?? "0x"), [],
113
+ ];
114
+ const sighash = keccak_256(Buffer.concat([Uint8Array.of(0x02), rlpEncode(unsigned)]));
115
+ // noble "recovered" layout: [recovery(1) || r(32) || s(32)]
116
+ const sig = secp256k1.sign(sighash, hexToBytes(privHex), { format: "recovered", prehash: false });
117
+ const parity = sig[0];
118
+ // r/s enter RLP as minimal integers (BigInt round-trip strips leading zeros)
119
+ const rInt = BigInt(bytesToHex(sig.slice(1, 33))), sInt = BigInt(bytesToHex(sig.slice(33, 65)));
120
+ const signed = [...unsigned, bnToBytes(BigInt(parity)), bnToBytes(rInt), bnToBytes(sInt)];
121
+ const raw = Buffer.concat([Uint8Array.of(0x02), rlpEncode(signed)]);
122
+ return { txId: bytesToHex(keccak_256(raw)), rawTx: bytesToHex(raw) };
123
+ }
124
+
125
+ /** Recover the from-address of a signed EIP-1559 payload (test instrument). */
126
+ export function recoverEip1559Sender({ chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, gasLimit, to, value, data, yParity, r, s }) {
127
+ const unsigned = [
128
+ bnToBytes(chainId), bnToBytes(nonce), bnToBytes(maxPriorityFeePerGas), bnToBytes(maxFeePerGas),
129
+ bnToBytes(gasLimit), hexToBytes(to), bnToBytes(value), hexToBytes(data ?? "0x"), [],
130
+ ];
131
+ const sighash = keccak_256(Buffer.concat([Uint8Array.of(0x02), rlpEncode(unsigned)]));
132
+ const rec = Uint8Array.from(Buffer.concat([
133
+ Uint8Array.of(Number(yParity >= 27n ? yParity - 27n : yParity)),
134
+ pad32(bnToBytes(r)), pad32(bnToBytes(s)),
135
+ ]));
136
+ const point = secp256k1.Point.fromBytes(secp256k1.recoverPublicKey(rec, sighash, { prehash: false }));
137
+ return bytesToHex(keccak_256(point.toBytes(false).slice(1)).slice(12));
138
+ }
139
+
140
+ /* ------------------------------------------------ ERC-20 --------------- */
141
+
142
+ // Self-derived from the (chain-anchored) keccak — the unit test asserts the
143
+ // well-known literals so a hash regression cannot pass silently.
144
+ export const ERC20_TRANSFER_SELECTOR = bytesToHex(keccak_256(Buffer.from("transfer(address,uint256)", "utf-8")).slice(0, 4));
145
+ export const ERC20_TRANSFER_TOPIC = bytesToHex(keccak_256(Buffer.from("Transfer(address,address,uint256)", "utf-8")));
146
+
147
+ export function erc20TransferData(to, amount) {
148
+ return ERC20_TRANSFER_SELECTOR +
149
+ pad32(hexToBytes(to)).toString("hex") +
150
+ pad32(bnToBytes(toWei(amount))).toString("hex");
151
+ }
152
+
153
+ const ASSET_RE = /^erc20:0x[0-9a-fA-F]{40}$/;
154
+ export function parseAsset(asset) {
155
+ if (asset === "native" || asset == null) return { kind: "native" };
156
+ if (typeof asset === "string" && ASSET_RE.test(asset)) return { kind: "erc20", token: asset.slice(6).toLowerCase() }; // token keeps its 0x prefix
157
+ throw new Error(`evm: unknown asset ${JSON.stringify(asset)}`);
158
+ }
159
+
160
+ /* ------------------------------------------------ rail ----------------- */
161
+
162
+ const KNOWN_TX_ERRORS = /already known|alreadyknown|nonce too low|known transaction|already exists/i;
163
+
164
+ export class EvmRail {
165
+ /**
166
+ * @param rpcUrl escrow's own JSON-RPC endpoint (the attestation instrument)
167
+ * @param chainId pinned; verified against eth_chainId on first use (fail closed)
168
+ * @param handle escrow's 0x address on this chain
169
+ * @param privateKeyProvider async () -> 0x-hex key, called per signature, never stored
170
+ * @param confirmations depth required to attest a payment (and payout receipts)
171
+ * @param maxFeePerGasWei hard cap on maxFeePerGas (fail closed if the chain wants more)
172
+ * @param fetchImpl injectable for tests
173
+ */
174
+ constructor({
175
+ rpcUrl, chainId, handle,
176
+ privateKeyProvider = async () => {
177
+ const k = process.env.PILLAR_EVM_KEY;
178
+ if (!k) throw new Error("evm: no signing key (set PILLAR_EVM_KEY or inject privateKeyProvider)");
179
+ return k;
180
+ },
181
+ confirmations = 15,
182
+ maxFeePerGasWei = 2_000_000_000_000n, // 2000 gwei — generous for Polygon, a cap not a price
183
+ gasLimitNative = 21_000n,
184
+ gasLimitErc20 = 120_000n,
185
+ attempts = 6, delayMs = 5000,
186
+ fetchImpl = fetch,
187
+ }) {
188
+ if (!rpcUrl) throw new Error("EvmRail: rpcUrl required");
189
+ if (!Number.isInteger(chainId) || chainId < 1) throw new Error("EvmRail: integer chainId required");
190
+ if (!ADDRESS_RE.test(handle ?? "")) throw new Error("EvmRail: handle must be the escrow's 0x address");
191
+ this.name = "evm";
192
+ this.rpcUrl = rpcUrl;
193
+ this.chainId = chainId;
194
+ this.handle = handle;
195
+ this.privateKeyProvider = privateKeyProvider;
196
+ this.confirmations = confirmations;
197
+ this.maxFeePerGasWei = maxFeePerGasWei;
198
+ this.gasLimitNative = gasLimitNative;
199
+ this.gasLimitErc20 = gasLimitErc20;
200
+ this.attempts = attempts;
201
+ this.delayMs = delayMs;
202
+ this._fetch = fetchImpl;
203
+ this._chainChecked = false;
204
+ // Per-account prep serialization: concurrent preparePayout calls in this
205
+ // process are chained, and _nonceFloor hands out strictly increasing
206
+ // nonces even before the chain sees the earlier broadcasts. Cross-process
207
+ // / cross-restart races are caught by payoutStatus() -> re-prepare.
208
+ this._prepLock = Promise.resolve();
209
+ this._nonceFloor = -1n;
210
+ }
211
+
212
+ get railKey() { return `evm:${this.chainId}`; }
213
+
214
+ async _rpc(method, params) {
215
+ const res = await this._fetch(this.rpcUrl, {
216
+ method: "POST",
217
+ headers: { "Content-Type": "application/json" },
218
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
219
+ });
220
+ const json = await res.json();
221
+ if (json.error) { const e = new Error(`evm ${method}: ${json.error.message ?? JSON.stringify(json.error)}`); e.rpcError = json.error; throw e; }
222
+ return json.result;
223
+ }
224
+
225
+ /** Pinned-chain guard: the configured chainId must be the endpoint's. */
226
+ async _assertChain() {
227
+ if (this._chainChecked) return;
228
+ const got = Number(BigInt(await this._rpc("eth_chainId", [])));
229
+ if (got !== this.chainId) throw new Error(`evm: rpc serves chain ${got}, rail pinned to ${this.chainId}`);
230
+ this._chainChecked = true;
231
+ }
232
+
233
+ async _confirmedReceipt(txId) {
234
+ const rcpt = await this._rpc("eth_getTransactionReceipt", [txId]);
235
+ if (!rcpt) return null;
236
+ if (rcpt.status !== "0x1") return { rcpt, ok: false, reason: "reverted" };
237
+ const latest = BigInt(await this._rpc("eth_blockNumber", []));
238
+ const depth = latest - BigInt(rcpt.blockNumber) + 1n;
239
+ if (depth < BigInt(this.confirmations)) return { rcpt, ok: false, reason: "unconfirmed" };
240
+ return { rcpt, ok: true };
241
+ }
242
+
243
+ /**
244
+ * Attest the buyer's payment from the chain itself. txId is required on
245
+ * this rail (there is no amount+window fallback: without an indexer the
246
+ * escrow cannot enumerate inbound txs, and the claim already carries it).
247
+ * Polls to ride out inclusion + confirmation lag. Returns
248
+ * { txId, sourceId: null, raw } or null.
249
+ */
250
+ async attestPayment({ fromHandle, amount, txId, asset }) {
251
+ if (typeof txId !== "string" || !/^0x[0-9a-fA-F]{64}$/.test(txId)) return null;
252
+ await this._assertChain();
253
+ const want = toWei(amount);
254
+ const a = parseAsset(asset);
255
+ for (let i = 0; i < this.attempts; i++) {
256
+ const hit = await this._attestOnce({ fromHandle, want, txId, a }).catch(() => null);
257
+ if (hit) return hit;
258
+ if (i < this.attempts - 1) await new Promise((r) => setTimeout(r, this.delayMs));
259
+ }
260
+ return null;
261
+ }
262
+
263
+ async _attestOnce({ fromHandle, want, txId, a }) {
264
+ const conf = await this._confirmedReceipt(txId);
265
+ if (!conf?.ok) return null;
266
+ const { rcpt } = conf;
267
+ const tx = await this._rpc("eth_getTransactionByHash", [txId]);
268
+ if (!tx) return null;
269
+ if (fromHandle && !sameAddr(tx.from, fromHandle)) return null;
270
+ if (a.kind === "native") {
271
+ if (!sameAddr(tx.to, this.handle)) return null;
272
+ if (BigInt(tx.value) !== want) return null;
273
+ } else {
274
+ // exact Transfer(from=buyer, to=escrow, value=want) log emitted by THE token
275
+ const log = (rcpt.logs ?? []).find((l) =>
276
+ sameAddr(l.address, a.token) &&
277
+ (l.topics?.[0] ?? "").toLowerCase() === ERC20_TRANSFER_TOPIC &&
278
+ sameAddr("0x" + strip0x(l.topics?.[1] ?? "").slice(-40), fromHandle ?? tx.from) &&
279
+ sameAddr("0x" + strip0x(l.topics?.[2] ?? "").slice(-40), this.handle) &&
280
+ BigInt(l.data ?? "0x0") === want);
281
+ if (!log) return null;
282
+ }
283
+ return { txId, sourceId: null, raw: { blockNumber: rcpt.blockNumber, from: tx.from } };
284
+ }
285
+
286
+ /**
287
+ * Build + sign the payout WITHOUT broadcasting. The result is journaled by
288
+ * the caller before sendPayout — the crash-recovery anchor. Nonce is read
289
+ * at "latest": the escrow account must have a single writer (this service).
290
+ */
291
+ async preparePayout(args) {
292
+ // serialize concurrent preps on this account (finding #2: two offers
293
+ // preparing at once would both read the same "latest" nonce)
294
+ const run = this._prepLock.then(() => this._prepareLocked(args));
295
+ this._prepLock = run.catch(() => {}); // a failed prep never wedges the lock
296
+ return run;
297
+ }
298
+
299
+ async _prepareLocked({ toHandle, amount, asset }) {
300
+ await this._assertChain();
301
+ if (!ADDRESS_RE.test(toHandle ?? "")) throw new Error("evm: payout recipient must be a 0x address");
302
+ const value = toWei(amount);
303
+ const a = parseAsset(asset);
304
+ const chainNonce = BigInt(await this._rpc("eth_getTransactionCount", [this.handle, "latest"]));
305
+ const nonce = chainNonce > this._nonceFloor ? chainNonce : this._nonceFloor + 1n;
306
+ this._nonceFloor = nonce;
307
+ const block = await this._rpc("eth_getBlockByNumber", ["latest", false]);
308
+ const baseFee = BigInt(block?.baseFeePerGas ?? "0x0");
309
+ let tip;
310
+ try { tip = BigInt(await this._rpc("eth_maxPriorityFeePerGas", [])); }
311
+ catch (_e) { tip = 30_000_000_000n; } // 30 gwei — Polygon's floor
312
+ let maxFee = baseFee * 2n + tip;
313
+ if (baseFee + tip > this.maxFeePerGasWei) throw new Error(`evm: fee cap exceeded (base+tip ${baseFee + tip} > cap ${this.maxFeePerGasWei})`);
314
+ if (maxFee > this.maxFeePerGasWei) maxFee = this.maxFeePerGasWei;
315
+ const txReq = a.kind === "native"
316
+ ? { to: toHandle, value, data: "0x", gasLimit: this.gasLimitNative }
317
+ : { to: a.token, value: 0n, data: erc20TransferData(toHandle, value), gasLimit: this.gasLimitErc20 };
318
+ const priv = await this.privateKeyProvider();
319
+ try {
320
+ const { txId, rawTx } = signEip1559({
321
+ chainId: BigInt(this.chainId), nonce,
322
+ maxPriorityFeePerGas: tip > maxFee ? maxFee : tip, maxFeePerGas: maxFee,
323
+ gasLimit: txReq.gasLimit, to: txReq.to, value: txReq.value, data: txReq.data,
324
+ }, priv);
325
+ return { txId, rawTx, nonce: nonce.toString(), chainId: this.chainId };
326
+ } finally { /* the key was a local; nothing retained on this */ }
327
+ }
328
+
329
+ /**
330
+ * Broadcast a prepared payout and wait for its confirmed receipt.
331
+ * Re-broadcasting the same rawTx is idempotent by nonce — "already known" /
332
+ * "nonce too low" fall through to the receipt check.
333
+ */
334
+ async sendPayout({ prepared }) {
335
+ if (!prepared?.rawTx || !prepared?.txId) throw new Error("evm: sendPayout needs a prepared payout");
336
+ await this._assertChain();
337
+ try {
338
+ await this._rpc("eth_sendRawTransaction", [prepared.rawTx]);
339
+ } catch (e) {
340
+ if (!KNOWN_TX_ERRORS.test(String(e?.rpcError?.message ?? e?.message ?? ""))) throw e;
341
+ }
342
+ for (let i = 0; i < this.attempts; i++) {
343
+ const conf = await this._confirmedReceipt(prepared.txId).catch(() => null);
344
+ if (conf?.ok) return { txId: prepared.txId, sourceId: null };
345
+ if (conf && conf.reason === "reverted") throw new Error(`evm: payout ${prepared.txId} reverted on-chain`);
346
+ await new Promise((r) => setTimeout(r, this.delayMs));
347
+ }
348
+ throw new Error(`evm: payout ${prepared.txId} not confirmed after ${this.attempts} attempts (reconcile will retry)`);
349
+ }
350
+
351
+ /** Crash-recovery instrument: exact receipt lookup of the journaled txId. */
352
+ async findPriorPayout({ prepared }) {
353
+ if (!prepared?.txId) return null;
354
+ await this._assertChain();
355
+ const conf = await this._confirmedReceipt(prepared.txId).catch(() => null);
356
+ return conf?.ok ? { txId: prepared.txId, sourceId: null } : null;
357
+ }
358
+
359
+ /**
360
+ * Fate of a prepared payout — can its exact bytes still land?
361
+ * { state: "confirmed" } receipt at depth, status 1
362
+ * { state: "pending" } may still land (keep re-broadcasting)
363
+ * { state: "invalidated", reason } provably dead: the tx reverted, or its
364
+ * nonce was consumed at FINALIZED depth by a different hash — safe to
365
+ * re-prepare (a reorg deeper than `confirmations` is the documented
366
+ * residual, same posture as attestation).
367
+ */
368
+ async payoutStatus({ prepared }) {
369
+ if (!prepared?.txId || prepared.nonce == null) return { state: "pending" };
370
+ await this._assertChain();
371
+ const conf = await this._confirmedReceipt(prepared.txId).catch(() => null);
372
+ if (conf?.ok) return { state: "confirmed" };
373
+ if (conf?.reason === "reverted") return { state: "invalidated", reason: "reverted" };
374
+ if (conf?.reason === "unconfirmed") return { state: "pending" };
375
+ const latest = BigInt(await this._rpc("eth_blockNumber", []));
376
+ let finalized = latest - BigInt(this.confirmations);
377
+ if (finalized < 0n) finalized = 0n;
378
+ const count = BigInt(await this._rpc("eth_getTransactionCount", [this.handle, "0x" + finalized.toString(16)]));
379
+ if (count > BigInt(prepared.nonce)) return { state: "invalidated", reason: "nonce-superseded" };
380
+ return { state: "pending" };
381
+ }
382
+
383
+ /**
384
+ * Standalone transfer verification — the attestation instrument opened to
385
+ * ANY (from, to) pair, not just payments into this escrow. Verifies from
386
+ * the chain's own record at confirmation depth: status 1, exact amount,
387
+ * exact parties; for ERC-20, the Transfer log emitted by THE token.
388
+ * One read, no polling. Returns
389
+ * { verified: true, blockNumber, depth, from, to, amount, asset }
390
+ * or { verified: false, reason }.
391
+ */
392
+ async verifyTransfer({ txId, from, to, amount, asset }) {
393
+ if (typeof txId !== "string" || !/^0x[0-9a-fA-F]{64}$/.test(txId)) return { verified: false, reason: "bad-txid" };
394
+ if (!ADDRESS_RE.test(to ?? "")) return { verified: false, reason: "bad-to" };
395
+ if (from != null && !ADDRESS_RE.test(from)) return { verified: false, reason: "bad-from" };
396
+ await this._assertChain();
397
+ const want = toWei(amount);
398
+ const a = parseAsset(asset);
399
+ const conf = await this._confirmedReceipt(txId).catch(() => null);
400
+ if (!conf) return { verified: false, reason: "not-found" };
401
+ if (!conf.ok) return { verified: false, reason: conf.reason };
402
+ const { rcpt } = conf;
403
+ const tx = await this._rpc("eth_getTransactionByHash", [txId]);
404
+ if (!tx) return { verified: false, reason: "not-found" };
405
+ if (from && !sameAddr(tx.from, from)) return { verified: false, reason: "from-mismatch" };
406
+ if (a.kind === "native") {
407
+ if (!sameAddr(tx.to, to)) return { verified: false, reason: "to-mismatch" };
408
+ if (BigInt(tx.value) !== want) return { verified: false, reason: "amount-mismatch" };
409
+ } else {
410
+ const log = (rcpt.logs ?? []).find((l) =>
411
+ sameAddr(l.address, a.token) &&
412
+ (l.topics?.[0] ?? "").toLowerCase() === ERC20_TRANSFER_TOPIC &&
413
+ sameAddr("0x" + strip0x(l.topics?.[1] ?? "").slice(-40), from ?? tx.from) &&
414
+ sameAddr("0x" + strip0x(l.topics?.[2] ?? "").slice(-40), to) &&
415
+ BigInt(l.data ?? "0x0") === want);
416
+ if (!log) return { verified: false, reason: "no-matching-transfer-log" };
417
+ }
418
+ const latest = BigInt(await this._rpc("eth_blockNumber", []));
419
+ return {
420
+ verified: true,
421
+ blockNumber: Number(BigInt(rcpt.blockNumber)),
422
+ depth: Number(latest - BigInt(rcpt.blockNumber) + 1n),
423
+ from: (from ?? tx.from).toLowerCase(), to: to.toLowerCase(),
424
+ amount: want.toString(), asset: a.kind === "native" ? "native" : `erc20:${a.token}`,
425
+ };
426
+ }
427
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Rail registry — resolves the rail instance a terms object rides on.
3
+ *
4
+ * Rail keys: "zilligon" (v1 terms and the classic rail), "evm:<chainId>"
5
+ * (v2 terms carry rail:"evm" + an integer chainId). A terms object whose
6
+ * key has no registered rail is REJECTED by the escrow ("rail-unsupported")
7
+ * — never guessed at.
8
+ */
9
+ import { ZilligonRail } from "./zilligon-rail.mjs";
10
+ import { EvmRail } from "./evm-rail.mjs";
11
+
12
+ export { ZilligonRail, EvmRail };
13
+
14
+ /** The registry key a terms object addresses. */
15
+ export function railKeyOfTerms(terms) {
16
+ if (!terms) return null;
17
+ if (terms.v === 1) return "zilligon";
18
+ if (terms.rail === "evm") return Number.isInteger(terms.chainId) ? `evm:${terms.chainId}` : null;
19
+ return typeof terms.rail === "string" ? terms.rail : null;
20
+ }
21
+
22
+ export class RailSet {
23
+ constructor(rails = []) {
24
+ this.byKey = new Map();
25
+ for (const r of rails) this.add(r);
26
+ }
27
+
28
+ add(rail) {
29
+ if (!rail?.railKey) throw new Error("RailSet: rail must expose railKey");
30
+ this.byKey.set(rail.railKey, rail);
31
+ return this;
32
+ }
33
+
34
+ get(key) { return this.byKey.get(key) ?? null; }
35
+
36
+ /** Rail for a terms object, or null (caller rejects the offer). */
37
+ for(terms) {
38
+ const key = railKeyOfTerms(terms);
39
+ return key ? this.get(key) : null;
40
+ }
41
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Zilligon rail adapter — wraps the existing ZilligonClient (or any
3
+ * duck-typed client with findPayment/transfer/getWallet + handle) behind the
4
+ * uniform rail interface the EscrowService drives:
5
+ *
6
+ * attestPayment({ fromHandle, amount, txId, sourceId }) -> {txId, sourceId, raw} | null
7
+ * preparePayout(...) -> null (one-shot rail)
8
+ * sendPayout({ toHandle, amount }) -> {txId, sourceId}
9
+ * findPriorPayout({ toHandle, amount }) -> {txId, sourceId} | null
10
+ *
11
+ * Behavior is byte-for-byte the logic that lived inline in escrow-service.mjs
12
+ * (v0.2.0-alpha.11): the transfer-response id extraction, the acked-but-no-id
13
+ * wallet re-read, and the 3×2s crash-recovery wallet scan all moved here
14
+ * unchanged. Amounts arrive as decimal strings (rail interface) and become
15
+ * the integer Gons the client expects.
16
+ */
17
+ import { findPayoutInWallet, txIdOf } from "../zilligon.mjs";
18
+
19
+ const asGons = (amount) => {
20
+ const n = Number(amount);
21
+ if (!Number.isInteger(n) || n < 0) throw new Error(`zilligon rail: amount must be integer Gons, got ${amount}`);
22
+ return n;
23
+ };
24
+
25
+ export class ZilligonRail {
26
+ constructor({ client, retryDelayMs = 2000 }) {
27
+ if (!client?.handle) throw new Error("ZilligonRail: client with a handle required");
28
+ this.name = "zilligon";
29
+ this.railKey = "zilligon";
30
+ this.client = client;
31
+ this.handle = client.handle;
32
+ this.retryDelayMs = retryDelayMs;
33
+ }
34
+
35
+ async attestPayment({ fromHandle, amount, txId, sourceId }) {
36
+ const hit = await this.client.findPayment({
37
+ buyerHandle: fromHandle, expectedAmount: asGons(amount), txId, sourceId: sourceId ?? null,
38
+ });
39
+ if (!hit) return null;
40
+ return { txId: txIdOf(hit) ?? txId, sourceId: hit?.sourceId ?? null, raw: hit };
41
+ }
42
+
43
+ async preparePayout() { return null; }
44
+
45
+ async sendPayout({ toHandle, amount }) {
46
+ const gons = asGons(amount);
47
+ const res = await this.client.transfer({ recipientHandle: toHandle, amount: gons });
48
+ let txId = txIdOf(res?.data?.transaction ?? res?.data ?? {});
49
+ // The transfer response's transactionId equals the Tip id = both wallet
50
+ // rows' sourceId (verified live 2026-08-21) — bind to it.
51
+ let sourceId = txId ?? null;
52
+ if (!txId) {
53
+ // rail acked but returned no id we recognize — verify via wallet read
54
+ const wallet = await this.client.getWallet(this.handle).catch(() => null);
55
+ const prior = wallet ? findPayoutInWallet(wallet, { sellerHandle: toHandle, amount: gons }) : null;
56
+ txId = prior ? txIdOf(prior) : null;
57
+ sourceId = prior?.sourceId ?? null;
58
+ }
59
+ return { txId, sourceId };
60
+ }
61
+
62
+ async findPriorPayout({ toHandle, amount }) {
63
+ const gons = asGons(amount);
64
+ // a few retries to ride out rail propagation lag
65
+ let prior = null;
66
+ for (let i = 0; i < 3 && !prior; i++) {
67
+ const wallet = await this.client.getWallet(this.handle).catch(() => null);
68
+ prior = wallet ? findPayoutInWallet(wallet, { sellerHandle: toHandle, amount: gons }) : null;
69
+ if (!prior) await new Promise((r) => setTimeout(r, this.retryDelayMs));
70
+ }
71
+ return prior && txIdOf(prior) ? { txId: txIdOf(prior), sourceId: prior?.sourceId ?? null } : null;
72
+ }
73
+ }