@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 +17 -0
- package/README.md +153 -2
- package/bin/verify.mjs +105 -0
- package/package.json +33 -4
- package/src/bundle.mjs +364 -0
- package/src/cleared.d.ts +11 -0
- package/src/cleared.mjs +120 -0
- package/src/evidence.mjs +112 -0
- package/src/index.d.ts +32 -0
- package/src/index.mjs +8 -0
- package/src/merkle.mjs +150 -0
- package/src/record.mjs +440 -0
- package/src/solana.mjs +235 -0
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
|
-
#
|
|
1
|
+
# @agenticstechnologies/verify
|
|
2
2
|
|
|
3
|
-
|
|
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": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|
package/src/cleared.d.ts
ADDED
|
@@ -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>;
|