verifyhash 0.1.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.
Files changed (154) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +883 -0
  3. package/cli/abi/ContributionRegistry.json +881 -0
  4. package/cli/agent.js +2173 -0
  5. package/cli/anchor-artifact.js +853 -0
  6. package/cli/anchor.js +400 -0
  7. package/cli/claim.js +881 -0
  8. package/cli/core/agent-commit.js +448 -0
  9. package/cli/core/agent-session.js +598 -0
  10. package/cli/core/anchor-binding.js +663 -0
  11. package/cli/core/attestation.js +580 -0
  12. package/cli/core/evidence-plans.js +495 -0
  13. package/cli/core/fixtures/evidence-plans/baseline.json +19 -0
  14. package/cli/core/fulfill-intake.js +1082 -0
  15. package/cli/core/go-live-preflight.js +481 -0
  16. package/cli/core/license.js +534 -0
  17. package/cli/core/manifest.js +243 -0
  18. package/cli/core/packetseal.js +591 -0
  19. package/cli/core/registryArtifact.js +49 -0
  20. package/cli/core/revocation.js +539 -0
  21. package/cli/core/rfc3161.js +389 -0
  22. package/cli/core/timestamp.js +482 -0
  23. package/cli/core/trust-asof.js +479 -0
  24. package/cli/dataset.js +2950 -0
  25. package/cli/evidence.js +2227 -0
  26. package/cli/fulfill-webhook-http.js +438 -0
  27. package/cli/git.js +220 -0
  28. package/cli/hash.js +550 -0
  29. package/cli/identity.js +1072 -0
  30. package/cli/journal-cli.js +1110 -0
  31. package/cli/journal-log.js +454 -0
  32. package/cli/journal.js +334 -0
  33. package/cli/lineage.js +447 -0
  34. package/cli/list.js +287 -0
  35. package/cli/parcel.js +1509 -0
  36. package/cli/proof.js +578 -0
  37. package/cli/prove.js +300 -0
  38. package/cli/receipt.js +631 -0
  39. package/cli/registry.js +331 -0
  40. package/cli/reputation.js +344 -0
  41. package/cli/revocation.js +495 -0
  42. package/cli/serve-verify-http.js +298 -0
  43. package/cli/serve-verify.js +333 -0
  44. package/cli/show.js +339 -0
  45. package/cli/verify.js +383 -0
  46. package/cli/vh.js +3927 -0
  47. package/docs/ADOPT.md +183 -0
  48. package/docs/ADOPTION.json +11 -0
  49. package/docs/AGENTTRACE.md +247 -0
  50. package/docs/ANCHORING.md +167 -0
  51. package/docs/AUDIT.md +55 -0
  52. package/docs/CONFORMANCE.md +107 -0
  53. package/docs/DATALEDGER.md +638 -0
  54. package/docs/DECIDE.md +47 -0
  55. package/docs/DECISIONS-PENDING.md +27 -0
  56. package/docs/DEPLOY-PUBLIC-SITE.md +301 -0
  57. package/docs/ENGINE-LEDGER.json +12 -0
  58. package/docs/EVIDENCE.md +519 -0
  59. package/docs/GO-LIVE.md +66 -0
  60. package/docs/IDENTITY.md +123 -0
  61. package/docs/INDEPENDENT-VERIFICATION.md +377 -0
  62. package/docs/INTEGRITY-JOURNAL.md +337 -0
  63. package/docs/KEY-LIFECYCLE.md +179 -0
  64. package/docs/LICENSING.md +46 -0
  65. package/docs/LINEAGE.md +307 -0
  66. package/docs/LOOP-AUDIT-2026-07-03.json +580 -0
  67. package/docs/LOOP-HARDENING-PLAN.md +44 -0
  68. package/docs/MERKLE-LEAVES.md +113 -0
  69. package/docs/METRICS.jsonl +31 -0
  70. package/docs/MORNING.md +204 -0
  71. package/docs/PILOT.md +444 -0
  72. package/docs/PROOFPARCEL.md +227 -0
  73. package/docs/PROOFS.md +262 -0
  74. package/docs/RECEIPTS.md +341 -0
  75. package/docs/REPUTATION.md +158 -0
  76. package/docs/SDK.md +301 -0
  77. package/docs/STRATEGY-ARCHIVE.md +5055 -0
  78. package/docs/SUPERVISOR-RUNBOOK.md +52 -0
  79. package/docs/TRUST-BOUNDARIES.md +335 -0
  80. package/docs/TRUSTLEDGER.md +1976 -0
  81. package/docs/USAGE-BUDGET.json +121 -0
  82. package/docs/VERIFY-SERVICE.md +168 -0
  83. package/index.js +160 -0
  84. package/package.json +41 -0
  85. package/trustledger/build-standalone.js +796 -0
  86. package/trustledger/cli.js +3179 -0
  87. package/trustledger/close.js +391 -0
  88. package/trustledger/corpus.js +159 -0
  89. package/trustledger/dist/BUILD-PROVENANCE.json +99 -0
  90. package/trustledger/dist/trustledger-standalone.html +6197 -0
  91. package/trustledger/dist/trustledger-standalone.html.sha256 +1 -0
  92. package/trustledger/door-core.js +442 -0
  93. package/trustledger/fixtures/bank.csv +7 -0
  94. package/trustledger/fixtures/bank.malformed.csv +3 -0
  95. package/trustledger/fixtures/bank.noalias.csv +5 -0
  96. package/trustledger/fixtures/bank.ofx +34 -0
  97. package/trustledger/fixtures/bank.real.csv +5 -0
  98. package/trustledger/fixtures/corpus/_shared/prior-close.json +22 -0
  99. package/trustledger/fixtures/corpus/bank-book-mismatch--benign-twin/inputs.json +14 -0
  100. package/trustledger/fixtures/corpus/bank-book-mismatch--benign-twin/meta.json +7 -0
  101. package/trustledger/fixtures/corpus/bank-book-mismatch--out-of-trust/inputs.json +14 -0
  102. package/trustledger/fixtures/corpus/bank-book-mismatch--out-of-trust/meta.json +7 -0
  103. package/trustledger/fixtures/corpus/continuity-break--benign-twin/inputs.json +15 -0
  104. package/trustledger/fixtures/corpus/continuity-break--benign-twin/meta.json +7 -0
  105. package/trustledger/fixtures/corpus/continuity-break--out-of-trust/inputs.json +15 -0
  106. package/trustledger/fixtures/corpus/continuity-break--out-of-trust/meta.json +7 -0
  107. package/trustledger/fixtures/corpus/negative-tenant-ledger--benign-twin/inputs.json +13 -0
  108. package/trustledger/fixtures/corpus/negative-tenant-ledger--benign-twin/meta.json +7 -0
  109. package/trustledger/fixtures/corpus/negative-tenant-ledger--out-of-trust/inputs.json +13 -0
  110. package/trustledger/fixtures/corpus/negative-tenant-ledger--out-of-trust/meta.json +7 -0
  111. package/trustledger/fixtures/corpus/owner-overdraw--benign-twin/inputs.json +15 -0
  112. package/trustledger/fixtures/corpus/owner-overdraw--benign-twin/meta.json +7 -0
  113. package/trustledger/fixtures/corpus/owner-overdraw--out-of-trust/inputs.json +15 -0
  114. package/trustledger/fixtures/corpus/owner-overdraw--out-of-trust/meta.json +7 -0
  115. package/trustledger/fixtures/corpus/security-deposit-segregation--benign-twin/inputs.json +16 -0
  116. package/trustledger/fixtures/corpus/security-deposit-segregation--benign-twin/meta.json +7 -0
  117. package/trustledger/fixtures/corpus/security-deposit-segregation--out-of-trust/inputs.json +13 -0
  118. package/trustledger/fixtures/corpus/security-deposit-segregation--out-of-trust/meta.json +7 -0
  119. package/trustledger/fixtures/corpus/subledger-out-of-balance--benign-twin/inputs.json +13 -0
  120. package/trustledger/fixtures/corpus/subledger-out-of-balance--benign-twin/meta.json +7 -0
  121. package/trustledger/fixtures/corpus/subledger-out-of-balance--out-of-trust/inputs.json +13 -0
  122. package/trustledger/fixtures/corpus/subledger-out-of-balance--out-of-trust/meta.json +7 -0
  123. package/trustledger/fixtures/e2e/bank.aliased.csv +4 -0
  124. package/trustledger/fixtures/e2e/bank.csv +4 -0
  125. package/trustledger/fixtures/e2e/bank.nsf.csv +4 -0
  126. package/trustledger/fixtures/e2e/quickbooks.csv +6 -0
  127. package/trustledger/fixtures/e2e/quickbooks.nsf.csv +8 -0
  128. package/trustledger/fixtures/e2e/rentroll.csv +6 -0
  129. package/trustledger/fixtures/e2e/rentroll.nsf.csv +8 -0
  130. package/trustledger/fixtures/e2e/rentroll.short.csv +5 -0
  131. package/trustledger/fixtures/plans/baseline.json +25 -0
  132. package/trustledger/fixtures/plans/price-binding.example.json +27 -0
  133. package/trustledger/fixtures/policy/ambiguous-deposit-example.json +12 -0
  134. package/trustledger/fixtures/policy/baseline.json +19 -0
  135. package/trustledger/fixtures/policy/ca-example.json +12 -0
  136. package/trustledger/fixtures/policy/negative-tenant-ledger-example.json +12 -0
  137. package/trustledger/fixtures/policy/owner-overdraw-example.json +12 -0
  138. package/trustledger/fixtures/quickbooks.csv +7 -0
  139. package/trustledger/fixtures/quickbooks.real.csv +5 -0
  140. package/trustledger/fixtures/rentroll.csv +6 -0
  141. package/trustledger/fixtures/rentroll.real.csv +4 -0
  142. package/trustledger/ingest.js +1163 -0
  143. package/trustledger/lib/policy-bundled-loader.js +44 -0
  144. package/trustledger/lib/sha256-vendored.js +227 -0
  145. package/trustledger/license.js +563 -0
  146. package/trustledger/match.js +551 -0
  147. package/trustledger/plans.js +551 -0
  148. package/trustledger/policy.js +398 -0
  149. package/trustledger/public/index.html +512 -0
  150. package/trustledger/reconcile.js +1486 -0
  151. package/trustledger/report.js +887 -0
  152. package/trustledger/seal.js +854 -0
  153. package/trustledger/server.js +391 -0
  154. package/trustledger/valueproof.js +350 -0
package/cli/proof.js ADDED
@@ -0,0 +1,578 @@
1
+ "use strict";
2
+
3
+ // cli/proof.js — a versioned, strictly-validated, PORTABLE Merkle-proof artifact for verifyhash.
4
+ //
5
+ // WHY THIS EXISTS
6
+ // `vh prove <file> --root <dir>` builds a genuine Merkle proof that a single file belongs to an
7
+ // anchored repository root, but historically it only PRINTED the proof or checked it on-chain
8
+ // in-process against the prover's own working tree. That makes the proof non-portable: a third
9
+ // party handed "file X is in anchored repo root R" cannot independently confirm it without
10
+ // re-running the prover against the prover's files. That directly contradicts the project's core
11
+ // promise — "anyone can later prove some content is byte-for-byte what was anchored, without
12
+ // trusting any server."
13
+ //
14
+ // This module closes that gap. `vh prove <file> --root <dir> --out <p>` writes a SELF-CONTAINED
15
+ // proof artifact: everything a verifier needs is in the file. `vh verify-proof <p>` then verifies
16
+ // it needing ONLY the artifact + an RPC URL — never the original repo or working tree:
17
+ // (a) it re-derives the leaf from `contentHash` + `relPath` and re-folds `proof` PURELY OFFLINE,
18
+ // using the SAME sorted-pair / domain-separated convention the contract's verifyLeaf uses
19
+ // (reusing hash.js's pathLeaf / leafHash / nodeHash — NOT a re-implementation), to confirm
20
+ // the proof folds back to `root`; then
21
+ // (b) it makes ONE read-only on-chain check that the root is ACTUALLY anchored (isAnchored) and
22
+ // that the contract's own verifyLeaf accepts the proof.
23
+ //
24
+ // TRUST POSTURE (consistent with docs/TRUST-BOUNDARIES.md)
25
+ // The artifact is an UNTRUSTED TRANSPORT CONTAINER. verify-proof never trusts the file's claims —
26
+ // it RE-DERIVES the leaf from contentHash+relPath (so a forged `leaf` field that doesn't match its
27
+ // own contentHash+relPath is rejected) and re-folds the proof itself; the `root` it checks on-chain
28
+ // is the one the offline fold produced from the proof, and a root that was never anchored reports
29
+ // NOT ANCHORED rather than a false ACCEPT. What this proves is SET-MEMBERSHIP: that the named file
30
+ // (path + bytes) is a leaf of an anchored repo root. It says NOTHING about authorship, the meaning
31
+ // of `contributor`, or any `uri` — exactly as the contract's verifyLeaf says nothing about those.
32
+ // A corrupt artifact must never be silently half-accepted: readProofArtifact validates strictly and
33
+ // throws on ANY deviation rather than filling defaults (mirroring cli/receipt.js's posture).
34
+
35
+ const fs = require("fs");
36
+ const { pathLeaf, leafHash, nodeHash } = require("./hash");
37
+ const {
38
+ assertRegistry,
39
+ formatRegistryLine,
40
+ formatSkippedLine,
41
+ jsonRegistryBlock,
42
+ jsonSkippedBlock,
43
+ } = require("./registry");
44
+
45
+ const ARTIFACT = require("./core/registryArtifact");
46
+ const ABI = ARTIFACT.abi;
47
+
48
+ // On-disk schema discriminators. A proof artifact carries its OWN kind + version (distinct from the
49
+ // receipt kinds in cli/receipt.js) so a random JSON file, a receipt, or a future/foreign artifact is
50
+ // never misread as a current proof artifact.
51
+ const PROOF_KIND = "verifyhash.merkle-proof";
52
+ const PROOF_SCHEMA_VERSION = 1;
53
+ const SUPPORTED_PROOF_SCHEMA_VERSIONS = Object.freeze([1]);
54
+
55
+ // Same hex/address shapes cli/receipt.js validates against, so the two modules never drift.
56
+ const HEX32_RE = /^0x[0-9a-fA-F]{64}$/;
57
+ const ADDR_RE = /^0x[0-9a-fA-F]{40}$/;
58
+
59
+ // The one-line trust boundary that LEADS every human-readable verify-proof run. It is load-bearing,
60
+ // not decorative: a reader must never mistake a set-membership proof for proof of authorship/URI.
61
+ const TRUST_CAVEAT = [
62
+ "NOTE: this proves SET-MEMBERSHIP only — that the named file (its path + bytes) is a leaf of an",
63
+ "anchored repo Merkle root. It does NOT prove authorship, who anchored the root, or anything about",
64
+ "any `uri`. The artifact is an UNTRUSTED transport container: verify-proof RE-DERIVES the leaf and",
65
+ "RE-FOLDS the proof itself (it never trusts the file's claims), then confirms the root is anchored",
66
+ "on-chain. Set-membership in an anchored root is exactly what the contract's verifyLeaf attests.",
67
+ ].join("\n");
68
+
69
+ // Verify-proof outcomes. ACCEPTED requires BOTH the offline fold AND the on-chain checks to pass.
70
+ const STATUS = Object.freeze({
71
+ ACCEPTED: "ACCEPTED", // offline fold folds to root AND root is anchored AND on-chain verifyLeaf true
72
+ REJECTED: "REJECTED", // an offline or on-chain check failed (tampered proof, leaf, contentHash, …)
73
+ NOT_ANCHORED: "NOT_ANCHORED", // offline fold is fine, but the root was never anchored on-chain
74
+ });
75
+
76
+ /**
77
+ * Build a normalized, fully-validated portable proof artifact from a built proof (the object
78
+ * `buildProof` in cli/prove.js returns) plus optional on-chain context. Throws if any required field
79
+ * is missing or malformed, so a corrupt artifact is never even written.
80
+ *
81
+ * `relPath` is the file's repo-relative POSIX path — exactly what was bound into the leaf, so a
82
+ * verifier can RE-DERIVE the leaf from contentHash + relPath without the original tree. The optional
83
+ * `contractAddress` / `chainId` record WHERE the prover expects the root to be anchored; they are
84
+ * UNTRUSTED hints (the verifier may override with --contract/--rpc) but recording them makes the
85
+ * artifact more self-describing.
86
+ *
87
+ * @param {object} built a buildProof() result: { root, leaf, contentHash, proof, file }
88
+ * @param {object} [ctx]
89
+ * @param {string} [ctx.contractAddress] 0x 20-byte ContributionRegistry address the root is anchored at
90
+ * @param {number|string|bigint} [ctx.chainId] the chain the root is anchored on
91
+ * @returns {object} a validated proof-artifact object
92
+ */
93
+ function buildProofArtifact(built, ctx = {}) {
94
+ if (!built || typeof built !== "object") {
95
+ throw new Error("buildProofArtifact requires the object buildProof() returns");
96
+ }
97
+ const artifact = {
98
+ kind: PROOF_KIND,
99
+ schemaVersion: PROOF_SCHEMA_VERSION,
100
+ root: built.root,
101
+ leaf: built.leaf,
102
+ contentHash: built.contentHash,
103
+ relPath: built.file,
104
+ proof: built.proof,
105
+ };
106
+ if (ctx.contractAddress != null) artifact.contractAddress = ctx.contractAddress;
107
+ if (ctx.chainId != null) artifact.chainId = _normChainId(ctx.chainId);
108
+ _validate(artifact);
109
+ return artifact;
110
+ }
111
+
112
+ /** Normalize a chainId (number|string|bigint) to a non-negative integer Number, like receipt.js. */
113
+ function _normChainId(v) {
114
+ let n;
115
+ try {
116
+ n = Number(BigInt(v));
117
+ } catch (_) {
118
+ throw new Error(`proof artifact chainId must be an integer, got: ${String(v)}`);
119
+ }
120
+ if (!Number.isSafeInteger(n) || n < 0) {
121
+ throw new Error(`proof artifact chainId must be a non-negative integer, got: ${String(v)}`);
122
+ }
123
+ return n;
124
+ }
125
+
126
+ /**
127
+ * Strictly validate a parsed proof-artifact object. Throws an Error describing the FIRST problem.
128
+ * Never mutates and never fills defaults — an artifact either is complete and well-formed or it is
129
+ * rejected outright (mirroring cli/receipt.js's _validate). A malformed/short hash or a `proof` that
130
+ * is not an array of 32-byte hex strings hard-errors here, so verify-proof can never silently accept
131
+ * a structurally bogus file.
132
+ * @param {any} obj
133
+ * @returns {object} the same object, if valid
134
+ */
135
+ function _validate(obj) {
136
+ if (obj == null || typeof obj !== "object" || Array.isArray(obj)) {
137
+ throw new Error("proof artifact must be a JSON object");
138
+ }
139
+ if (obj.kind !== PROOF_KIND) {
140
+ throw new Error(
141
+ `not a verifyhash proof artifact (kind: ${JSON.stringify(obj.kind)}; expected ${JSON.stringify(
142
+ PROOF_KIND
143
+ )})`
144
+ );
145
+ }
146
+ if (!SUPPORTED_PROOF_SCHEMA_VERSIONS.includes(obj.schemaVersion)) {
147
+ throw new Error(
148
+ `unsupported proof artifact schemaVersion: ${JSON.stringify(obj.schemaVersion)} ` +
149
+ `(this build understands ${JSON.stringify(SUPPORTED_PROOF_SCHEMA_VERSIONS)})`
150
+ );
151
+ }
152
+
153
+ for (const f of ["root", "leaf", "contentHash"]) {
154
+ const v = obj[f];
155
+ if (v === undefined || v === null) throw new Error(`proof artifact missing required field: ${f}`);
156
+ if (typeof v !== "string" || !HEX32_RE.test(v)) {
157
+ throw new Error(
158
+ `proof artifact field ${f} must be a 0x-prefixed 32-byte hex string, got: ${String(v)}`
159
+ );
160
+ }
161
+ }
162
+
163
+ if (typeof obj.relPath !== "string" || obj.relPath.length === 0) {
164
+ throw new Error(`proof artifact relPath must be a non-empty string, got: ${String(obj.relPath)}`);
165
+ }
166
+
167
+ if (!Array.isArray(obj.proof)) {
168
+ throw new Error("proof artifact field proof must be an array of 0x 32-byte hex siblings");
169
+ }
170
+ obj.proof.forEach((sib, i) => {
171
+ if (typeof sib !== "string" || !HEX32_RE.test(sib)) {
172
+ throw new Error(
173
+ `proof artifact proof[${i}] must be a 0x-prefixed 32-byte hex string, got: ${String(sib)}`
174
+ );
175
+ }
176
+ });
177
+
178
+ // Optional on-chain context. Validate SHAPE only when present (an artifact built with --dry-run and
179
+ // no chain context legitimately omits both).
180
+ if (obj.contractAddress !== undefined && obj.contractAddress !== null) {
181
+ if (typeof obj.contractAddress !== "string" || !ADDR_RE.test(obj.contractAddress)) {
182
+ throw new Error(
183
+ `proof artifact contractAddress must be a 0x-prefixed 20-byte address when present, got: ${String(
184
+ obj.contractAddress
185
+ )}`
186
+ );
187
+ }
188
+ }
189
+ if (obj.chainId !== undefined && obj.chainId !== null) {
190
+ if (!Number.isSafeInteger(obj.chainId) || obj.chainId < 0) {
191
+ throw new Error(
192
+ `proof artifact chainId must be a non-negative integer when present, got: ${String(obj.chainId)}`
193
+ );
194
+ }
195
+ }
196
+
197
+ return obj;
198
+ }
199
+
200
+ /**
201
+ * Validate and write a proof artifact to `path` as pretty JSON. The only side effect is the file
202
+ * write, and it throws (before writing) if the object is not a valid artifact, so a corrupt artifact
203
+ * never lands on disk. Mirrors cli/receipt.js's writeReceipt.
204
+ * @param {object} obj a proof artifact (typically from buildProofArtifact)
205
+ * @param {string} path destination file path (caller-chosen — never silently the cwd)
206
+ * @returns {object} the validated object that was written
207
+ */
208
+ function writeProofArtifact(obj, path) {
209
+ if (!path || typeof path !== "string") {
210
+ throw new Error("writeProofArtifact requires a destination path");
211
+ }
212
+ const valid = _validate(obj);
213
+ fs.writeFileSync(path, JSON.stringify(valid, null, 2) + "\n");
214
+ return valid;
215
+ }
216
+
217
+ /**
218
+ * Read, JSON-parse, and strictly validate a proof artifact from `path`. Throws a clear error if the
219
+ * file is missing, not JSON, or fails validation — it NEVER returns a partial/corrupt artifact.
220
+ * Mirrors cli/receipt.js's readReceipt.
221
+ * @param {string} path
222
+ * @returns {object} the validated artifact
223
+ */
224
+ function readProofArtifact(path) {
225
+ if (!path || typeof path !== "string") {
226
+ throw new Error("readProofArtifact requires a path");
227
+ }
228
+ let raw;
229
+ try {
230
+ raw = fs.readFileSync(path, "utf8");
231
+ } catch (e) {
232
+ throw new Error(`cannot read proof artifact at ${path}: ${e.message}`);
233
+ }
234
+ let parsed;
235
+ try {
236
+ parsed = JSON.parse(raw);
237
+ } catch (e) {
238
+ throw new Error(`proof artifact at ${path} is not valid JSON: ${e.message}`);
239
+ }
240
+ try {
241
+ return _validate(parsed);
242
+ } catch (e) {
243
+ throw new Error(`proof artifact at ${path} is invalid: ${e.message}`);
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Re-fold a proof artifact PURELY OFFLINE — no network — to confirm it is internally consistent. This
249
+ * is the portability core: it re-derives the leaf from contentHash + relPath and replays the proof
250
+ * with the SAME convention the contract's verifyLeaf uses, reusing hash.js's pathLeaf / leafHash /
251
+ * nodeHash (NOT a re-implementation). Two independent things are checked:
252
+ *
253
+ * 1. leafMatches — the artifact's `leaf` actually equals pathLeaf(relPath, contentHash). A forged
254
+ * `leaf` that does not match its own claimed contentHash+relPath fails here, so verify-proof can
255
+ * never be fooled by swapping the leaf alone.
256
+ * 2. foldsToRoot — folding leafHash(leaf) up through `proof` with nodeHash reproduces `root` (the
257
+ * exact computation verifyLeaf does on-chain: it applies LEAF_TAG to the supplied value, then
258
+ * folds NODE_TAG sorted-pairs). The `computedRoot` is returned so the caller checks the SAME root
259
+ * on-chain that the offline fold produced — not merely the `root` field the file claims.
260
+ *
261
+ * @param {object} artifact a validated proof artifact (from readProofArtifact / buildProofArtifact)
262
+ * @returns {{
263
+ * derivedLeaf: string, // pathLeaf(relPath, contentHash) — what the leaf MUST be
264
+ * leafMatches: boolean, // artifact.leaf === derivedLeaf
265
+ * computedRoot: string, // fold of leafHash(leaf) through proof
266
+ * foldsToRoot: boolean, // computedRoot === artifact.root
267
+ * offlineOk: boolean, // leafMatches && foldsToRoot
268
+ * }}
269
+ */
270
+ function recomputeFold(artifact) {
271
+ // Re-derive the leaf from the path + content digest the artifact carries. This is exactly the
272
+ // path-bound leaf hashDir/buildProof produced, so a genuine artifact's stored `leaf` equals it.
273
+ const derivedLeaf = pathLeaf(artifact.relPath, artifact.contentHash);
274
+ const leafMatches = derivedLeaf.toLowerCase() === artifact.leaf.toLowerCase();
275
+
276
+ // Fold the (tagged) leaf up through the proof, byte-identically to the on-chain verifyLeaf:
277
+ // computed = leafHash(leaf); for each sibling s: computed = nodeHash(computed, s)
278
+ // We fold the artifact's stored leaf (the value the contract is handed); leafMatches above
279
+ // independently guarantees that stored leaf is the genuine pathLeaf for (relPath, contentHash).
280
+ let computed = leafHash(artifact.leaf);
281
+ for (const sibling of artifact.proof) {
282
+ computed = nodeHash(computed, sibling);
283
+ }
284
+ const computedRoot = computed;
285
+ const foldsToRoot = computedRoot.toLowerCase() === artifact.root.toLowerCase();
286
+
287
+ return {
288
+ derivedLeaf,
289
+ leafMatches,
290
+ computedRoot,
291
+ foldsToRoot,
292
+ offlineOk: leafMatches && foldsToRoot,
293
+ };
294
+ }
295
+
296
+ /**
297
+ * Render a verify-proof result as the human-readable block the CLI prints. Always LEADS with the
298
+ * trust-boundary one-liner (set-membership only, not authorship/uri), then the per-check breakdown,
299
+ * then the verdict and — on anything other than ACCEPTED — exactly which check failed.
300
+ */
301
+ function formatVerifyProof(r) {
302
+ const yn = (b) => (b ? "yes" : "NO");
303
+ const lines = [
304
+ TRUST_CAVEAT,
305
+ "",
306
+ ` proof artifact: ${r.artifactPath}`,
307
+ ` relPath: ${r.relPath}`,
308
+ ` contentHash: ${r.contentHash}`,
309
+ ` leaf: ${r.leaf}`,
310
+ ` root: ${r.root}`,
311
+ ` proof siblings: ${r.proofLength}`,
312
+ "",
313
+ " offline recompute (no network):",
314
+ ` leaf re-derived from contentHash+relPath: ${yn(r.leafMatches)}`,
315
+ ` proof folds to the claimed root: ${yn(r.foldsToRoot)}`,
316
+ ];
317
+ // T-11.2: the registry-authentication confirmation (or the loud skip warning), printed BEFORE the
318
+ // on-chain checks so a reader sees the contract+network were authenticated before believing them.
319
+ if (r.checkedChain || r.identitySkipped) {
320
+ if (r.identitySkipped) {
321
+ lines.push("", formatSkippedLine());
322
+ } else if (r.registry) {
323
+ lines.push("", formatRegistryLine(r.registry));
324
+ }
325
+ }
326
+ // On-chain checks are only meaningful once the offline fold holds; we still report what we did.
327
+ if (r.checkedChain) {
328
+ lines.push(
329
+ "",
330
+ " on-chain checks (one read-only call set):",
331
+ ` root is anchored (isAnchored): ${yn(r.rootAnchored)}`,
332
+ ` contract verifyLeaf accepts the proof: ${yn(r.onChainVerified)}`
333
+ );
334
+ } else if (r.offlineOk) {
335
+ lines.push("", " on-chain checks: SKIPPED (no provider) — offline fold only.");
336
+ }
337
+ lines.push("", ` result: ${r.status}`);
338
+
339
+ if (r.status === STATUS.ACCEPTED) {
340
+ lines.push(
341
+ " ACCEPTED: the file is a leaf of a Merkle root that is anchored on-chain (set-membership",
342
+ " proven offline AND confirmed on-chain). This binds the file's path + bytes to the anchored",
343
+ " root; it does NOT attest authorship or the meaning of `contributor`/`uri`."
344
+ );
345
+ } else if (r.status === STATUS.NOT_ANCHORED) {
346
+ lines.push(
347
+ " NOT ANCHORED: the proof folds to its root OFFLINE, but that root was never anchored on-chain.",
348
+ " There is nothing on-chain to prove the file against (it was never anchored, or you are pointed",
349
+ " at the wrong contract/chain). This is NOT an accept."
350
+ );
351
+ } else {
352
+ // REJECTED — name the first failed check so the reason is unambiguous.
353
+ if (!r.leafMatches) {
354
+ lines.push(
355
+ " REJECTED: the artifact's `leaf` does NOT equal pathLeaf(contentHash, relPath) — the leaf,",
356
+ " contentHash, or relPath was altered. A tampered leaf/contentHash is caught here offline."
357
+ );
358
+ } else if (!r.foldsToRoot) {
359
+ lines.push(
360
+ " REJECTED: the proof does NOT fold to the claimed root — a `proof` sibling (or the root) was",
361
+ " altered. The file is not a member of that root. Caught here offline, no network needed."
362
+ );
363
+ } else if (r.checkedChain && !r.onChainVerified) {
364
+ lines.push(
365
+ " REJECTED: the offline fold held, but the on-chain verifyLeaf rejected the proof against the",
366
+ " anchored root. (The on-chain root differs from the artifact's root, or the proof was altered.)"
367
+ );
368
+ } else {
369
+ lines.push(" REJECTED: a verification check failed.");
370
+ }
371
+ }
372
+ return lines.join("\n");
373
+ }
374
+
375
+ /**
376
+ * Shape a verify-proof result for `--json`. A machine consumer gets the same verdict + per-check
377
+ * booleans as the human block (so `--json` round-trips), plus the artifact's identifying hashes. The
378
+ * trust caveat is included verbatim so a JSON consumer can surface it too.
379
+ */
380
+ function jsonVerifyProof(r) {
381
+ return {
382
+ kind: PROOF_KIND,
383
+ artifactPath: r.artifactPath,
384
+ relPath: r.relPath,
385
+ contentHash: r.contentHash,
386
+ leaf: r.leaf,
387
+ root: r.root,
388
+ proofLength: r.proofLength,
389
+ offline: {
390
+ leafMatches: r.leafMatches,
391
+ foldsToRoot: r.foldsToRoot,
392
+ ok: r.offlineOk,
393
+ },
394
+ onChain: r.checkedChain
395
+ ? { checked: true, rootAnchored: r.rootAnchored, verifyLeaf: r.onChainVerified }
396
+ : { checked: false },
397
+ // T-11.2: the machine-readable registry block — proves the on-chain leg ran against an
398
+ // authenticated registry on the artifact's recorded chain (or that the check was skipped). null
399
+ // when no on-chain leg ran (offline-only / rejected before the chain check).
400
+ registry: r.identitySkipped
401
+ ? jsonSkippedBlock()
402
+ : r.registry
403
+ ? jsonRegistryBlock(r.registry)
404
+ : null,
405
+ accepted: r.status === STATUS.ACCEPTED,
406
+ status: r.status,
407
+ trustNote: TRUST_CAVEAT,
408
+ };
409
+ }
410
+
411
+ /**
412
+ * Verify a portable proof artifact. Read-only: needs ONLY the artifact + (for the on-chain leg) a
413
+ * provider. NEVER needs the original repo/working tree, and NEVER a signer or key — that is the
414
+ * portability property: hand someone the artifact and an RPC URL and they can independently confirm
415
+ * the file is in the anchored root with no trust in the prover.
416
+ *
417
+ * Flow:
418
+ * 1. Read + strictly validate the artifact (a malformed/short hash or non-hex proof hard-ERRORS).
419
+ * 2. Recompute the leaf from contentHash+relPath and re-fold the proof PURELY OFFLINE. If that fold
420
+ * fails (tampered leaf/contentHash/proof/root), the verdict is REJECTED immediately — no network.
421
+ * 3. If a provider is supplied, make the on-chain checks against the SAME root the offline fold
422
+ * produced: isAnchored(root) AND verifyLeaf(root, leaf, proof). A root that was never anchored is
423
+ * reported as NOT_ANCHORED (a distinct, non-accept outcome), distinguished from a genuine RPC
424
+ * error exactly as verify.js/show.js do (a real error is re-thrown, not masqueraded). ACCEPTED is
425
+ * printed ONLY when the offline fold AND both on-chain checks pass.
426
+ *
427
+ * @param {object} opts
428
+ * @param {string} opts.artifactPath path to the proof artifact JSON
429
+ * @param {string} [opts.contractAddress] override the artifact's contractAddress (else use it)
430
+ * @param {object} [opts.provider] ethers v6 Provider (read-only); omit for an offline-only run
431
+ * @param {boolean}[opts.json] emit a JSON object instead of the human block
432
+ * @param {object} [opts.ethers] ethers v6 module (defaults to the bundled one)
433
+ * @param {(s:string)=>void}[opts.log] sink for output (defaults to process.stdout)
434
+ * @returns {Promise<object>} the structured result
435
+ */
436
+ async function runVerifyProof(opts) {
437
+ const ethersLib = opts.ethers || require("ethers");
438
+ const log = opts.log || ((s) => process.stdout.write(s));
439
+
440
+ const artifact = readProofArtifact(opts.artifactPath);
441
+ const fold = recomputeFold(artifact);
442
+
443
+ const result = {
444
+ artifactPath: opts.artifactPath,
445
+ relPath: artifact.relPath,
446
+ contentHash: artifact.contentHash,
447
+ leaf: artifact.leaf,
448
+ root: artifact.root,
449
+ proofLength: artifact.proof.length,
450
+ derivedLeaf: fold.derivedLeaf,
451
+ leafMatches: fold.leafMatches,
452
+ computedRoot: fold.computedRoot,
453
+ foldsToRoot: fold.foldsToRoot,
454
+ offlineOk: fold.offlineOk,
455
+ checkedChain: false,
456
+ rootAnchored: null,
457
+ onChainVerified: null,
458
+ contractAddress: null,
459
+ // T-11.2: the resolved registry identity (or null when not yet checked / skipped / offline-only).
460
+ registry: null,
461
+ identitySkipped: Boolean(opts.skipIdentityCheck),
462
+ artifactChainId: artifact.chainId != null ? artifact.chainId : null,
463
+ status: STATUS.REJECTED,
464
+ };
465
+
466
+ // The offline fold is the gate: if the artifact is not internally consistent (tampered leaf/
467
+ // contentHash/proof/root), it is REJECTED before any network call. Membership in a root the proof
468
+ // does not even fold to is meaningless to check on-chain.
469
+ if (!fold.offlineOk) {
470
+ result.status = STATUS.REJECTED;
471
+ _emit(result, opts, log);
472
+ return result;
473
+ }
474
+
475
+ const provider = opts.provider;
476
+ if (!provider) {
477
+ // No provider: the offline fold is the only thing we can assert. The acceptance criteria require
478
+ // the on-chain leg for an ACCEPTED verdict, so without a provider we do NOT claim ACCEPTED — we
479
+ // surface the offline-only result (status stays REJECTED so a script never reads it as a full
480
+ // pass). Callers that want an offline-only confirmation read result.offlineOk.
481
+ result.status = STATUS.REJECTED;
482
+ result.note = "no provider: offline fold passed but the on-chain anchored check was not performed";
483
+ _emit(result, opts, log);
484
+ return result;
485
+ }
486
+
487
+ // Resolve the contract address: explicit override > the artifact's recorded address. The artifact's
488
+ // address is an untrusted hint, so an explicit --contract always wins.
489
+ const contractAddress = opts.contractAddress || artifact.contractAddress;
490
+ if (!contractAddress) {
491
+ throw new Error(
492
+ "no contract address: pass --contract <address> (or set VH_CONTRACT), " +
493
+ "or use an artifact that records its contractAddress"
494
+ );
495
+ }
496
+ if (!ethersLib.isAddress(contractAddress)) {
497
+ throw new Error(`invalid contract address: ${contractAddress}`);
498
+ }
499
+ result.contractAddress = ethersLib.getAddress(contractAddress);
500
+
501
+ // T-11.2: authenticate the registry BEFORE the on-chain checks — and cross-check the chainId. The
502
+ // artifact's recorded `chainId` (T-9.2) is passed as expectedChainId, so the offline fold + on-chain
503
+ // checks are believed ONLY once the provider is confirmed to be the right network AND the contract is
504
+ // the real registry. This is the portability promise made trustworthy: the consumer no longer has to
505
+ // trust the prover's RPC blindly. (A power user pointed at a known local/not-yet-deployed contract can
506
+ // opt out, loudly, via skipIdentityCheck.)
507
+ let registryAuth = null;
508
+ if (!opts.skipIdentityCheck) {
509
+ registryAuth = await assertRegistry({
510
+ provider,
511
+ contractAddress: result.contractAddress,
512
+ // The artifact's chainId is an UNTRUSTED hint we now ENFORCE: if it disagrees with the provider's
513
+ // chain, refuse to report a verdict against the wrong network.
514
+ expectedChainId: artifact.chainId,
515
+ ethers: ethersLib,
516
+ });
517
+ }
518
+ result.registry = registryAuth;
519
+ result.identitySkipped = Boolean(opts.skipIdentityCheck);
520
+
521
+ const contract = new ethersLib.Contract(result.contractAddress, ABI, provider);
522
+
523
+ // ONE read-only on-chain check set: is the root anchored, and does the contract's verifyLeaf accept
524
+ // the proof. We check anchoring against the root the OFFLINE FOLD produced (computedRoot), which
525
+ // equals the artifact root here (foldsToRoot held) — so we never trust the file's root unchecked.
526
+ result.checkedChain = true;
527
+ result.rootAnchored = await contract.isAnchored(fold.computedRoot);
528
+ if (!result.rootAnchored) {
529
+ // The proof is internally valid but its root was never anchored — distinct from a tamper. This is
530
+ // NOT a false ACCEPT; the CLI exits non-zero on it.
531
+ result.status = STATUS.NOT_ANCHORED;
532
+ _emit(result, opts, log);
533
+ return result;
534
+ }
535
+
536
+ // The contract's own verdict (defense in depth: even if our offline fold had a bug, the chain
537
+ // decides). verifyLeaf takes the path-bound leaf as its `contentHash` argument and tags it itself.
538
+ result.onChainVerified = await contract.verifyLeaf(
539
+ fold.computedRoot,
540
+ artifact.leaf,
541
+ artifact.proof
542
+ );
543
+
544
+ result.status =
545
+ result.offlineOk && result.rootAnchored && result.onChainVerified
546
+ ? STATUS.ACCEPTED
547
+ : STATUS.REJECTED;
548
+
549
+ _emit(result, opts, log);
550
+ return result;
551
+ }
552
+
553
+ /** Emit the result as JSON or the human block, per opts.json. */
554
+ function _emit(result, opts, log) {
555
+ if (opts.json) {
556
+ log(JSON.stringify(jsonVerifyProof(result), null, 2) + "\n");
557
+ } else {
558
+ log(formatVerifyProof(result) + "\n");
559
+ }
560
+ }
561
+
562
+ module.exports = {
563
+ PROOF_KIND,
564
+ PROOF_SCHEMA_VERSION,
565
+ SUPPORTED_PROOF_SCHEMA_VERSIONS,
566
+ STATUS,
567
+ TRUST_CAVEAT,
568
+ buildProofArtifact,
569
+ writeProofArtifact,
570
+ readProofArtifact,
571
+ recomputeFold,
572
+ runVerifyProof,
573
+ formatVerifyProof,
574
+ jsonVerifyProof,
575
+ ABI,
576
+ // Exported for unit tests that exercise validation directly.
577
+ _validate,
578
+ };