@agenticstechnologies/verify 0.0.0-stage → 1.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.
package/src/bundle.mjs ADDED
@@ -0,0 +1,364 @@
1
+ // Full offline check of an evidence export or a proof bundle.
2
+ // Leaf and prev_hash: verifyEvidenceExport (GET /api/proof/export).
3
+ // A single-receipt proof bundle (GET /api/proof/bundle) has `receipt` instead
4
+ // of `receipts`; its chain array is hash links only.
5
+ // Merkle and Solana run only when the bundle carries those fields.
6
+
7
+ import { clearedLeafMatches, leafHash, verifyEvidenceExport } from "./evidence.mjs";
8
+ import { merkleRoot, merkleRootV2, treeRuleOf, TREE_RULE_V2, verifyProof, verifyProofV2 } from "./merkle.mjs";
9
+ import { checkSolanaMemo, classifyCluster } from "./solana.mjs";
10
+
11
+ const HEX64 = /^[0-9a-f]{64}$/;
12
+
13
+ function failure(reason, hashOk = false, chainOk = false) {
14
+ return {
15
+ receipt_hash: "",
16
+ prev_hash: "",
17
+ hash_ok: hashOk,
18
+ chain_ok: chainOk,
19
+ reason,
20
+ };
21
+ }
22
+
23
+ function hex64(v) {
24
+ if (typeof v !== "string") return null;
25
+ const s = v.trim().toLowerCase();
26
+ return HEX64.test(s) ? s : null;
27
+ }
28
+
29
+ function text(v) {
30
+ if (v == null) return null;
31
+ const s = String(v).trim();
32
+ return s.length ? s : null;
33
+ }
34
+
35
+ function linksOldestFirst(chain) {
36
+ for (let i = 0; i < chain.length; i++) {
37
+ const prev = String((chain[i] ?? {}).prev_hash ?? "");
38
+ if (i === 0) {
39
+ if (!HEX64.test(prev)) return false;
40
+ } else if (prev !== String((chain[i - 1] ?? {}).receipt_hash ?? "")) {
41
+ return false;
42
+ }
43
+ }
44
+ return true;
45
+ }
46
+
47
+ function linksNewestFirst(chain) {
48
+ for (let i = 0; i < chain.length; i++) {
49
+ const prev = String((chain[i] ?? {}).prev_hash ?? "");
50
+ if (!HEX64.test(prev)) return false;
51
+ if (i < chain.length - 1 && prev !== String((chain[i + 1] ?? {}).receipt_hash ?? "")) {
52
+ return false;
53
+ }
54
+ }
55
+ return true;
56
+ }
57
+
58
+ async function verifySingleReceipt(bundle) {
59
+ const canonical = bundle.receipt ?? {};
60
+ const claimed = String(bundle.receipt_hash ?? "");
61
+ const prev = String(canonical.prev_hash ?? "");
62
+ let recomputed = "";
63
+ let hashOk = false;
64
+ try {
65
+ recomputed = await leafHash(canonical);
66
+ hashOk = recomputed === claimed || await clearedLeafMatches(canonical, claimed, leafHash, {
67
+ text_cleared_at: bundle.text_cleared_at,
68
+ hash_version: bundle.hash_version ?? canonical.hash_version,
69
+ uncleared_digest: bundle.uncleared_digest,
70
+ status: bundle.status,
71
+ });
72
+ } catch {
73
+ hashOk = false;
74
+ }
75
+ let chainOk = true;
76
+ let reason = null;
77
+ if (!hashOk) reason = `leaf recomputed ${recomputed} != ${claimed}`;
78
+ if (Array.isArray(bundle.chain) && bundle.chain.length > 0) {
79
+ const forward = linksOldestFirst(bundle.chain);
80
+ const backward = linksNewestFirst(bundle.chain);
81
+ if (!forward && !backward) {
82
+ chainOk = false;
83
+ reason = reason ?? "prev_hash does not match the previous receipt_hash";
84
+ } else {
85
+ const mine = bundle.chain.find((row) => String((row ?? {}).receipt_hash ?? "") === claimed);
86
+ if (!mine) {
87
+ chainOk = false;
88
+ reason = reason ?? "receipt_hash is not in the chain";
89
+ } else if (String(mine.prev_hash ?? "") !== prev) {
90
+ chainOk = false;
91
+ reason = reason ?? "prev_hash does not match the previous receipt_hash";
92
+ }
93
+ }
94
+ } else if (!HEX64.test(prev)) {
95
+ chainOk = false;
96
+ reason = reason ?? "prev_hash is not 64 hex characters";
97
+ }
98
+ const failures = [];
99
+ if (!hashOk || !chainOk) {
100
+ failures.push({
101
+ receipt_hash: claimed,
102
+ prev_hash: prev,
103
+ hash_ok: hashOk,
104
+ chain_ok: chainOk,
105
+ reason: reason ?? "failed",
106
+ });
107
+ }
108
+ return { checked: 1, failures };
109
+ }
110
+
111
+ function hexList(v) {
112
+ if (!Array.isArray(v)) return null;
113
+ return v.map((item) => (typeof item === "string" ? item.trim().toLowerCase() : ""));
114
+ }
115
+
116
+ function claimFrom(merkle, leaf) {
117
+ if (!merkle || typeof merkle !== "object") return null;
118
+ const root = hex64(merkle.root) || hex64(merkle.merkle_root);
119
+ const siblings = Array.isArray(merkle.siblings)
120
+ ? hexList(merkle.siblings)
121
+ : (Array.isArray(merkle.proof) ? hexList(merkle.proof) : null);
122
+ const leaves = Array.isArray(merkle.leaves) ? hexList(merkle.leaves) : null;
123
+ const index = Number.isInteger(merkle.index)
124
+ ? merkle.index
125
+ : (Number.isInteger(merkle.leaf_index) ? merkle.leaf_index : null);
126
+ if (!root && !siblings && !leaves) return null;
127
+ const size = Number.isInteger(merkle.leaf_count) ? merkle.leaf_count : (Number.isInteger(merkle.tree_size) ? merkle.tree_size : null);
128
+ return {
129
+ root,
130
+ siblings,
131
+ leaves,
132
+ index,
133
+ size,
134
+ // anchor_batches.tree_rule (L3-16). Absent is v1, the rule every batch before v2 was anchored under.
135
+ rule: merkle.tree_rule ?? null,
136
+ leaf: hex64(merkle.leaf) || leaf || null,
137
+ };
138
+ }
139
+
140
+ function merkleClaims(bundle) {
141
+ const claims = [];
142
+ const topLeaf = hex64(bundle.receipt_hash);
143
+ const top = claimFrom(bundle.merkle, topLeaf);
144
+ if (top) claims.push(top);
145
+ if (Array.isArray(bundle.receipts)) {
146
+ for (const row of bundle.receipts) {
147
+ if (!row || typeof row !== "object" || row.merkle == null) continue;
148
+ const rowClaim = claimFrom(row.merkle, hex64(row.receipt_hash));
149
+ if (rowClaim) claims.push(rowClaim);
150
+ }
151
+ }
152
+ // ledger_batches / anchor batch rows, if an export included them.
153
+ // Same field names as proof.ts: merkle_root, plus siblings or leaves.
154
+ if (Array.isArray(bundle.batches)) {
155
+ for (const batch of bundle.batches) {
156
+ if (!batch || typeof batch !== "object") continue;
157
+ const rowClaim = claimFrom({
158
+ root: batch.root,
159
+ merkle_root: batch.merkle_root,
160
+ siblings: batch.siblings,
161
+ proof: batch.proof,
162
+ leaves: batch.leaves,
163
+ index: batch.index,
164
+ leaf_index: batch.leaf_index,
165
+ leaf_count: batch.leaf_count,
166
+ tree_rule: batch.tree_rule,
167
+ }, topLeaf);
168
+ if (rowClaim) claims.push(rowClaim);
169
+ }
170
+ }
171
+ return claims;
172
+ }
173
+
174
+ async function checkClaim(claim) {
175
+ const reasons = [];
176
+ let confirmed = null;
177
+ const rule = treeRuleOf(claim.rule);
178
+ if (rule === null) {
179
+ reasons.push(`unknown merkle tree rule ${String(claim.rule).slice(0, 40)}`);
180
+ return { reasons, root: null };
181
+ }
182
+ const v2 = rule === TREE_RULE_V2;
183
+ if (!claim.root) {
184
+ reasons.push("merkle root is missing");
185
+ return { reasons, root: null };
186
+ }
187
+ if (!claim.leaves && !claim.siblings) {
188
+ reasons.push("merkle root is present but the export has no leaves or siblings to recompute");
189
+ return { reasons, root: null };
190
+ }
191
+ if (claim.leaves) {
192
+ if (claim.leaves.some((h) => !HEX64.test(h))) {
193
+ reasons.push("merkle leaf is not 64 hex characters");
194
+ } else {
195
+ const recomputed = v2 ? await merkleRootV2(claim.leaves) : await merkleRoot(claim.leaves);
196
+ if (recomputed !== claim.root) {
197
+ reasons.push(`merkle root recomputed ${recomputed} != ${claim.root}`);
198
+ } else {
199
+ confirmed = recomputed;
200
+ }
201
+ if (claim.index != null) {
202
+ if (claim.index < 0 || claim.index >= claim.leaves.length) {
203
+ reasons.push("merkle index is outside the leaf list");
204
+ } else if (claim.leaf && claim.leaves[claim.index] !== claim.leaf) {
205
+ reasons.push("merkle index does not point at the receipt hash");
206
+ }
207
+ }
208
+ }
209
+ }
210
+ if (claim.siblings) {
211
+ if (!claim.leaf) {
212
+ reasons.push("merkle proof is present but no leaf hash");
213
+ } else if (claim.siblings.some((h) => !HEX64.test(h))) {
214
+ reasons.push("merkle sibling is not 64 hex characters");
215
+ } else {
216
+ const size = claim.size ?? claim.leaves?.length ?? null;
217
+ if (v2 && (claim.index == null || size == null)) {
218
+ reasons.push("a v2 merkle proof needs the leaf index and leaf_count");
219
+ } else {
220
+ const folded = v2
221
+ ? await verifyProofV2(claim.leaf, claim.index, size, claim.siblings, claim.root)
222
+ : await verifyProof(claim.leaf, claim.siblings, claim.root);
223
+ if (!folded) reasons.push(`merkle proof does not fold to ${claim.root}`);
224
+ else if (!confirmed) confirmed = claim.root;
225
+ }
226
+ }
227
+ }
228
+ return { reasons, root: reasons.length === 0 ? confirmed : null };
229
+ }
230
+
231
+ async function verifyMerkle(bundle) {
232
+ const claims = merkleClaims(bundle);
233
+ if (claims.length === 0) {
234
+ return {
235
+ status: "not-present",
236
+ reason: "batch/merkle fields are not in this export",
237
+ roots: [],
238
+ };
239
+ }
240
+ const reasons = [];
241
+ const roots = [];
242
+ for (const claim of claims) {
243
+ const checked = await checkClaim(claim);
244
+ reasons.push(...checked.reasons);
245
+ if (checked.root) roots.push(checked.root);
246
+ }
247
+ if (reasons.length) {
248
+ return { status: "fail", reason: reasons[0], roots: [] };
249
+ }
250
+ return { status: "pass", reason: null, roots };
251
+ }
252
+
253
+ function anchorFrom(bundle) {
254
+ const a = bundle.anchor && typeof bundle.anchor === "object" ? bundle.anchor : null;
255
+ const src = a ?? {};
256
+ const tx = text(src.txid) || text(src.tx) || text(src.tx_hash) || text(bundle.tx) || text(bundle.txid) || text(bundle.anchor_txid);
257
+ const clusterHint = text(src.cluster) || text(src.chain) || text(bundle.cluster) || text(bundle.anchor_chain);
258
+ const explorer = text(src.explorer_url);
259
+ const memo = text(src.memo) || text(bundle.memo);
260
+ if (!a && !tx && !clusterHint) return null;
261
+ return { tx, clusterHint, explorer, memo };
262
+ }
263
+
264
+ async function verifySolana(bundle, merkle, opts) {
265
+ const anchor = anchorFrom(bundle);
266
+ // Local consistency: memo must equal a recomputed root even when there is
267
+ // no transaction to fetch, and even when the memo is not 64 hex characters.
268
+ // This does not call the network.
269
+ const memoText = anchor && anchor.memo ? String(anchor.memo).trim().toLowerCase() : "";
270
+ const memoHex = anchor ? hex64(anchor.memo) : null;
271
+ const roots = merkle.roots || [];
272
+ if (memoText && roots.length && !roots.includes(memoText)) {
273
+ return { status: "fail", reason: "anchor memo does not equal the recomputed merkle root" };
274
+ }
275
+ if (!anchor || (!anchor.tx && !anchor.clusterHint && !anchor.explorer)) {
276
+ return { status: "not-present", reason: "tx/cluster are not in this export" };
277
+ }
278
+ if (!anchor.tx || !(anchor.clusterHint || anchor.explorer)) {
279
+ return { status: "not-present", reason: "tx and cluster are both required" };
280
+ }
281
+ const cluster = classifyCluster(anchor.clusterHint, anchor.explorer);
282
+ if (cluster === "mainnet") {
283
+ return {
284
+ status: "warn",
285
+ reason: "solana memo not checked: cluster is mainnet; public devnet and testnet only",
286
+ };
287
+ }
288
+ if (cluster !== "devnet" && cluster !== "testnet") {
289
+ return {
290
+ status: "warn",
291
+ reason: "solana memo not checked: cluster is not devnet or testnet",
292
+ };
293
+ }
294
+ const expected = (memoHex && roots.includes(memoHex) ? memoHex : roots[0]) || memoHex;
295
+ if (!expected) {
296
+ return {
297
+ status: "not-present",
298
+ reason: "no merkle root or memo to compare",
299
+ };
300
+ }
301
+ const checked = await checkSolanaMemo({
302
+ txid: anchor.tx,
303
+ cluster,
304
+ expected,
305
+ timeoutMs: opts.timeoutMs,
306
+ fetchImpl: opts.fetchImpl,
307
+ });
308
+ if (checked.ok) return { status: "pass", reason: null };
309
+ if (checked.warn) return { status: "warn", reason: checked.reason };
310
+ return { status: "fail", reason: checked.reason };
311
+ }
312
+
313
+ /**
314
+ * @param {unknown} bundle
315
+ * @param {{ timeoutMs?: number, fetchImpl?: typeof fetch }} [opts]
316
+ */
317
+ export async function verifyBundle(bundle, opts = {}) {
318
+ if (!bundle || typeof bundle !== "object" || Array.isArray(bundle)) {
319
+ return {
320
+ ok: false,
321
+ checked: 0,
322
+ failures: [failure("bundle is not an object")],
323
+ merkle: { status: "not-present", reason: "batch/merkle fields are not in this export" },
324
+ solana: { status: "not-present", reason: "tx/cluster are not in this export" },
325
+ };
326
+ }
327
+
328
+ let checked = 0;
329
+ /** @type {ReturnType<typeof failure>[]} */
330
+ let failures = [];
331
+ if (Array.isArray(bundle.receipts)) {
332
+ const leaf = await verifyEvidenceExport(bundle);
333
+ checked = leaf.checked;
334
+ failures = leaf.failures.slice();
335
+ } else if (bundle.receipt && typeof bundle.receipt === "object") {
336
+ const one = await verifySingleReceipt(bundle);
337
+ checked = one.checked;
338
+ failures = one.failures.slice();
339
+ } else {
340
+ failures = [failure("bundle.receipts is missing")];
341
+ }
342
+
343
+ // A receipts export already records this inside verifyEvidenceExport.
344
+ // A single-receipt proof bundle (GET /api/proof/bundle, the sample receipt)
345
+ // did not, so a truncated file could still pass leaf and Merkle.
346
+ if (bundle.verification && bundle.verification.truncated === true) {
347
+ const already = failures.some((row) => String(row.reason).includes("truncated"));
348
+ if (!already) failures.push(failure("export was truncated; refuse to call it complete"));
349
+ }
350
+
351
+ const merkle = await verifyMerkle(bundle);
352
+ if (merkle.status === "fail") failures.push(failure(merkle.reason));
353
+
354
+ const solana = await verifySolana(bundle, merkle, opts);
355
+ if (solana.status === "fail") failures.push(failure(solana.reason));
356
+
357
+ return {
358
+ ok: failures.length === 0,
359
+ checked,
360
+ failures,
361
+ merkle: { status: merkle.status, reason: merkle.reason },
362
+ solana: { status: solana.status, reason: solana.reason },
363
+ };
364
+ }
@@ -0,0 +1,11 @@
1
+ export function clearedLeafMatches(
2
+ canonical: Record<string, unknown>,
3
+ storedHash: string,
4
+ leafHash: (canonical: Record<string, unknown>) => Promise<string>,
5
+ meta?: {
6
+ text_cleared_at?: unknown;
7
+ hash_version?: unknown;
8
+ uncleared_digest?: unknown;
9
+ status?: unknown;
10
+ },
11
+ ): Promise<boolean>;
@@ -0,0 +1,120 @@
1
+ // Cleared-text leaf check. Shared by the evidence export, the proof bundle,
2
+ // and the server (lib/gateway/proof.ts passes its own leafHash).
3
+ //
4
+ // Retention and deletion null free text and set text_cleared_at. They do not
5
+ // rewrite receipt_hash. The older leaf (hash_version null, 2, or 3) covers
6
+ // `outcome`, so a straight recompute misses after that column is null.
7
+ //
8
+ // When text_cleared_at is set:
9
+ // * Cleared fields are not put back. The stored hash is their commitment.
10
+ // * Older leaves: try the historical outcome labels in memory. A match means
11
+ // every field that was not cleared still agrees with the stored hash.
12
+ // The label that matched is not returned.
13
+ // * Version 4: reason, target, outcome, and error_message were inside the
14
+ // leaf. uncleared_digest, sealed when the text was cleared, is the hash of
15
+ // the leaf with those keys removed. Without that digest the leaf fails.
16
+ // An older outcome that is not in the historical list uses the same kind
17
+ // of digest, with outcome removed. Chain linkage is the caller's check.
18
+ //
19
+ // A receipt with no text_cleared_at still has to recompute exactly.
20
+
21
+ export const LEGACY_OUTCOMES = Object.freeze([
22
+ "success",
23
+ "failed",
24
+ "partial",
25
+ "blocked",
26
+ "escalated",
27
+ "timeout",
28
+ "settled",
29
+ "attempted",
30
+ "circuit_open",
31
+ "fallback",
32
+ "retried",
33
+ "deployed",
34
+ "not_deployed",
35
+ "ok",
36
+ "clean",
37
+ "reversed",
38
+ "pending",
39
+ "human_override",
40
+ "escalation_needed",
41
+ ]);
42
+
43
+ const HEX64 = /^[0-9a-f]{64}$/;
44
+ const V4_CLEARED = ["reason", "target", "outcome", "error_message"];
45
+
46
+ function hex64(value) {
47
+ const text = String(value ?? "").trim().toLowerCase();
48
+ return HEX64.test(text) ? text : "";
49
+ }
50
+
51
+ /**
52
+ * @param {Record<string, unknown>} canonical
53
+ * @param {string} storedHash
54
+ * @param {(canonical: Record<string, unknown>) => Promise<string>} leafHash
55
+ * @param {readonly string[]} [extraOutcomes]
56
+ */
57
+ export async function legacyClearedMatches(canonical, storedHash, leafHash, extraOutcomes) {
58
+ const claimed = hex64(storedHash);
59
+ if (!claimed) return false;
60
+ const base = { ...(canonical || {}) };
61
+ delete base.text_cleared_at;
62
+ delete base.uncleared_digest;
63
+ const seen = new Set();
64
+ const list = extraOutcomes ? [...LEGACY_OUTCOMES, ...extraOutcomes] : LEGACY_OUTCOMES;
65
+ for (const outcome of list) {
66
+ if (typeof outcome !== "string") continue;
67
+ const label = outcome.trim();
68
+ if (!label || seen.has(label)) continue;
69
+ seen.add(label);
70
+ if (await leafHash({ ...base, outcome: label }) === claimed) return true;
71
+ }
72
+ return false;
73
+ }
74
+
75
+ /**
76
+ * Direct recompute, then the cleared-text rule.
77
+ * `meta.text_cleared_at` is the sibling flag. It is not part of the leaf.
78
+ *
79
+ * @param {Record<string, unknown>} canonical
80
+ * @param {string} storedHash
81
+ * @param {(canonical: Record<string, unknown>) => Promise<string>} leafHash
82
+ * @param {{ text_cleared_at?: unknown, hash_version?: unknown, uncleared_digest?: unknown, status?: unknown }} [meta]
83
+ */
84
+ export async function clearedLeafMatches(canonical, storedHash, leafHash, meta) {
85
+ const raw = String(storedHash ?? "");
86
+ const claimed = hex64(raw);
87
+ let recomputed = "";
88
+ try {
89
+ recomputed = await leafHash(canonical || {});
90
+ } catch {
91
+ recomputed = "";
92
+ }
93
+ if (recomputed && (recomputed === raw || recomputed === claimed)) return true;
94
+ const cleared = meta?.text_cleared_at || canonical?.text_cleared_at;
95
+ if (!cleared) return false;
96
+ const version = Number(meta?.hash_version ?? canonical?.hash_version);
97
+ if (version === 4) {
98
+ if (!claimed) return false;
99
+ const digest = hex64(meta?.uncleared_digest || canonical?.uncleared_digest);
100
+ if (!digest) return false;
101
+ const body = { ...(canonical || {}) };
102
+ delete body.receipt_id;
103
+ delete body.batch_id;
104
+ delete body.text_cleared_at;
105
+ delete body.uncleared_digest;
106
+ for (const key of V4_CLEARED) delete body[key];
107
+ const partial = await leafHash(body);
108
+ return partial === digest;
109
+ }
110
+ const extra = [];
111
+ if (typeof meta?.status === "string" && meta.status.trim()) extra.push(meta.status.trim());
112
+ if (await legacyClearedMatches(canonical, claimed, leafHash, extra)) return true;
113
+ const digest = hex64(meta?.uncleared_digest || canonical?.uncleared_digest);
114
+ if (!digest) return false;
115
+ const body = { ...(canonical || {}) };
116
+ delete body.text_cleared_at;
117
+ delete body.uncleared_digest;
118
+ delete body.outcome;
119
+ return (await leafHash(body)) === digest;
120
+ }
@@ -0,0 +1,112 @@
1
+ // Leaf and prev_hash checks for GET /api/proof/export JSON.
2
+ // Moved from scripts/verify-evidence.mjs so the package and that script share
3
+ // one implementation. Behavior is unchanged:
4
+ // sha256Hex(JSON.stringify(canonicalize(canonical minus {receipt_id, batch_id})))
5
+ // canonicalize sorts object keys recursively and drops null and undefined.
6
+ // After the first row, prev_hash must equal the previous receipt_hash.
7
+ // The first prev_hash must be 64 hex characters (genesis, or a receipt older
8
+ // than the export window). A truncated export is not a pass.
9
+ // When text_cleared_at is set, the stored hash covers the cleared fields
10
+ // and every field that was not cleared is still checked.
11
+
12
+ import { clearedLeafMatches } from "./cleared.mjs";
13
+
14
+ export function canonicalize(v) {
15
+ if (v === null || v === undefined) return undefined;
16
+ if (Array.isArray(v)) return v.map(canonicalize);
17
+ if (typeof v === "object") {
18
+ const out = {};
19
+ for (const k of Object.keys(v).sort()) {
20
+ const cv = canonicalize(v[k]);
21
+ if (cv !== undefined) out[k] = cv;
22
+ }
23
+ return out;
24
+ }
25
+ return v;
26
+ }
27
+
28
+ export async function sha256Hex(input) {
29
+ const data = typeof input === "string" ? new TextEncoder().encode(input) : input;
30
+ const buf = await crypto.subtle.digest("SHA-256", data);
31
+ return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
32
+ }
33
+
34
+ export async function leafHash(canonical) {
35
+ const rest = { ...canonical };
36
+ delete rest.receipt_id;
37
+ delete rest.batch_id;
38
+ return sha256Hex(JSON.stringify(canonicalize(rest)));
39
+ }
40
+
41
+ export { clearedLeafMatches };
42
+
43
+ /**
44
+ * @param {unknown} bundle
45
+ * @returns {Promise<{ ok: boolean, checked: number, failures: Array<{ receipt_hash: string, prev_hash: string, hash_ok: boolean, chain_ok: boolean, reason: string }> }>}
46
+ */
47
+ export async function verifyEvidenceExport(bundle) {
48
+ const receipts = bundle && Array.isArray(bundle.receipts) ? bundle.receipts : null;
49
+ if (!receipts) {
50
+ return {
51
+ ok: false,
52
+ checked: 0,
53
+ failures: [{
54
+ receipt_hash: "",
55
+ prev_hash: "",
56
+ hash_ok: false,
57
+ chain_ok: false,
58
+ reason: "bundle.receipts is missing",
59
+ }],
60
+ };
61
+ }
62
+ const failures = [];
63
+ for (let i = 0; i < receipts.length; i++) {
64
+ const row = receipts[i] ?? {};
65
+ const canonical = row.canonical ?? {};
66
+ const claimed = String(row.receipt_hash ?? "");
67
+ const prev = String(canonical.prev_hash ?? "");
68
+ let recomputed = "";
69
+ try {
70
+ recomputed = await leafHash(canonical);
71
+ } catch {
72
+ recomputed = "";
73
+ }
74
+ const hashOk = recomputed === claimed || await clearedLeafMatches(canonical, claimed, leafHash, {
75
+ text_cleared_at: row.text_cleared_at,
76
+ hash_version: row.hash_version,
77
+ uncleared_digest: row.uncleared_digest,
78
+ status: row.status,
79
+ });
80
+ let chainOk = true;
81
+ let reason = null;
82
+ if (!hashOk) reason = `leaf recomputed ${recomputed} != ${claimed}`;
83
+ if (i === 0) {
84
+ if (!/^[0-9a-f]{64}$/.test(prev)) {
85
+ chainOk = false;
86
+ reason = reason ?? "prev_hash is not 64 hex characters";
87
+ }
88
+ } else if (prev !== String(receipts[i - 1].receipt_hash ?? "")) {
89
+ chainOk = false;
90
+ reason = reason ?? "prev_hash does not match the previous receipt_hash";
91
+ }
92
+ if (!hashOk || !chainOk) {
93
+ failures.push({
94
+ receipt_hash: claimed,
95
+ prev_hash: prev,
96
+ hash_ok: hashOk,
97
+ chain_ok: chainOk,
98
+ reason: reason ?? "failed",
99
+ });
100
+ }
101
+ }
102
+ if (bundle.verification && bundle.verification.truncated === true) {
103
+ failures.push({
104
+ receipt_hash: "",
105
+ prev_hash: "",
106
+ hash_ok: false,
107
+ chain_ok: false,
108
+ reason: "export was truncated; refuse to call it complete",
109
+ });
110
+ }
111
+ return { ok: failures.length === 0, checked: receipts.length, failures };
112
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,32 @@
1
+ export function canonicalize(value: unknown): unknown;
2
+ export function sha256Hex(input: string): Promise<string>;
3
+ export function leafHash(canonical: unknown): Promise<string>;
4
+ export function verifyEvidenceExport(bundle: unknown): Promise<unknown>;
5
+
6
+ export function merkleRoot(leaves: string[]): Promise<string | null>;
7
+ export function merkleProof(leaves: string[], index: number): Promise<string[] | null>;
8
+ export function verifyProof(leaf: string, proof: string[] | null | undefined, root: string): Promise<boolean>;
9
+
10
+ /** Tree rule v2 (RFC 9162 2.1). Batches name their rule in tree_rule; absent is v1. */
11
+ export const TREE_RULE_V1: "v1";
12
+ export const TREE_RULE_V2: "v2";
13
+ export function treeRuleOf(value: unknown): "v1" | "v2" | null;
14
+ export function leafHashV2(leaf: string): Promise<string>;
15
+ export function merkleRootV2(leaves: string[]): Promise<string | null>;
16
+ export function merkleProofV2(leaves: string[], index: number): Promise<string[] | null>;
17
+ export function verifyProofV2(leaf: string, index: number, size: number, proof: string[] | null | undefined, root: string): Promise<boolean>;
18
+
19
+ export function verifyBundle(bundle: unknown, opts?: Record<string, unknown>): Promise<unknown>;
20
+
21
+ export function agentRecord(events: unknown, asOf?: string): unknown;
22
+ export function recordBundle(input: { handle: string; as_of?: string; outcomes: unknown }): unknown;
23
+ export function signRecord(privateJwk: Record<string, unknown>, bundle: unknown): Promise<unknown>;
24
+ export function verifyRecord(bundle: unknown, jwks: unknown): Promise<unknown>;
25
+ export function isRecordBundle(bundle: unknown): boolean;
26
+
27
+ export function classifyCluster(
28
+ hint: string | null | undefined,
29
+ explorer?: string | null | undefined,
30
+ ): "devnet" | "testnet" | "mainnet" | null;
31
+ export function checkSolanaMemo(opts: Record<string, unknown>): Promise<unknown>;
32
+ export const PUBLIC_RPC: { readonly devnet: string; readonly testnet: string };
package/src/index.mjs ADDED
@@ -0,0 +1,8 @@
1
+ export { canonicalize, sha256Hex, leafHash, verifyEvidenceExport } from "./evidence.mjs";
2
+ export {
3
+ merkleRoot, merkleProof, verifyProof,
4
+ TREE_RULE_V1, TREE_RULE_V2, treeRuleOf, leafHashV2, merkleRootV2, merkleProofV2, verifyProofV2,
5
+ } from "./merkle.mjs";
6
+ export { verifyBundle } from "./bundle.mjs";
7
+ export { agentRecord, recordBundle, signRecord, verifyRecord, isRecordBundle } from "./record.mjs";
8
+ export { classifyCluster, checkSolanaMemo, PUBLIC_RPC } from "./solana.mjs";