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/lineage.js ADDED
@@ -0,0 +1,447 @@
1
+ "use strict";
2
+
3
+ // `vh lineage <0xhash> [--contract a] [--rpc u] [--json] [--max-depth n]` — walk the immutable
4
+ // `parent` chain UP from a record to its lineage root, read-only.
5
+ //
6
+ // WHERE THIS FITS
7
+ // T-10.1 added an optional, immutable predecessor edge (`parent`) to every record, and
8
+ // `vh anchor/claim --parent <hash>` writes it. This is the read counterpart: given any record's
9
+ // contentHash, it follows `record.parent` from child -> parent -> ... until it reaches a lineage
10
+ // root (parent == bytes32(0)), printing each ancestor in order. It is the "what is the full history
11
+ // of this contribution, and who authored each step?" query.
12
+ //
13
+ // The walk is purely OFF-CHAIN: there is no on-chain loop (the contract deliberately never walks an
14
+ // unbounded set), so we issue one bounded `getRecord` per hop. The chain is acyclic by construction
15
+ // (a non-zero parent MUST already be anchored at write time), so a finite chain always terminates at
16
+ // a root — but a client must still cap the walk so a pathological/huge chain cannot hang it. That cap
17
+ // is `--max-depth` (default 256); reaching it prints a clear "deeper than --max-depth" note rather
18
+ // than looping forever.
19
+ //
20
+ // TRUST POSTURE (mirrors docs/TRUST-BOUNDARIES.md and the contract NatSpec). Two caveats lead every
21
+ // human run:
22
+ // 1. the shared record caveat (uri untrusted; contributor only proves authorship when authorBound);
23
+ // 2. a lineage-specific one: a `parent` edge is the CHILD author's CLAIM of a predecessor. It does
24
+ // NOT prove the predecessor's content is a genuine ancestor of the child's content (re-derive
25
+ // BOTH and reason about it yourself), and it does NOT transfer the parent's authorship to the
26
+ // child. Each record's contributor/authorBound stands on its own.
27
+ //
28
+ // Read-only by construction: it takes a PROVIDER only, never a signer and never a key. Walking a
29
+ // public, immutable lineage must never require the ability to write to it.
30
+
31
+ const {
32
+ normalizeContentHash,
33
+ attributionFor,
34
+ isoFromUnix,
35
+ isRoot,
36
+ ZERO_HASH,
37
+ } = require("./show");
38
+ const { isNotAnchoredError } = require("./verify");
39
+ const {
40
+ assertRegistry,
41
+ formatRegistryLine,
42
+ formatSkippedLine,
43
+ jsonRegistryBlock,
44
+ jsonSkippedBlock,
45
+ } = require("./registry");
46
+
47
+ const ARTIFACT = require("./core/registryArtifact");
48
+ const ABI = ARTIFACT.abi;
49
+
50
+ // Default cap on how many ancestors the walk follows. A finite, acyclic chain always terminates at a
51
+ // root well before this; the cap exists only so a client can't be hung by a pathological/huge chain.
52
+ // 256 is generous for a real revision history yet bounds the worst case to 256 cheap eth_calls.
53
+ const DEFAULT_MAX_DEPTH = 256;
54
+
55
+ // Outcomes of a lineage run. WALKED == we read the start record and followed the chain (possibly to a
56
+ // root, possibly capped). NOT_ANCHORED == the START hash itself has no record (the contract reverted
57
+ // NotAnchored). A genuine RPC error is neither — it throws.
58
+ const STATUS = Object.freeze({
59
+ WALKED: "WALKED",
60
+ NOT_ANCHORED: "NOT_ANCHORED",
61
+ });
62
+
63
+ // The shared record trust caveat, kept consistent with cli/list.js / cli/show.js so the read commands
64
+ // never disagree about what `uri` / `contributor` are allowed to mean.
65
+ const RECORD_CAVEAT =
66
+ "NOTE: `uri` is an UNTRUSTED hint (never fetched/validated — re-fetch + re-hash yourself); " +
67
+ "`contributor` only means proven authorship when authorBound is true (commit-reveal), " +
68
+ "otherwise it is merely the first anchorer.";
69
+
70
+ // The lineage-specific caveat that ALSO leads every human run (acceptance #3). A parent edge is only a
71
+ // CLAIM by the child's author; spelling out what it does NOT prove keeps a reader from over-trusting an
72
+ // ancestry edge as proof of derivation or as a transfer of authorship.
73
+ const LINEAGE_CAVEAT =
74
+ "NOTE (lineage): a `parent` edge is the CHILD author's CLAIM of a predecessor. It does NOT prove " +
75
+ "the predecessor's content is a genuine ancestor of the child's content (re-derive BOTH yourself " +
76
+ "and reason about the relationship), and it does NOT transfer the parent's authorship to the child. " +
77
+ "Each record's contributor/authorBound stands on its own.";
78
+
79
+ /**
80
+ * Validate and normalize the `--max-depth` value. A missing value means the default; anything present
81
+ * must be a positive integer (a zero/negative/non-integer cap is a usage error — a 0-depth walk could
82
+ * never even read the start record, and a typo must never silently change how far we walk).
83
+ *
84
+ * @param {number|string|undefined|null} value
85
+ * @returns {number} the resolved positive-integer cap
86
+ */
87
+ function normalizeMaxDepth(value) {
88
+ if (value === undefined || value === null || value === "") return DEFAULT_MAX_DEPTH;
89
+ const n = typeof value === "number" ? value : Number(value);
90
+ if (!Number.isInteger(n) || n < 1) {
91
+ throw new Error(`invalid --max-depth: must be a positive integer, got: ${value}`);
92
+ }
93
+ return n;
94
+ }
95
+
96
+ /**
97
+ * Read ONE record by content hash, classifying a NotAnchored revert (an expected "no record") versus a
98
+ * genuine RPC/network failure (re-thrown), reusing the SAME `isNotAnchoredError` classifier verify.js /
99
+ * show.js use so the three commands never drift. Returns a normalized record object, or null when the
100
+ * hash has no record.
101
+ *
102
+ * @param {object} contract ethers v6 Contract bound to a provider
103
+ * @param {string} contentHash the 0x 32-byte hash to read
104
+ * @param {object} ethersLib ethers v6 module
105
+ * @param {string} notAnchoredSelector the NotAnchored 4-byte selector (for raw-data fallback)
106
+ * @returns {Promise<{
107
+ * contentHash:string, contributor:string, authorBound:boolean,
108
+ * timestamp:bigint, blockNumber:bigint, uri:string, parent:string
109
+ * }|null>}
110
+ */
111
+ async function readOne(contract, contentHash, ethersLib, notAnchoredSelector) {
112
+ let record;
113
+ try {
114
+ record = await contract.getRecord(contentHash);
115
+ } catch (err) {
116
+ if (isNotAnchoredError(err, ethersLib, notAnchoredSelector)) return null;
117
+ throw err; // genuine failure (network/address/etc.) — never masquerade as a missing record.
118
+ }
119
+ return {
120
+ contentHash: contentHash.toLowerCase(),
121
+ contributor: record.contributor,
122
+ authorBound: Boolean(record.authorBound),
123
+ timestamp: BigInt(record.timestamp),
124
+ blockNumber: BigInt(record.blockNumber),
125
+ uri: record.uri,
126
+ // The immutable lineage edge. Normalize to a lowercase 0x string so isRoot()/equality is stable; a
127
+ // root reads back as the 32-byte zero hash.
128
+ parent: String(record.parent).toLowerCase(),
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Walk the parent chain UP from `startHash`, bounded by `maxDepth`. Read-only: it issues at most
134
+ * `maxDepth` `getRecord` calls (one per hop), each through the shared NotAnchored classifier.
135
+ *
136
+ * Returns a structured result:
137
+ * - status NOT_ANCHORED + an empty ancestors[] when the START hash itself has no record;
138
+ * - status WALKED + ancestors[] in child->root order otherwise. `cappedAtDepth` is true iff we hit
139
+ * the cap before reaching a root (there is still an un-walked parent), in which case
140
+ * `nextParent` names the predecessor we stopped before so a caller could resume from there.
141
+ *
142
+ * The chain is acyclic by construction (a non-zero parent must already be anchored at write time), so
143
+ * a finite chain always terminates at a root before the cap; the cap only guards a pathological depth.
144
+ * A defensive in-walk visited-set still breaks on any repeat (it must never happen on a real chain),
145
+ * so even a (impossible) cycle can never spin forever.
146
+ *
147
+ * @param {object} contract ethers v6 Contract bound to a provider
148
+ * @param {string} startHash normalized 0x 32-byte start hash
149
+ * @param {object} opts { maxDepth:number, ethers:object }
150
+ * @returns {Promise<{
151
+ * status:"WALKED"|"NOT_ANCHORED",
152
+ * start:string,
153
+ * ancestors:Array<object>,
154
+ * cappedAtDepth:boolean,
155
+ * maxDepth:number,
156
+ * nextParent:string|null
157
+ * }>}
158
+ */
159
+ async function walkLineage(contract, startHash, opts) {
160
+ const ethersLib = opts.ethers || require("ethers");
161
+ const maxDepth = opts.maxDepth || DEFAULT_MAX_DEPTH;
162
+
163
+ const iface = new ethersLib.Interface(ABI);
164
+ const notAnchoredSelector = iface.getError("NotAnchored").selector;
165
+
166
+ const ancestors = [];
167
+ const seen = new Set(); // defensive cycle guard (cannot trigger on a real acyclic chain)
168
+ let cursor = startHash.toLowerCase();
169
+ let cappedAtDepth = false;
170
+ let nextParent = null;
171
+
172
+ for (let depth = 0; depth < maxDepth; depth++) {
173
+ if (seen.has(cursor)) break; // impossible on an acyclic chain; never loop forever regardless
174
+ seen.add(cursor);
175
+
176
+ /* eslint-disable no-await-in-loop */
177
+ const rec = await readOne(contract, cursor, ethersLib, notAnchoredSelector);
178
+ /* eslint-enable no-await-in-loop */
179
+
180
+ if (rec === null) {
181
+ // Only the very FIRST hop can be NOT_ANCHORED: a non-zero parent is required to be anchored at
182
+ // write time, so an interior hash is always present. If the START hash is missing we report
183
+ // NOT_ANCHORED; an (impossible) missing interior just terminates the walk at what we have.
184
+ if (depth === 0) {
185
+ return {
186
+ status: STATUS.NOT_ANCHORED,
187
+ start: startHash.toLowerCase(),
188
+ ancestors: [],
189
+ cappedAtDepth: false,
190
+ maxDepth,
191
+ nextParent: null,
192
+ };
193
+ }
194
+ break;
195
+ }
196
+
197
+ ancestors.push({
198
+ depth,
199
+ contentHash: rec.contentHash,
200
+ contributor: rec.contributor,
201
+ authorBound: rec.authorBound,
202
+ attribution: attributionFor(rec.authorBound),
203
+ timestamp: rec.timestamp,
204
+ blockNumber: rec.blockNumber,
205
+ uri: rec.uri,
206
+ parent: rec.parent,
207
+ isRoot: isRoot(rec.parent),
208
+ });
209
+
210
+ if (isRoot(rec.parent)) {
211
+ // Reached the lineage root: no predecessor. Done.
212
+ return {
213
+ status: STATUS.WALKED,
214
+ start: startHash.toLowerCase(),
215
+ ancestors,
216
+ cappedAtDepth: false,
217
+ maxDepth,
218
+ nextParent: null,
219
+ };
220
+ }
221
+
222
+ cursor = rec.parent;
223
+ // If this was the last allowed iteration and the record still has a (non-root) parent, the walk is
224
+ // capped: there is an un-walked predecessor. Record it so the caller can resume from there.
225
+ if (depth === maxDepth - 1) {
226
+ cappedAtDepth = true;
227
+ nextParent = rec.parent;
228
+ }
229
+ }
230
+
231
+ return {
232
+ status: STATUS.WALKED,
233
+ start: startHash.toLowerCase(),
234
+ ancestors,
235
+ cappedAtDepth,
236
+ maxDepth,
237
+ nextParent,
238
+ };
239
+ }
240
+
241
+ /** Render one ancestor as the human-readable block printed per hop. Mirrors list.js/show.js fields. */
242
+ function formatAncestor(a, index) {
243
+ const rootTag = a.isRoot ? " <- lineage root (no predecessor)" : "";
244
+ const lines = [
245
+ `[${index}] ${a.contentHash}${rootTag}`,
246
+ ` contributor: ${a.contributor}`,
247
+ ` attribution: ${a.attribution}`,
248
+ ` timestamp: ${a.timestamp} (${isoFromUnix(a.timestamp)})`,
249
+ ` blockNumber: ${a.blockNumber}`,
250
+ ` uri: ${a.uri ? a.uri : "(none)"}`,
251
+ ];
252
+ // Show the edge to the next ancestor explicitly so a reader can see the chain links, not just nodes.
253
+ const parentLine = a.isRoot
254
+ ? " parent: (none) — lineage root"
255
+ : ` parent: ${a.parent}`;
256
+ lines.push(parentLine);
257
+ return lines.join("\n");
258
+ }
259
+
260
+ /**
261
+ * Render a full lineage walk as the human-readable block the CLI prints. ALWAYS leads with both trust
262
+ * caveats (record + lineage-specific), then either the ordered ancestors or a NOT ANCHORED block, then
263
+ * a capped-walk note when the cap was hit.
264
+ */
265
+ function formatLineage(r) {
266
+ const lines = [RECORD_CAVEAT, "", LINEAGE_CAVEAT, ""];
267
+ // T-11.2: the registry-authentication confirmation (or the loud skip warning), printed BEFORE the
268
+ // walk so a reader sees the contract was authenticated before believing any ancestor below.
269
+ if (r.identitySkipped) {
270
+ lines.push(formatSkippedLine(), "");
271
+ } else if (r.registry) {
272
+ lines.push(formatRegistryLine(r.registry), "");
273
+ }
274
+ lines.push(` start: ${r.start}`);
275
+
276
+ if (r.status === STATUS.NOT_ANCHORED) {
277
+ lines.push(
278
+ " result: NOT ANCHORED",
279
+ " No record exists for this content hash, so it has no lineage. It was never anchored (or you",
280
+ " mistyped the hash). `vh anchor <path> --parent <hash>` / `vh claim <path> --parent <hash>`",
281
+ " anchor a record AS a revision of an existing one; `vh verify <path>` recomputes a path's hash."
282
+ );
283
+ return lines.join("\n");
284
+ }
285
+
286
+ const n = r.ancestors.length;
287
+ lines.push(
288
+ ` result: WALKED ${n} record${n === 1 ? "" : "s"} (child -> root order)`,
289
+ ""
290
+ );
291
+ lines.push(r.ancestors.map((a, i) => formatAncestor(a, i)).join("\n\n"));
292
+
293
+ if (r.cappedAtDepth) {
294
+ lines.push(
295
+ "",
296
+ ` NOTE: lineage deeper than --max-depth (${r.maxDepth}); the walk stopped before its root.`,
297
+ ` The next un-walked predecessor is ${r.nextParent}.`,
298
+ ` Re-run \`vh lineage ${r.nextParent} --max-depth <n>\` to continue from there.`
299
+ );
300
+ }
301
+ return lines.join("\n");
302
+ }
303
+
304
+ /**
305
+ * Shape a lineage result for `--json`: an ordered ancestor ARRAY carrying the same fields as the human
306
+ * block (BigInts -> Numbers so unix seconds / block heights pipe cleanly into CI). NOT_ANCHORED is a
307
+ * first-class value (anchored:false, empty ancestors), not an error object, so a script can branch on
308
+ * it without parsing stderr — while still seeing a non-zero exit from the CLI.
309
+ */
310
+ function jsonLineage(r) {
311
+ // T-11.2: the machine-readable registry block — proves the walk was read from an authenticated
312
+ // registry (or that the check was skipped).
313
+ const registry = r.identitySkipped
314
+ ? jsonSkippedBlock()
315
+ : r.registry
316
+ ? jsonRegistryBlock(r.registry)
317
+ : null;
318
+ if (r.status === STATUS.NOT_ANCHORED) {
319
+ return {
320
+ start: r.start,
321
+ registry,
322
+ anchored: false,
323
+ ancestors: [],
324
+ note:
325
+ "NOT ANCHORED: no on-chain record for this hash, so it has no lineage. `lineage` only walks " +
326
+ "anchored records; run `vh verify <path>` to bind a record to real content.",
327
+ };
328
+ }
329
+ return {
330
+ start: r.start,
331
+ registry,
332
+ anchored: true,
333
+ // The ordered ancestor array, child -> root. An indexer/UI can reconstruct the lineage path from
334
+ // this alone, mirroring the on-chain Linked(child, parent) logs.
335
+ ancestors: r.ancestors.map((a) => ({
336
+ depth: a.depth,
337
+ contentHash: a.contentHash,
338
+ contributor: a.contributor,
339
+ authorBound: a.authorBound,
340
+ attribution: a.attribution,
341
+ timestamp: Number(a.timestamp),
342
+ timestampISO: isoFromUnix(a.timestamp),
343
+ blockNumber: Number(a.blockNumber),
344
+ uri: a.uri ? a.uri : null,
345
+ // A root serializes parent:null + isRoot:true (distinguishable from a missing key); a parented
346
+ // record carries the predecessor hash + isRoot:false.
347
+ parent: a.isRoot ? null : a.parent,
348
+ isRoot: a.isRoot,
349
+ })),
350
+ // True iff the walk hit --max-depth before a root; `nextParent` is the un-walked predecessor.
351
+ cappedAtDepth: r.cappedAtDepth,
352
+ maxDepth: r.maxDepth,
353
+ nextParent: r.cappedAtDepth ? r.nextParent : null,
354
+ };
355
+ }
356
+
357
+ /**
358
+ * Walk a record's lineage by content hash. Read-only: requires a provider, never a signer.
359
+ *
360
+ * Validates the hash shape FIRST (a malformed/short hash throws BEFORE any network call, reusing
361
+ * show.js's normalizeContentHash so the same usage-grade error fires everywhere), then walks the chain.
362
+ * A NotAnchored revert on the START hash is the expected "no record" path (STATUS.NOT_ANCHORED); any
363
+ * other failure (bad RPC, wrong address, network down) is re-thrown rather than masqueraded — exactly as
364
+ * verify.js / show.js handle it, via the shared `isNotAnchoredError`.
365
+ *
366
+ * @param {object} opts
367
+ * @param {string} opts.contentHash the 0x 32-byte hash to start the walk from
368
+ * @param {string} opts.contractAddress deployed ContributionRegistry address to read from
369
+ * @param {object} opts.provider ethers v6 Provider (read-only RPC connection)
370
+ * @param {number|string} [opts.maxDepth] cap on how many ancestors to walk (default 256)
371
+ * @param {boolean} [opts.json] emit a JSON object instead of the human block
372
+ * @param {object} [opts.ethers] ethers v6 module (defaults to the bundled one)
373
+ * @param {(s:string)=>void} [opts.log] sink for output (defaults to process.stdout)
374
+ * @returns {Promise<{
375
+ * status:"WALKED"|"NOT_ANCHORED",
376
+ * start:string,
377
+ * ancestors:Array<object>,
378
+ * cappedAtDepth:boolean,
379
+ * maxDepth:number,
380
+ * nextParent:string|null
381
+ * }>}
382
+ */
383
+ async function runLineage(opts) {
384
+ const ethersLib = opts.ethers || require("ethers");
385
+ const log = opts.log || ((s) => process.stdout.write(s));
386
+
387
+ // Validate the hash + the cap BEFORE touching the contract address / provider, so a bad input
388
+ // hard-errors with a usage-grade message and never reaches the network.
389
+ const contentHash = normalizeContentHash(opts.contentHash, ethersLib);
390
+ const maxDepth = normalizeMaxDepth(opts.maxDepth);
391
+
392
+ const { contractAddress, provider } = opts;
393
+ if (!contractAddress) {
394
+ throw new Error(
395
+ "no contract address: pass --contract <address> or set VH_CONTRACT in the environment"
396
+ );
397
+ }
398
+ if (!ethersLib.isAddress(contractAddress)) {
399
+ throw new Error(`invalid contract address: ${contractAddress}`);
400
+ }
401
+ if (!provider) {
402
+ throw new Error("no provider: pass --rpc <url> or set VH_RPC_URL / AMOY_RPC_URL");
403
+ }
404
+
405
+ // T-11.2: authenticate the registry BEFORE walking the chain — no lineage is reported until we have
406
+ // confirmed there is a real verifyhash ContributionRegistry at this address (unless the caller
407
+ // explicitly, loudly opts out with skipIdentityCheck for a known not-yet-deployed/local-dev target).
408
+ let registryAuth = null;
409
+ if (!opts.skipIdentityCheck) {
410
+ registryAuth = await assertRegistry({ provider, contractAddress, ethers: ethersLib });
411
+ }
412
+
413
+ const contract = new ethersLib.Contract(
414
+ ethersLib.getAddress(contractAddress),
415
+ ABI,
416
+ provider
417
+ );
418
+
419
+ const result = await walkLineage(contract, contentHash, { maxDepth, ethers: ethersLib });
420
+ // Attach the registry identity (or the skip marker) so the human block and --json both surface it.
421
+ result.registry = registryAuth;
422
+ result.identitySkipped = Boolean(opts.skipIdentityCheck);
423
+
424
+ if (opts.json) {
425
+ log(JSON.stringify(jsonLineage(result), null, 2) + "\n");
426
+ } else {
427
+ log(formatLineage(result) + "\n");
428
+ }
429
+
430
+ return result;
431
+ }
432
+
433
+ module.exports = {
434
+ runLineage,
435
+ walkLineage,
436
+ readOne,
437
+ normalizeMaxDepth,
438
+ formatLineage,
439
+ formatAncestor,
440
+ jsonLineage,
441
+ STATUS,
442
+ RECORD_CAVEAT,
443
+ LINEAGE_CAVEAT,
444
+ DEFAULT_MAX_DEPTH,
445
+ ZERO_HASH,
446
+ ABI,
447
+ };