@agenticstechnologies/verify 0.0.0-stage → 1.0.0

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/LICENSE ADDED
@@ -0,0 +1,17 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ https://www.apache.org/licenses/
4
+
5
+ Copyright 2026 Agentics, Inc.
6
+
7
+ Licensed under the Apache License, Version 2.0 (the "License");
8
+ you may not use this file except in compliance with the License.
9
+ You may obtain a copy of the License at
10
+
11
+ https://www.apache.org/licenses/LICENSE-2.0
12
+
13
+ Unless required by applicable law or agreed to in writing, software
14
+ distributed under the License is distributed on an "AS IS" BASIS,
15
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ See the License for the specific language governing permissions and
17
+ limitations under the License.
package/README.md CHANGED
@@ -1,3 +1,154 @@
1
- # Temporary Holding Version
1
+ # @agenticstechnologies/verify
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Offline checks for an Agentics evidence export. The tool recomputes receipt
4
+ leaves and the `prev_hash` chain from the JSON file. It does not call
5
+ Agentics, and it does not trust the `verification` summary inside the file.
6
+
7
+ ## Install
8
+
9
+ > **Not yet published.** `@agenticstechnologies/verify` is not on npm, so this install fails with E404. Run it from a checkout of this repo, as shown below.
10
+
11
+ ```bash
12
+ npm install @agenticstechnologies/verify
13
+ ```
14
+
15
+ The package binary is `agentics-verify`.
16
+
17
+ ## Version bump policy
18
+
19
+ Versions follow semver. A fix increments the patch number, a backwards-compatible addition increments the minor number, and a breaking change increments the major number. Update `version` in this package's `package.json` in the same commit as the change. Publishing uses the founder's npm token and does not run on merge.
20
+
21
+ `scripts/verify-evidence.mjs` is a thin wrapper around the same leaf and chain
22
+ code. From a checkout of this repo:
23
+
24
+ ```bash
25
+ node scripts/verify-evidence.mjs bundle.json
26
+ node packages/agentics-verify/bin/verify.mjs bundle.json
27
+ ```
28
+
29
+ The package binary is `agentics-verify` (`./bin/verify.mjs`). Pass `-` to read
30
+ JSON from stdin.
31
+
32
+ ## Exit codes
33
+
34
+ | Code | Meaning |
35
+ | --- | --- |
36
+ | 0 | Leaf and chain match. Merkle, when present, folds. A devnet or testnet memo, when present, matches or the public RPC was skipped. |
37
+ | 1 | A check failed, or the file is not JSON. The reason is on stderr. |
38
+ | 2 | No bundle path was given. |
39
+
40
+ A passing package run prints three lines:
41
+
42
+ ```text
43
+ pass checked 2 receipt(s)
44
+ merkle not-present batch/merkle fields are not in this export
45
+ solana not-present tx/cluster are not in this export
46
+ ```
47
+
48
+ `not-present` means that block was skipped. It is not a failure. `warn` (RPC
49
+ timeout or a cluster this tool does not query) is also not a failure.
50
+
51
+ ## What is checked
52
+
53
+ **Leaf and chain.** Same algorithm as `lib/gateway/proof.ts` `leafHash` and
54
+ the date-range export from `GET /api/proof/export`:
55
+
56
+ ```text
57
+ sha256Hex(JSON.stringify(canonicalize(canonical minus {receipt_id, batch_id})))
58
+ ```
59
+
60
+ `canonicalize` sorts object keys and drops null and undefined. Each receipt
61
+ after the first must set `prev_hash` to the previous `receipt_hash`. The first
62
+ `prev_hash` must be 64 hex characters. `verification.truncated: true` fails
63
+ the file, including a single-receipt proof bundle.
64
+
65
+ If a receipt sets `text_cleared_at`, the stored hash covers the cleared fields.
66
+ Older leaves still check every other canonical field by trying the historical
67
+ outcome labels in memory. The label is not printed. An older outcome that is
68
+ not in that list, and every version 4 receipt, must carry `uncleared_digest`.
69
+ Version 4 checks the leaf with `reason`, `target`, `outcome`, and
70
+ `error_message` removed against that digest. An older unlisted outcome checks
71
+ the leaf with `outcome` removed. Without the digest those leaves fail. The
72
+ chain link is unchanged. A changed amount, agent, or `prev_hash` still fails.
73
+
74
+ A proof bundle from `GET /api/proof/bundle` has one `receipt` plus
75
+ `receipt_hash` instead of `receipts`. The leaf is recomputed the same way.
76
+ `chain` is checked as hash links (oldest-first or the newest-first order
77
+ `chainFor` returns). Ancestor bodies are not in that bundle, so only the
78
+ included receipt is re-hashed.
79
+
80
+ **Merkle.** When the export includes a proof, the fold is the sorted-pair
81
+ Merkle in `netlify/edge-functions/lib/ledger.js` (`hashPair` hashes the two
82
+ digests in sorted order, an odd node is duplicated, an empty list is null).
83
+ That is the same fold `lib/gateway/proof.ts` uses for a batch. It is tree rule
84
+ `v1`, and every batch anchored before v2 stays v1.
85
+
86
+ **Tree rule v2 (1.0.0).** A claim with `tree_rule: "v2"` uses RFC 9162
87
+ section 2.1 (RFC 6962) over the 32 bytes of each leaf:
88
+
89
+ - leaf = SHA-256(0x00 || leaf)
90
+ - node = SHA-256(0x01 || left || right), in position order
91
+ - an odd last node is promoted unchanged
92
+ - a one-leaf root is its leaf hash
93
+
94
+ A v2 proof is the RFC audit path, so it also needs `index` (or `leaf_index`)
95
+ and `leaf_count` (or `tree_size`). Without them the claim fails rather than
96
+ guessing. With no `tree_rule`, the claim is v1. Any other value is refused,
97
+ never folded as v1. `merkleRootV2`, `merkleProofV2`, `verifyProofV2`,
98
+ `leafHashV2` and `treeRuleOf` are exported.
99
+
100
+ Recognized fields, and no others:
101
+
102
+ - `merkle.siblings` or `merkle.proof`, with `merkle.root` or `merkle.merkle_root`
103
+ - `merkle.leaves` with a root (full leaf list, receipt-hash order)
104
+ - the same fields on a receipt row's `merkle` object
105
+ - `batches[].merkle_root` with `batches[].leaves` or `batches[].siblings`
106
+ - `tree_rule` and `leaf_count` on any of the above (v2 only)
107
+
108
+ The proof bundle published by `bundle()` uses `merkle.siblings` and
109
+ `merkle.root`. If none of these fields are present, the line is
110
+ `merkle not-present`. A root with neither siblings nor a leaf list fails:
111
+ the export claimed a root and this tool cannot recompute it.
112
+
113
+ **Solana memo.** Only when the bundle has both a transaction id and a cluster
114
+ (`anchor.txid` and `anchor.chain` or `anchor.cluster`, or top-level `tx` and
115
+ `cluster`). Devnet (`solana-devnet`, `devnet`, Solscan `?cluster=devnet`) uses
116
+ `https://api.devnet.solana.com`. Testnet uses `https://api.testnet.solana.com`.
117
+ The public `getTransaction` response is read for a Memo program instruction
118
+ (`MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr`) and compared to the recomputed
119
+ Merkle root, or to `anchor.memo` when the export has no root. No private key
120
+ is read and nothing is signed. Mainnet is not queried. If `anchor.memo` is
121
+ present and a Merkle root was recomputed, the two must match before any
122
+ network read; a missing transaction id still skips the public RPC. If the RPC
123
+ does not answer within 8 seconds, the line is `solana warn` and the process
124
+ still exits 0 when every other check passed.
125
+
126
+ ## Agent record
127
+
128
+ `GET /api/record/:handle/export` is a signed record (`typ` `agentics.record.v1`).
129
+ Check it with the public keys from `https://agentics.you/.well-known/jwks.json`.
130
+ The tool does not call Agentics.
131
+
132
+ ```bash
133
+ node packages/agentics-verify/bin/verify.mjs record.json --jwks jwks.json
134
+ ```
135
+
136
+ Exit 0 prints the handle, the label, and the counts. Exit 1 is `fail`, the
137
+ reason code, and a sentence. A changed count, a changed score, or a flipped
138
+ signature byte fails. A record does not expire by clock. A previous signing
139
+ key still verifies old exports while it remains in the published key set.
140
+ `New, no record yet` is a real label: the file had no settlement and no claim.
141
+
142
+ ## Tests
143
+
144
+ ```bash
145
+ node --test packages/agentics-verify/test/verify.test.mjs
146
+ ```
147
+
148
+ The fixture `test/fixtures/evidence-export.json` is an unmodified
149
+ `agentics.evidence.v1` export. Tampering with `receipt_hash` or a leaf field
150
+ exits 1.
151
+
152
+ ## License
153
+
154
+ Apache-2.0 — © Agentics Technologies LLC.
package/bin/verify.mjs ADDED
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env node
2
+ // Offline check of an Agentics evidence export.
3
+ //
4
+ // node packages/agentics-verify/bin/verify.mjs bundle.json
5
+ // node packages/agentics-verify/bin/verify.mjs - # JSON on stdin
6
+ //
7
+ // Exit 0 when the leaf and prev_hash checks pass, any included Merkle proof
8
+ // folds, and a devnet/testnet memo (if present) matches or the RPC is skipped.
9
+ // Exit 1 when a check fails. Exit 2 when no path is given.
10
+
11
+ import { verifyBundle } from "../src/bundle.mjs";
12
+ import { isRecordBundle, verifyRecord } from "../src/record.mjs";
13
+
14
+ function statusLine(label, part) {
15
+ return `${label} ${part.status}${part.reason ? " " + part.reason : ""}`;
16
+ }
17
+
18
+ const args = process.argv.slice(2);
19
+ let jwksPath = null;
20
+ const positional = [];
21
+ for (let i = 0; i < args.length; i++) {
22
+ if (args[i] === "--jwks") {
23
+ jwksPath = args[i + 1] ?? "";
24
+ i += 1;
25
+ continue;
26
+ }
27
+ positional.push(args[i]);
28
+ }
29
+ const arg = positional[0];
30
+ if (!arg) {
31
+ console.error("usage: node packages/agentics-verify/bin/verify.mjs <bundle.json | -> [--jwks jwks.json]");
32
+ process.exit(2);
33
+ }
34
+
35
+ const fs = await import("node:fs");
36
+ let text;
37
+ try {
38
+ text = arg === "-" ? fs.readFileSync(0, "utf8") : fs.readFileSync(arg, "utf8");
39
+ } catch (e) {
40
+ console.error("cannot read bundle:", e instanceof Error ? e.message : String(e));
41
+ process.exit(1);
42
+ }
43
+
44
+ let bundle;
45
+ try {
46
+ bundle = JSON.parse(text);
47
+ } catch (e) {
48
+ console.error("invalid JSON:", e instanceof Error ? e.message : String(e));
49
+ process.exit(1);
50
+ }
51
+
52
+ if (isRecordBundle(bundle)) {
53
+ if (!jwksPath) {
54
+ console.error("fail jwks required pass --jwks with the keys from /.well-known/jwks.json");
55
+ process.exit(1);
56
+ }
57
+ let jwksText;
58
+ try {
59
+ jwksText = fs.readFileSync(jwksPath, "utf8");
60
+ } catch (e) {
61
+ console.error("cannot read jwks:", e instanceof Error ? e.message : String(e));
62
+ process.exit(1);
63
+ }
64
+ let jwks;
65
+ try {
66
+ jwks = JSON.parse(jwksText);
67
+ } catch (e) {
68
+ console.error("invalid jwks JSON:", e instanceof Error ? e.message : String(e));
69
+ process.exit(1);
70
+ }
71
+ const checked = await verifyRecord(bundle, jwks);
72
+ if (!checked.ok) {
73
+ console.error(`fail ${checked.reason}${checked.message ? " " + checked.message : ""}`);
74
+ process.exit(1);
75
+ }
76
+ const record = checked.record ?? {};
77
+ console.log(`pass record ${checked.handle}`);
78
+ console.log(`label ${record.label}`);
79
+ console.log(`paid on time ${record.paid_on_time}`);
80
+ console.log(`claims filed ${record.claims_filed}`);
81
+ console.log(`claims paid ${record.claims_paid}`);
82
+ console.log(`sellers ${record.distinct_sellers}`);
83
+ console.log(`age ${record.age_days == null ? "none" : record.age_days + " days"}`);
84
+ process.exit(0);
85
+ }
86
+
87
+ const result = await verifyBundle(bundle);
88
+ const merkleLine = statusLine("merkle", result.merkle);
89
+ const solanaLine = statusLine("solana", result.solana);
90
+
91
+ if (result.ok) {
92
+ console.log(`pass checked ${result.checked} receipt(s)`);
93
+ console.log(merkleLine);
94
+ if (result.solana.status === "warn") console.error(solanaLine);
95
+ else console.log(solanaLine);
96
+ process.exit(0);
97
+ }
98
+
99
+ console.error(`fail checked ${result.checked}`);
100
+ for (const f of result.failures) {
101
+ console.error(`- ${f.reason} hash=${f.receipt_hash} prev=${f.prev_hash}`);
102
+ }
103
+ console.error(merkleLine);
104
+ console.error(solanaLine);
105
+ process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,35 @@
1
1
  {
2
2
  "name": "@agenticstechnologies/verify",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "Offline verifier for Agentics evidence export bundles. Recomputes receipt leaves and the prev_hash chain, and folds a Merkle proof when the export includes one.",
5
+ "license": "Apache-2.0",
6
+ "author": "Agentics Technologies LLC",
7
+ "homepage": "https://agentics.you",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/agenticstechnologies/agentics.you",
11
+ "directory": "packages/agentics-verify"
12
+ },
13
+ "type": "module",
14
+ "bin": {
15
+ "agentics-verify": "./bin/verify.mjs"
16
+ },
17
+ "types": "./src/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./src/index.d.ts",
21
+ "import": "./src/index.mjs"
22
+ },
23
+ "./cleared": {
24
+ "types": "./src/cleared.d.ts",
25
+ "import": "./src/cleared.mjs"
26
+ }
27
+ },
28
+ "scripts": {
29
+ "test": "node --test test/verify.test.mjs"
30
+ },
31
+ "engines": { "node": ">=18" },
32
+ "files": ["bin", "src", "README.md", "LICENSE"],
33
+ "publishConfig": { "access": "public" },
34
+ "keywords": ["agentics", "evidence", "receipts", "merkle", "verify"]
35
+ }
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>;