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/receipt.js ADDED
@@ -0,0 +1,631 @@
1
+ "use strict";
2
+
3
+ // cli/receipt.js — a versioned, strictly-validated JSON receipt for the verifyhash CLI.
4
+ //
5
+ // WHY THIS EXISTS
6
+ // Two flows need a durable, portable on-disk artifact:
7
+ //
8
+ // 1. CLAIM RECEIPTS (kind "verifyhash.claim-receipt").
9
+ // The commit-reveal attribution flow (`vh claim`) is two transactions separated by a maturation
10
+ // window of MIN_REVEAL_DELAY blocks. On a live testnet that window is minutes. If a single-process
11
+ // `runClaim` (commit + reveal in one go) crashes/Ctrl-C's between the two legs, the secret salt —
12
+ // held only in memory — is lost forever. The contentHash is then committed-but-unrevealable by
13
+ // ANYONE (reveal needs that exact salt), so the attribution is permanently burned. The claim
14
+ // receipt makes the claim DURABLE and RESUMABLE: at commit time the orchestrator persists the
15
+ // salt/commitment (and everything `reveal()` needs) to a JSON receipt on disk; a later, separate
16
+ // `vh reveal --receipt <path>` process loads it and finishes the claim.
17
+ //
18
+ // 2. ANCHOR RECEIPTS (kind "verifyhash.anchor-receipt").
19
+ // A one-shot `vh anchor` records only a contentHash on-chain. For a DIRECTORY that hash is a
20
+ // Merkle root over per-file path-bound leaves, so `vh verify <dir>` can only ever say "the whole
21
+ // tree's root matches / does not match" — it cannot say WHICH file diverged. `vh hash <dir>`
22
+ // already computes every per-file `{ path, contentHash, leaf }` and then throws them away. An
23
+ // anchor receipt records that sorted MANIFEST so a later `vh verify <dir> --receipt <p>` can
24
+ // localize the change to specific files (ADDED / REMOVED / CHANGED).
25
+ //
26
+ // Both kinds, at schemaVersion >= 2, may additionally carry a `manifest` for a directory target.
27
+ // The claim/commit receipts for a directory record it too (so a resumed reveal — and any later
28
+ // verify — has the per-file breakdown).
29
+ //
30
+ // TRUST POSTURE
31
+ // The receipt is an UNTRUSTED local convenience, consistent with docs/TRUST-BOUNDARIES.md: the
32
+ // authoritative result still comes from the on-chain record (and, for verify, from re-deriving the
33
+ // root and comparing it to that record). A receipt's `manifest` only LOCALIZES which file diverged;
34
+ // it can never, by itself, make content "verified". But a receipt that is *corrupt* must never be
35
+ // silently half-accepted — a partial claim receipt could make a user re-derive a wrong commitment
36
+ // or reveal with the wrong salt and waste a transaction (or worse, leak the salt while producing
37
+ // nothing). So `readReceipt` validates strictly and throws on ANY deviation rather than filling
38
+ // defaults.
39
+
40
+ const fs = require("fs");
41
+
42
+ // Current on-disk schema version written by this build. History (all ADDITIVE):
43
+ // 1 -> 2 : added the optional `manifest` (per-file dir leaves, for localized verify diffs).
44
+ // 2 -> 3 : added the optional `git` block { commit, scope } — the resolved commit oid and the
45
+ // repo-relative scope used to enumerate the tracked files for a `--git` anchor/claim.
46
+ // 3 -> 4 : added the optional `parent` field on a CLAIM receipt ONLY (B-10.1) — a 0x 32-byte hex
47
+ // contentHash of an already-anchored predecessor (the lineage edge a `vh commit --parent`
48
+ // will record at REVEAL time via revealWithParent), or absent for a lineage root. It is an
49
+ // UNTRUSTED convenience hint (docs/TRUST-BOUNDARIES.md): the AUTHORITATIVE edge is what
50
+ // revealWithParent records on-chain, not this field. The ANCHOR receipt is unchanged.
51
+ // `readReceipt` still ACCEPTS every prior version (a v1 receipt has no manifest, a v1/v2 receipt has
52
+ // no git block, a v1/v2/v3 receipt has no parent), so older artifacts keep working; it only WRITES
53
+ // version SCHEMA_VERSION. Any version outside the supported set is rejected so a future/foreign file
54
+ // is never misread as a current one.
55
+ const SCHEMA_VERSION = 4;
56
+ const SUPPORTED_SCHEMA_VERSIONS = Object.freeze([1, 2, 3, 4]);
57
+
58
+ // Receipts carry one of these discriminators so a random JSON file (or a different vh artifact) is
59
+ // never mistaken for a verifyhash receipt. A CLAIM receipt is the resumable commit-reveal artifact
60
+ // (carries salt/commitment); an ANCHOR receipt is the one-shot anchor artifact (no secret material).
61
+ const RECEIPT_KIND = "verifyhash.claim-receipt"; // back-compat alias: the claim-receipt kind
62
+ const CLAIM_RECEIPT_KIND = "verifyhash.claim-receipt";
63
+ const ANCHOR_RECEIPT_KIND = "verifyhash.anchor-receipt";
64
+ const RECEIPT_KINDS = Object.freeze([CLAIM_RECEIPT_KIND, ANCHOR_RECEIPT_KIND]);
65
+
66
+ // Fields that must be present and be 0x-prefixed 32-byte (64 hex char) values, by kind.
67
+ // Claim receipts bind a salt + commitment; anchor receipts only ever attest a contentHash.
68
+ const HEX32_FIELDS_CLAIM = ["contentHash", "salt", "commitment"];
69
+ const HEX32_FIELDS_ANCHOR = ["contentHash"];
70
+ // Address fields that must be present, by kind. Anchor receipts have no committer (no signer needed
71
+ // to anchor a hash they already know — and verify needs no signer at all).
72
+ const ADDR_FIELDS_CLAIM = ["committer", "contractAddress"];
73
+ const ADDR_FIELDS_ANCHOR = ["contractAddress"];
74
+
75
+ const HEX32_RE = /^0x[0-9a-fA-F]{64}$/;
76
+ const ADDR_RE = /^0x[0-9a-fA-F]{40}$/;
77
+ // A full git commit object id: 40 lowercase hex chars (what resolveCommit always yields). The `git`
78
+ // block records this BARE (no 0x prefix) to match git's own representation, so a reader can paste it
79
+ // straight into `git show <oid>`.
80
+ const GIT_OID_RE = /^[0-9a-f]{40}$/;
81
+
82
+ /**
83
+ * Build a normalized, fully-populated CLAIM-receipt object from raw parts. Throws if any required
84
+ * field is missing or malformed, so we never *write* a partial receipt either.
85
+ *
86
+ * @param {object} parts
87
+ * @param {string} parts.contentHash 0x 32-byte digest being claimed
88
+ * @param {string} parts.committer 0x 20-byte address that committed and will reveal
89
+ * @param {string} parts.salt 0x 32-byte secret salt — `secret: true`: keep this PRIVATE
90
+ * until reveal. Whoever holds it before reveal can front-run
91
+ * the open; it is the one operationally-load-bearing field.
92
+ * @param {string} parts.commitment 0x 32-byte commitment hash
93
+ * @param {string} parts.contractAddress 0x 20-byte ContributionRegistry address
94
+ * @param {number|string|bigint} parts.chainId chain the commit was sent to
95
+ * @param {string} [parts.uri] optional untrusted off-chain pointer hint
96
+ * @param {string} [parts.kind] "file" | "dir" (informational target kind)
97
+ * @param {string} [parts.path] the source path claimed (informational)
98
+ * @param {string} [parts.commitTxHash] the commit() transaction hash (0x 32-byte)
99
+ * @param {number|string|bigint} [parts.commitBlockNumber] block.number the commit mined in
100
+ * @param {number|string|bigint} [parts.minRevealDelay] MIN_REVEAL_DELAY read from the contract
101
+ * @param {Array<{path:string,contentHash:string,leaf:string}>} [parts.manifest]
102
+ * sorted per-file manifest for a directory target (exactly what `vh hash <dir>` produces)
103
+ * @param {{commit:string,scope:string}} [parts.git]
104
+ * optional UNTRUSTED git-provenance hint: the resolved commit oid + repo-relative scope used
105
+ * to enumerate the tracked files for a `--git` claim
106
+ * @param {string} [parts.parent]
107
+ * optional 0x 32-byte contentHash of an ALREADY-ANCHORED predecessor (B-10.1 lineage edge).
108
+ * Recorded ONLY when present; absent means this claim is a lineage root. This is a CLAIM of a
109
+ * predecessor, NOT proof of ancestry or any transfer of the parent's authorship — it is an
110
+ * UNTRUSTED convenience hint. The AUTHORITATIVE edge is what `revealWithParent(contentHash,
111
+ * salt, uri, parent)` records on-chain at reveal time; this field merely lets a resumed
112
+ * `vh reveal` know which reveal leg to route to. Validated as a well-formed non-zero 32-byte
113
+ * hex hash that is NOT equal to `contentHash` (the contract rejects a self-parent as SelfParent).
114
+ * @returns {object} a validated receipt object
115
+ */
116
+ function buildReceipt(parts) {
117
+ if (!parts || typeof parts !== "object") {
118
+ throw new Error("buildReceipt requires an object of parts");
119
+ }
120
+ const receipt = {
121
+ kind: CLAIM_RECEIPT_KIND,
122
+ schemaVersion: SCHEMA_VERSION,
123
+ contentHash: parts.contentHash,
124
+ committer: parts.committer,
125
+ salt: parts.salt,
126
+ commitment: parts.commitment,
127
+ contractAddress: parts.contractAddress,
128
+ chainId: _normChainId(parts.chainId),
129
+ uri: parts.uri == null ? "" : String(parts.uri),
130
+ };
131
+ _attachOptional(receipt, parts);
132
+ _validate(receipt);
133
+ return receipt;
134
+ }
135
+
136
+ /**
137
+ * Build a normalized ANCHOR-receipt object: the durable companion to a one-shot `vh anchor`. It has
138
+ * no salt/commitment/committer (anchoring needs none); its reason to exist is the optional directory
139
+ * `manifest` that lets a later `vh verify --receipt` localize which file diverged.
140
+ *
141
+ * @param {object} parts
142
+ * @param {string} parts.contentHash 0x 32-byte digest anchored (file digest or dir Merkle root)
143
+ * @param {string} parts.contractAddress 0x 20-byte ContributionRegistry address
144
+ * @param {number|string|bigint} parts.chainId chain the anchor was/would be sent to
145
+ * @param {string} [parts.uri] optional untrusted off-chain pointer hint
146
+ * @param {string} [parts.kind] "file" | "dir" (informational target kind)
147
+ * @param {string} [parts.path] the source path anchored (informational)
148
+ * @param {string} [parts.anchorTxHash] the anchor() transaction hash (0x 32-byte), when sent
149
+ * @param {number|string|bigint} [parts.anchorBlockNumber] block.number the anchor mined in
150
+ * @param {Array<{path:string,contentHash:string,leaf:string}>} [parts.manifest]
151
+ * sorted per-file manifest for a directory target (exactly what `vh hash <dir>` produces)
152
+ * @param {{commit:string,scope:string}} [parts.git]
153
+ * optional UNTRUSTED git-provenance hint: the resolved commit oid + repo-relative scope used
154
+ * to enumerate the tracked files for a `--git` anchor
155
+ * @returns {object} a validated anchor-receipt object
156
+ */
157
+ function buildAnchorReceipt(parts) {
158
+ if (!parts || typeof parts !== "object") {
159
+ throw new Error("buildAnchorReceipt requires an object of parts");
160
+ }
161
+ const receipt = {
162
+ kind: ANCHOR_RECEIPT_KIND,
163
+ schemaVersion: SCHEMA_VERSION,
164
+ contentHash: parts.contentHash,
165
+ contractAddress: parts.contractAddress,
166
+ chainId: _normChainId(parts.chainId),
167
+ uri: parts.uri == null ? "" : String(parts.uri),
168
+ };
169
+ if (parts.path != null) receipt.path = String(parts.path);
170
+ if (parts.kind != null) receipt.targetKind = String(parts.kind);
171
+ if (parts.anchorTxHash != null) receipt.anchorTxHash = parts.anchorTxHash;
172
+ if (parts.anchorBlockNumber != null) {
173
+ receipt.anchorBlockNumber = _normIntField("anchorBlockNumber", parts.anchorBlockNumber);
174
+ }
175
+ if (parts.manifest != null) receipt.manifest = _normManifest(parts.manifest);
176
+ if (parts.git != null) receipt.git = _normGit(parts.git);
177
+ _validate(receipt);
178
+ return receipt;
179
+ }
180
+
181
+ /** Attach the optional/operational claim-receipt fields (validated for shape) when provided. */
182
+ function _attachOptional(receipt, parts) {
183
+ if (parts.path != null) receipt.path = String(parts.path);
184
+ if (parts.kind != null) receipt.targetKind = String(parts.kind);
185
+ if (parts.commitTxHash != null) receipt.commitTxHash = parts.commitTxHash;
186
+ if (parts.commitBlockNumber != null) {
187
+ receipt.commitBlockNumber = _normIntField("commitBlockNumber", parts.commitBlockNumber);
188
+ }
189
+ if (parts.minRevealDelay != null) {
190
+ receipt.minRevealDelay = _normIntField("minRevealDelay", parts.minRevealDelay);
191
+ }
192
+ if (parts.manifest != null) receipt.manifest = _normManifest(parts.manifest);
193
+ if (parts.git != null) receipt.git = _normGit(parts.git);
194
+ // The lineage edge (B-10.1) is recorded ONLY when present — absent means a lineage root. Mirrors the
195
+ // additive-optional `_normGit` pattern: validated here so a malformed/zero/self-referential parent is
196
+ // rejected at build time and never lands on disk. `receipt.contentHash` is already set above, so the
197
+ // self-reference check can run against it.
198
+ if (parts.parent != null) receipt.parent = _normParent(parts.parent, receipt.contentHash);
199
+ }
200
+
201
+ /**
202
+ * Normalize the optional lineage `parent` (B-10.1) into the canonical on-disk shape: a lowercased 0x
203
+ * 32-byte hex hash of an ALREADY-ANCHORED predecessor. Throws on a malformed value, on the all-zero
204
+ * hash (the zero hash is the contract's "no predecessor" sentinel — a receipt that means "root" must
205
+ * OMIT `parent` entirely, never carry the zero hash as a lie), and on a self-reference
206
+ * (`parent === contentHash`; the contract rejects this as SelfParent). The edge is an UNTRUSTED claim
207
+ * of a predecessor (docs/TRUST-BOUNDARIES.md): the authoritative edge is what `revealWithParent`
208
+ * records on-chain — this normalizer only keeps the receipt internally consistent and well-formed.
209
+ * @param {any} parent the candidate predecessor contentHash
210
+ * @param {string} contentHash the receipt's own contentHash (for the self-reference guard)
211
+ * @returns {string} the normalized, lowercased parent hash
212
+ */
213
+ function _normParent(parent, contentHash) {
214
+ if (typeof parent !== "string" || !HEX32_RE.test(parent)) {
215
+ throw new Error(`receipt parent must be a 0x 32-byte hex contentHash, got: ${String(parent)}`);
216
+ }
217
+ if (/^0x0{64}$/i.test(parent)) {
218
+ throw new Error(
219
+ "receipt parent must not be the all-zero hash (a lineage root omits parent entirely)"
220
+ );
221
+ }
222
+ if (typeof contentHash === "string" && parent.toLowerCase() === contentHash.toLowerCase()) {
223
+ throw new Error(
224
+ "receipt parent must not equal contentHash (self-reference; the contract rejects it as SelfParent)"
225
+ );
226
+ }
227
+ return parent.toLowerCase();
228
+ }
229
+
230
+ /**
231
+ * Normalize an optional git-provenance block into the canonical on-disk shape: { commit, scope }.
232
+ * `commit` is a full 40-hex lowercase oid (lowercased here); `scope` is a non-empty repo-relative
233
+ * POSIX path (or "." for the repo root). This is an UNTRUSTED convenience hint (docs/TRUST-BOUNDARIES):
234
+ * it records HOW the tracked set was enumerated, never the authoritative verdict. Throws on a
235
+ * malformed block — a git block either is well-formed or is rejected (the schema must not lie).
236
+ * @param {any} git
237
+ * @returns {{ commit: string, scope: string }}
238
+ */
239
+ function _normGit(git) {
240
+ if (!git || typeof git !== "object" || Array.isArray(git)) {
241
+ throw new Error("receipt git block must be an object { commit, scope }");
242
+ }
243
+ const commit = typeof git.commit === "string" ? git.commit.toLowerCase() : git.commit;
244
+ if (typeof commit !== "string" || !GIT_OID_RE.test(commit)) {
245
+ throw new Error(`receipt git.commit must be a 40-hex commit oid, got: ${String(git.commit)}`);
246
+ }
247
+ if (typeof git.scope !== "string" || git.scope.length === 0) {
248
+ throw new Error(`receipt git.scope must be a non-empty string, got: ${String(git.scope)}`);
249
+ }
250
+ return { commit, scope: git.scope };
251
+ }
252
+
253
+ /** Normalize a chainId (number|string|bigint) to a non-negative integer Number. */
254
+ function _normChainId(v) {
255
+ if (v == null) return v; // let _validate produce the missing-field error
256
+ let n;
257
+ try {
258
+ n = Number(BigInt(v));
259
+ } catch (_) {
260
+ throw new Error(`receipt chainId must be an integer, got: ${String(v)}`);
261
+ }
262
+ if (!Number.isSafeInteger(n) || n < 0) {
263
+ throw new Error(`receipt chainId must be a non-negative integer, got: ${String(v)}`);
264
+ }
265
+ return n;
266
+ }
267
+
268
+ /** Normalize an optional integer field (block numbers, delays) to a Number. */
269
+ function _normIntField(name, v) {
270
+ let n;
271
+ try {
272
+ n = Number(BigInt(v));
273
+ } catch (_) {
274
+ throw new Error(`receipt ${name} must be an integer, got: ${String(v)}`);
275
+ }
276
+ if (!Number.isSafeInteger(n) || n < 0) {
277
+ throw new Error(`receipt ${name} must be a non-negative integer, got: ${String(v)}`);
278
+ }
279
+ return n;
280
+ }
281
+
282
+ /**
283
+ * Normalize a per-file manifest into the canonical on-disk shape: an array of
284
+ * { path, contentHash, leaf } entries, sorted ascending by `leaf` (the same total order `hashDir`
285
+ * uses to build the tree, so a written manifest is deterministic regardless of input order).
286
+ * Throws on any malformed entry — a manifest either is well-formed or is rejected.
287
+ * @param {any} manifest
288
+ * @returns {Array<{path:string,contentHash:string,leaf:string}>}
289
+ */
290
+ function _normManifest(manifest) {
291
+ if (!Array.isArray(manifest)) {
292
+ throw new Error("receipt manifest must be an array");
293
+ }
294
+ const out = manifest.map((entry, i) => {
295
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
296
+ throw new Error(`receipt manifest entry ${i} must be an object`);
297
+ }
298
+ if (typeof entry.path !== "string" || entry.path.length === 0) {
299
+ throw new Error(`receipt manifest entry ${i} must have a non-empty string path`);
300
+ }
301
+ if (typeof entry.contentHash !== "string" || !HEX32_RE.test(entry.contentHash)) {
302
+ throw new Error(
303
+ `receipt manifest entry ${i} (${entry.path}) contentHash must be a 0x 32-byte hex string`
304
+ );
305
+ }
306
+ if (typeof entry.leaf !== "string" || !HEX32_RE.test(entry.leaf)) {
307
+ throw new Error(
308
+ `receipt manifest entry ${i} (${entry.path}) leaf must be a 0x 32-byte hex string`
309
+ );
310
+ }
311
+ return { path: entry.path, contentHash: entry.contentHash, leaf: entry.leaf };
312
+ });
313
+ // Deterministic order: sort by leaf value (matches hashDir's leaf-sorted tree).
314
+ out.sort((a, b) => {
315
+ const x = BigInt(a.leaf);
316
+ const y = BigInt(b.leaf);
317
+ return x < y ? -1 : x > y ? 1 : 0;
318
+ });
319
+ return out;
320
+ }
321
+
322
+ /**
323
+ * Strictly validate a parsed receipt object. Throws an Error describing the FIRST problem found.
324
+ * Never mutates the object and never fills defaults — a receipt either is complete and well-formed
325
+ * or it is rejected outright. Accepts every SUPPORTED_SCHEMA_VERSIONS (a v1 receipt has no manifest,
326
+ * a v1/v2 receipt has no git block, a v1/v2/v3 receipt has no parent), and both claim- and anchor-kind
327
+ * receipts. Each optional field is gated to the version that introduced it, so a version never lies.
328
+ * @param {any} obj
329
+ * @returns {object} the same object, if valid
330
+ */
331
+ function _validate(obj) {
332
+ if (obj == null || typeof obj !== "object" || Array.isArray(obj)) {
333
+ throw new Error("receipt must be a JSON object");
334
+ }
335
+ if (!RECEIPT_KINDS.includes(obj.kind)) {
336
+ throw new Error(
337
+ `not a verifyhash receipt (kind: ${JSON.stringify(obj.kind)}; expected one of ${JSON.stringify(
338
+ RECEIPT_KINDS
339
+ )})`
340
+ );
341
+ }
342
+ if (!SUPPORTED_SCHEMA_VERSIONS.includes(obj.schemaVersion)) {
343
+ throw new Error(
344
+ `unsupported receipt schemaVersion: ${JSON.stringify(obj.schemaVersion)} ` +
345
+ `(this build understands ${JSON.stringify(SUPPORTED_SCHEMA_VERSIONS)})`
346
+ );
347
+ }
348
+
349
+ const isAnchor = obj.kind === ANCHOR_RECEIPT_KIND;
350
+ const hex32Fields = isAnchor ? HEX32_FIELDS_ANCHOR : HEX32_FIELDS_CLAIM;
351
+ const addrFields = isAnchor ? ADDR_FIELDS_ANCHOR : ADDR_FIELDS_CLAIM;
352
+
353
+ for (const f of hex32Fields) {
354
+ const v = obj[f];
355
+ if (v === undefined || v === null) throw new Error(`receipt missing required field: ${f}`);
356
+ if (typeof v !== "string" || !HEX32_RE.test(v)) {
357
+ throw new Error(`receipt field ${f} must be a 0x-prefixed 32-byte hex string, got: ${String(v)}`);
358
+ }
359
+ }
360
+ for (const f of addrFields) {
361
+ const v = obj[f];
362
+ if (v === undefined || v === null) throw new Error(`receipt missing required field: ${f}`);
363
+ if (typeof v !== "string" || !ADDR_RE.test(v)) {
364
+ throw new Error(`receipt field ${f} must be a 0x-prefixed 20-byte address, got: ${String(v)}`);
365
+ }
366
+ }
367
+
368
+ if (obj.chainId === undefined || obj.chainId === null) {
369
+ throw new Error("receipt missing required field: chainId");
370
+ }
371
+ if (!Number.isSafeInteger(obj.chainId) || obj.chainId < 0) {
372
+ throw new Error(`receipt field chainId must be a non-negative integer, got: ${String(obj.chainId)}`);
373
+ }
374
+
375
+ if (obj.uri !== undefined && obj.uri !== null && typeof obj.uri !== "string") {
376
+ throw new Error(`receipt field uri must be a string when present, got: ${typeof obj.uri}`);
377
+ }
378
+
379
+ // Optional operational fields (claim + anchor): validate shape only when present.
380
+ for (const f of ["commitTxHash", "anchorTxHash"]) {
381
+ if (obj[f] !== undefined && obj[f] !== null) {
382
+ if (typeof obj[f] !== "string" || !HEX32_RE.test(obj[f])) {
383
+ throw new Error(
384
+ `receipt field ${f} must be a 0x-prefixed 32-byte hex string when present, got: ${String(obj[f])}`
385
+ );
386
+ }
387
+ }
388
+ }
389
+ for (const f of ["commitBlockNumber", "minRevealDelay", "anchorBlockNumber"]) {
390
+ if (obj[f] !== undefined && obj[f] !== null) {
391
+ if (!Number.isSafeInteger(obj[f]) || obj[f] < 0) {
392
+ throw new Error(`receipt field ${f} must be a non-negative integer when present, got: ${String(obj[f])}`);
393
+ }
394
+ }
395
+ }
396
+
397
+ // Optional manifest: only meaningful at schemaVersion >= 2. A v1 receipt that somehow carries a
398
+ // manifest is rejected (the version contract is that v1 has none), so the version is never a lie.
399
+ if (obj.manifest !== undefined && obj.manifest !== null) {
400
+ if (obj.schemaVersion < 2) {
401
+ throw new Error("receipt manifest requires schemaVersion >= 2");
402
+ }
403
+ _validateManifestShape(obj.manifest);
404
+ }
405
+
406
+ // Optional git provenance block: only meaningful at schemaVersion >= 3. A v1/v2 receipt that
407
+ // smuggles in a git block is rejected (those versions are defined to have none), so the version is
408
+ // never a lie. The block is an UNTRUSTED hint — validating its SHAPE here only keeps a written
409
+ // receipt internally consistent; it never elevates the block to an authoritative claim.
410
+ if (obj.git !== undefined && obj.git !== null) {
411
+ if (obj.schemaVersion < 3) {
412
+ throw new Error("receipt git block requires schemaVersion >= 3");
413
+ }
414
+ _validateGitShape(obj.git);
415
+ }
416
+
417
+ // Optional lineage `parent` (B-10.1): only meaningful at schemaVersion >= 4, and ONLY on a CLAIM
418
+ // receipt (the anchor receipt records its lineage edge on-chain at anchor time and carries none on
419
+ // disk). A v1/v2/v3 receipt that smuggles in a parent is rejected (those versions are defined to
420
+ // have none), so the version is never a lie. The edge is an UNTRUSTED claim of a predecessor — this
421
+ // SHAPE check only keeps the receipt internally consistent; the authoritative edge is on-chain.
422
+ if (obj.parent !== undefined && obj.parent !== null) {
423
+ if (isAnchor) {
424
+ throw new Error("an anchor receipt must not carry a parent field (the edge is on-chain only)");
425
+ }
426
+ if (obj.schemaVersion < 4) {
427
+ throw new Error("receipt parent requires schemaVersion >= 4");
428
+ }
429
+ if (typeof obj.parent !== "string" || !HEX32_RE.test(obj.parent)) {
430
+ throw new Error(
431
+ `receipt parent must be a 0x 32-byte hex contentHash when present, got: ${String(obj.parent)}`
432
+ );
433
+ }
434
+ if (/^0x0{64}$/i.test(obj.parent)) {
435
+ throw new Error(
436
+ "receipt parent must not be the all-zero hash (a lineage root omits parent entirely)"
437
+ );
438
+ }
439
+ if (
440
+ typeof obj.contentHash === "string" &&
441
+ obj.parent.toLowerCase() === obj.contentHash.toLowerCase()
442
+ ) {
443
+ throw new Error(
444
+ "receipt parent must not equal contentHash (self-reference; the contract rejects it as SelfParent)"
445
+ );
446
+ }
447
+ }
448
+
449
+ return obj;
450
+ }
451
+
452
+ /** Validate a parsed git-provenance block's shape (without mutating). Throws on the first problem. */
453
+ function _validateGitShape(git) {
454
+ if (!git || typeof git !== "object" || Array.isArray(git)) {
455
+ throw new Error("receipt git block must be an object { commit, scope }");
456
+ }
457
+ if (typeof git.commit !== "string" || !GIT_OID_RE.test(git.commit)) {
458
+ throw new Error(`receipt git.commit must be a 40-hex commit oid, got: ${String(git.commit)}`);
459
+ }
460
+ if (typeof git.scope !== "string" || git.scope.length === 0) {
461
+ throw new Error(`receipt git.scope must be a non-empty string, got: ${String(git.scope)}`);
462
+ }
463
+ }
464
+
465
+ /** Validate a parsed manifest's shape (without re-sorting). Throws on the first malformed entry. */
466
+ function _validateManifestShape(manifest) {
467
+ if (!Array.isArray(manifest)) {
468
+ throw new Error("receipt manifest must be an array");
469
+ }
470
+ manifest.forEach((entry, i) => {
471
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
472
+ throw new Error(`receipt manifest entry ${i} must be an object`);
473
+ }
474
+ if (typeof entry.path !== "string" || entry.path.length === 0) {
475
+ throw new Error(`receipt manifest entry ${i} must have a non-empty string path`);
476
+ }
477
+ if (typeof entry.contentHash !== "string" || !HEX32_RE.test(entry.contentHash)) {
478
+ throw new Error(`receipt manifest entry ${i} (${entry.path}) contentHash must be a 0x 32-byte hex string`);
479
+ }
480
+ if (typeof entry.leaf !== "string" || !HEX32_RE.test(entry.leaf)) {
481
+ throw new Error(`receipt manifest entry ${i} (${entry.path}) leaf must be a 0x 32-byte hex string`);
482
+ }
483
+ });
484
+ }
485
+
486
+ /**
487
+ * Validate and write a receipt object to `path` as pretty JSON. Pure-ish: the only side effect is
488
+ * the file write. Throws (before writing) if the object is not a valid receipt, so a corrupt object
489
+ * never lands on disk.
490
+ * @param {object} obj a receipt (typically from buildReceipt / buildAnchorReceipt)
491
+ * @param {string} path destination file path
492
+ * @returns {object} the validated object that was written
493
+ */
494
+ function writeReceipt(obj, path) {
495
+ if (!path || typeof path !== "string") {
496
+ throw new Error("writeReceipt requires a destination path");
497
+ }
498
+ const valid = _validate(obj);
499
+ // Trailing newline so the file is POSIX-clean and diff-friendly.
500
+ fs.writeFileSync(path, JSON.stringify(valid, null, 2) + "\n");
501
+ return valid;
502
+ }
503
+
504
+ /**
505
+ * Read, JSON-parse, and strictly validate a receipt from `path`. Throws a clear error if the file is
506
+ * missing, not JSON, or fails validation — it NEVER returns a partial/corrupt receipt. Accepts both
507
+ * claim and anchor receipts at any SUPPORTED_SCHEMA_VERSIONS (older artifacts keep working).
508
+ * @param {string} path
509
+ * @returns {object} the validated receipt
510
+ */
511
+ function readReceipt(path) {
512
+ if (!path || typeof path !== "string") {
513
+ throw new Error("readReceipt requires a path");
514
+ }
515
+ let raw;
516
+ try {
517
+ raw = fs.readFileSync(path, "utf8");
518
+ } catch (e) {
519
+ throw new Error(`cannot read receipt at ${path}: ${e.message}`);
520
+ }
521
+ let parsed;
522
+ try {
523
+ parsed = JSON.parse(raw);
524
+ } catch (e) {
525
+ throw new Error(`receipt at ${path} is not valid JSON: ${e.message}`);
526
+ }
527
+ try {
528
+ return _validate(parsed);
529
+ } catch (e) {
530
+ throw new Error(`receipt at ${path} is invalid: ${e.message}`);
531
+ }
532
+ }
533
+
534
+ /**
535
+ * Compute a precise file-level diff between a receipt's recorded manifest and a freshly-recomputed
536
+ * set of per-file leaves for the SAME directory. This LOCALIZES which file diverged — it does NOT,
537
+ * on its own, decide MATCH/MISMATCH (the authoritative verdict is re-deriving the root and comparing
538
+ * it to the on-chain record; see runVerify). The receipt manifest is an UNTRUSTED hint.
539
+ *
540
+ * @param {Array<{path:string,contentHash:string,leaf:string}>} recordedManifest the receipt's manifest
541
+ * @param {Array<{path:string,contentHash:string,leaf:string}>} currentLeaves `hashDir().leaves` now
542
+ * @returns {{
543
+ * added: Array<{path:string,contentHash:string}>, // present now, not in receipt
544
+ * removed: Array<{path:string,contentHash:string}>, // in receipt, gone now
545
+ * changed: Array<{path:string,oldContentHash:string,newContentHash:string}>, // same path, different content
546
+ * unchanged: Array<{path:string,contentHash:string}>,
547
+ * identical: boolean // no add/remove/change at all
548
+ * }}
549
+ */
550
+ function diffManifest(recordedManifest, currentLeaves) {
551
+ const recorded = new Map();
552
+ for (const e of recordedManifest || []) recorded.set(e.path, e);
553
+ const current = new Map();
554
+ for (const e of currentLeaves || []) current.set(e.path, e);
555
+
556
+ const added = [];
557
+ const removed = [];
558
+ const changed = [];
559
+ const unchanged = [];
560
+
561
+ for (const [p, cur] of current) {
562
+ const rec = recorded.get(p);
563
+ if (!rec) {
564
+ added.push({ path: p, contentHash: cur.contentHash });
565
+ } else if (rec.leaf.toLowerCase() !== cur.leaf.toLowerCase()) {
566
+ // Path bound into the leaf is identical (same key), so a leaf difference is a content change.
567
+ changed.push({ path: p, oldContentHash: rec.contentHash, newContentHash: cur.contentHash });
568
+ } else {
569
+ unchanged.push({ path: p, contentHash: cur.contentHash });
570
+ }
571
+ }
572
+ for (const [p, rec] of recorded) {
573
+ if (!current.has(p)) removed.push({ path: p, contentHash: rec.contentHash });
574
+ }
575
+
576
+ const sortByPath = (a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
577
+ added.sort(sortByPath);
578
+ removed.sort(sortByPath);
579
+ changed.sort(sortByPath);
580
+ unchanged.sort(sortByPath);
581
+
582
+ return {
583
+ added,
584
+ removed,
585
+ changed,
586
+ unchanged,
587
+ identical: added.length === 0 && removed.length === 0 && changed.length === 0,
588
+ };
589
+ }
590
+
591
+ /**
592
+ * Default receipt FILE NAME for a contentHash: `<first 16 hex chars>.vhclaim.json`. Short enough to
593
+ * be tidy, long enough to be collision-resistant for a human's working set.
594
+ *
595
+ * PURE HELPER — no side effects, no `process.cwd()` lookup. The returned path is RELATIVE
596
+ * (`./<prefix>.vhclaim.json`) and the CALLER is responsible for choosing a SAFE BASE to resolve it
597
+ * against before writing. This helper deliberately does NOT decide where the file lands: a claim
598
+ * receipt holds the SECRET `salt`, so silently dropping it into whatever the current working
599
+ * directory happens to be is a footgun. Callers must resolve against an explicit, user-opted-in base
600
+ * (e.g. `--receipt-dir`, or cwd when the user has been told the exact resolved path) — see
601
+ * `runCommit` and docs/RECEIPTS.md. `runClaim` (the one-shot convenience) writes NOTHING unless an
602
+ * explicit `receiptPath` is passed.
603
+ * @param {string} contentHash 0x 32-byte digest
604
+ * @returns {string} a RELATIVE file name; resolve it against a caller-chosen safe base before writing
605
+ */
606
+ function defaultReceiptPath(contentHash) {
607
+ if (typeof contentHash !== "string" || !HEX32_RE.test(contentHash)) {
608
+ throw new Error(`defaultReceiptPath needs a 0x 32-byte contentHash, got: ${String(contentHash)}`);
609
+ }
610
+ const prefix = contentHash.slice(2, 2 + 16); // 16 hex chars = 8 bytes
611
+ return `./${prefix}.vhclaim.json`;
612
+ }
613
+
614
+ module.exports = {
615
+ SCHEMA_VERSION,
616
+ SUPPORTED_SCHEMA_VERSIONS,
617
+ RECEIPT_KIND,
618
+ CLAIM_RECEIPT_KIND,
619
+ ANCHOR_RECEIPT_KIND,
620
+ buildReceipt,
621
+ buildAnchorReceipt,
622
+ writeReceipt,
623
+ readReceipt,
624
+ diffManifest,
625
+ defaultReceiptPath,
626
+ // Exported for unit tests that exercise validation/manifest normalization directly.
627
+ _validate,
628
+ _normManifest,
629
+ _normGit,
630
+ _normParent,
631
+ };