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/claim.js ADDED
@@ -0,0 +1,881 @@
1
+ "use strict";
2
+
3
+ // `vh claim <path>` — front-running-resistant attribution via commit-reveal.
4
+ //
5
+ // WHY THIS EXISTS
6
+ // The one-shot `vh anchor` puts the raw contentHash in the public mempool. Anyone watching can
7
+ // copy it and anchor it first, becoming the recorded `contributor` (audit findings F4/F14/F2/F5).
8
+ // So `anchor` records are only "first anchorer", never proven authorship.
9
+ //
10
+ // `vh claim` instead runs the contract's commit-reveal flow:
11
+ // 1. commit(commitment) where commitment = keccak256(abi.encode(contentHash, you, salt)).
12
+ // Only the opaque, sender-bound, salt-blinded hash goes on-chain — it leaks nothing about
13
+ // the contentHash and cannot be reused by anyone else.
14
+ // 2. ...wait MIN_REVEAL_DELAY blocks...
15
+ // 3. reveal(contentHash, salt, uri) — now the contentHash is public, but a mempool copier who
16
+ // resubmits this reveal as themselves recomputes a DIFFERENT commitment (bound to their
17
+ // address) that they never registered, so their reveal reverts. The committed claimant wins.
18
+ //
19
+ // The result is a record with authorBound = true and contributor = you, which front-running
20
+ // cannot redirect.
21
+ //
22
+ // The module is split into pure pieces (computeCommitment, buildCommitTx, buildRevealTx) plus an
23
+ // orchestration runner (runClaim) so the end-to-end test can drive both legs against a live hardhat
24
+ // node and prove a front-runner cannot steal the attribution.
25
+
26
+ const path = require("path");
27
+ const { hashPath, hashGit } = require("./hash");
28
+ const {
29
+ buildReceipt,
30
+ writeReceipt,
31
+ readReceipt,
32
+ defaultReceiptPath,
33
+ } = require("./receipt");
34
+
35
+ /**
36
+ * Resolve where a receipt file should be written from the caller's explicit choices, returning an
37
+ * ABSOLUTE path so the success line can name the exact file the user can see/relocate/delete.
38
+ *
39
+ * Precedence (all caller-opted-in; none of them silently default to cwd without telling the user):
40
+ * 1. `receiptPath` — an explicit full path (from `--receipt <path>`): used verbatim (resolved to
41
+ * absolute). The caller picked the exact file.
42
+ * 2. `receiptDir` + `contentHash` — an explicit destination directory (from `--receipt-dir <dir>`):
43
+ * `<dir>/<defaultName>`. The caller picked the folder; we pick the tidy default file name.
44
+ * 3. `contentHash` only — the documented default: `<baseDir>/<defaultName>` where `baseDir`
45
+ * defaults to `process.cwd()`. This is only reached for the DURABLE `vh commit` command, which
46
+ * MUST then print the exact resolved path (see runCommit) — never a silent cwd drop.
47
+ *
48
+ * @param {object} args
49
+ * @param {string} [args.receiptPath] explicit full path
50
+ * @param {string} [args.receiptDir] explicit destination directory
51
+ * @param {string} [args.contentHash] 0x digest, used to derive the default file name
52
+ * @param {string} [args.baseDir] base for the bare default (defaults to process.cwd())
53
+ * @returns {string} an ABSOLUTE receipt path
54
+ */
55
+ function resolveReceiptPath(args) {
56
+ if (args.receiptPath) return path.resolve(args.receiptPath);
57
+ const name = path.basename(defaultReceiptPath(args.contentHash)); // "<prefix>.vhclaim.json"
58
+ const base = args.receiptDir ? args.receiptDir : args.baseDir || process.cwd();
59
+ return path.resolve(base, name);
60
+ }
61
+
62
+ const ARTIFACT = require("./core/registryArtifact");
63
+ const ABI = ARTIFACT.abi;
64
+
65
+ /**
66
+ * Compute the content hash to claim for a filesystem path (same convention as `vh anchor`):
67
+ * a file claims its keccak256 digest, a directory its sorted-leaf Merkle root. For a directory the
68
+ * per-file manifest (sorted `{ path, contentHash, leaf }`) is returned too, so the claim/commit
69
+ * receipt records it (letting a later `vh verify --receipt` localize a tamper).
70
+ *
71
+ * With `opts.git`, the root and manifest are computed over EXACTLY the files git tracks at `opts.ref`
72
+ * (default HEAD) — the same reproducible `vh hash --git` enumeration (T-8.1) — and a `git` provenance
73
+ * block `{ commit, scope }` is returned so the claim/commit receipt records the resolved commit oid
74
+ * and the repo-relative scope used to enumerate the tracked set (an UNTRUSTED convenience hint).
75
+ *
76
+ * @param {string} targetPath
77
+ * @param {{ git?: boolean, ref?: string }} [opts]
78
+ * @returns {{ contentHash: string, kind: "file"|"dir",
79
+ * manifest: Array<{path:string,contentHash:string,leaf:string}>|null,
80
+ * git: {commit:string,scope:string}|null }}
81
+ */
82
+ function contentHashForPath(targetPath, opts = {}) {
83
+ if (opts.git) {
84
+ const res = hashGit(targetPath, { ref: opts.ref });
85
+ const manifest = res.leaves.map((l) => ({
86
+ path: l.path,
87
+ contentHash: l.contentHash,
88
+ leaf: l.leaf,
89
+ }));
90
+ return {
91
+ contentHash: res.root,
92
+ kind: "dir",
93
+ manifest,
94
+ git: { commit: res.commit, scope: res.scope },
95
+ };
96
+ }
97
+ const res = hashPath(targetPath);
98
+ const manifest =
99
+ res.kind === "dir" && Array.isArray(res.leaves)
100
+ ? res.leaves.map((l) => ({ path: l.path, contentHash: l.contentHash, leaf: l.leaf }))
101
+ : null;
102
+ return { contentHash: res.root, kind: res.kind, manifest, git: null };
103
+ }
104
+
105
+ /**
106
+ * Generate a fresh, cryptographically-random 32-byte salt (hex). The salt is the secret that, with
107
+ * the committer's address, blinds the commitment — it MUST be kept private until reveal.
108
+ * @param {object} [ethersLib] ethers v6 module
109
+ * @returns {string} 0x-prefixed 32-byte hex
110
+ */
111
+ function newSalt(ethersLib) {
112
+ const e = ethersLib || require("ethers");
113
+ return e.hexlify(e.randomBytes(32));
114
+ }
115
+
116
+ /**
117
+ * Compute the commitment hash exactly as the contract's `commitmentOf` does:
118
+ * keccak256(abi.encode(contentHash, committer, salt)).
119
+ * Binding `committer` is what makes a stolen reveal resolve to a different, never-registered
120
+ * commitment, so a front-runner cannot claim someone else's content.
121
+ *
122
+ * @param {object} args
123
+ * @param {string} args.contentHash 0x 32-byte digest being claimed
124
+ * @param {string} args.committer the address that will reveal (== eventual msg.sender)
125
+ * @param {string} args.salt 0x 32-byte secret salt
126
+ * @param {object} [args.ethers] ethers v6 module
127
+ * @returns {string} 0x 32-byte commitment hash
128
+ */
129
+ function computeCommitment(args) {
130
+ const e = args.ethers || require("ethers");
131
+ const { contentHash, committer, salt } = args;
132
+ if (!contentHash) throw new Error("computeCommitment requires contentHash");
133
+ if (!committer || !e.isAddress(committer)) {
134
+ throw new Error(`computeCommitment requires a valid committer address, got: ${committer}`);
135
+ }
136
+ if (!salt) throw new Error("computeCommitment requires a salt");
137
+ const encoded = e.AbiCoder.defaultAbiCoder().encode(
138
+ ["bytes32", "address", "bytes32"],
139
+ [contentHash, e.getAddress(committer), salt]
140
+ );
141
+ return e.keccak256(encoded);
142
+ }
143
+
144
+ /** Internal: validate + normalize the shared inputs both legs need. */
145
+ function _resolveContext(opts) {
146
+ const ethersLib = opts.ethers || require("ethers");
147
+ const { contractAddress } = opts;
148
+ if (!contractAddress) {
149
+ throw new Error(
150
+ "no contract address: pass --contract <address> or set VH_CONTRACT in the environment"
151
+ );
152
+ }
153
+ if (!ethersLib.isAddress(contractAddress)) {
154
+ throw new Error(`invalid contract address: ${contractAddress}`);
155
+ }
156
+ return { ethersLib, to: ethersLib.getAddress(contractAddress) };
157
+ }
158
+
159
+ /**
160
+ * Build (without sending) the `commit` transaction for a path. No network needed beyond knowing the
161
+ * committer's address. Generates a salt if one is not supplied; the caller MUST persist the returned
162
+ * salt (and contentHash + committer) to later reveal.
163
+ *
164
+ * @param {object} opts
165
+ * @param {string} opts.path file/dir to claim
166
+ * @param {string} opts.committer address that will commit & reveal
167
+ * @param {boolean}[opts.git] hash EXACTLY the git-tracked files (T-8.1 enumeration)
168
+ * @param {string} [opts.ref] with git: which commit's tracked set (default HEAD)
169
+ * @param {string} opts.contractAddress ContributionRegistry address (tx `to`)
170
+ * @param {string} [opts.salt] reuse a salt (else a fresh random one is generated)
171
+ * @param {string} [opts.parent] optional predecessor contentHash (B-10.1 lineage edge). The
172
+ * commit() tx itself NEVER carries a parent (the contract's
173
+ * commit takes only the commitment; the edge is recorded at
174
+ * REVEAL time via revealWithParent). We validate it here up
175
+ * front (parser parity with `vh anchor --parent`) and return
176
+ * the normalized value so runCommit can persist it for reveal.
177
+ * @param {object} [opts.ethers] ethers v6 module
178
+ * @returns {{
179
+ * to: string, data: string, value: string, functionName: "commit",
180
+ * contentHash: string, kind: "file"|"dir", path: string,
181
+ * manifest: Array|null, git: {commit:string,scope:string}|null,
182
+ * committer: string, salt: string, commitment: string, parent: string|null
183
+ * }}
184
+ */
185
+ function buildCommitTx(opts) {
186
+ const { ethersLib, to } = _resolveContext(opts);
187
+ const { path: targetPath, committer } = opts;
188
+ if (!targetPath) throw new Error("claim requires a <path>");
189
+ if (!committer || !ethersLib.isAddress(committer)) {
190
+ throw new Error(`claim requires a valid committer address, got: ${committer}`);
191
+ }
192
+
193
+ const { contentHash, kind, manifest, git } = contentHashForPath(targetPath, {
194
+ git: opts.git,
195
+ ref: opts.ref,
196
+ });
197
+ if (/^0x0{64}$/i.test(contentHash)) {
198
+ throw new Error("refusing to claim the zero hash (contract rejects it)");
199
+ }
200
+
201
+ // Validate the optional `--parent` lineage edge BEFORE building/sending anything (parser parity with
202
+ // `vh anchor --parent`, whose buildAnchorTx runs normalizeParent up front). A malformed/self-referential
203
+ // value is a typo the user must learn about immediately — never after commit() has already broadcast.
204
+ // The commit() tx is identical with or without a parent (the edge rides the REVEAL leg); we only carry
205
+ // the normalized parent on the built tx so runCommit can persist it into the receipt. normalizeParent
206
+ // maps missing/empty/zero -> null (a lineage root) and hard-errors on a malformed non-zero value.
207
+ const { normalizeParent } = require("./anchor");
208
+ const parent = normalizeParent(opts.parent, ethersLib);
209
+ if (parent !== null && parent.toLowerCase() === contentHash.toLowerCase()) {
210
+ throw new Error(
211
+ "refusing to claim a record as its own parent (self-reference; the contract rejects it as SelfParent)"
212
+ );
213
+ }
214
+
215
+ const salt = opts.salt || newSalt(ethersLib);
216
+ const committerAddr = ethersLib.getAddress(committer);
217
+ const commitment = computeCommitment({
218
+ contentHash,
219
+ committer: committerAddr,
220
+ salt,
221
+ ethers: ethersLib,
222
+ });
223
+
224
+ const iface = new ethersLib.Interface(ABI);
225
+ const data = iface.encodeFunctionData("commit", [commitment]);
226
+
227
+ return {
228
+ to,
229
+ data,
230
+ value: "0x0", // commit() is non-payable.
231
+ functionName: "commit",
232
+ contentHash,
233
+ kind,
234
+ path: targetPath,
235
+ manifest, // per-file manifest for a dir target (null for a file); recorded into the receipt
236
+ git, // { commit, scope } when --git was used; null otherwise. Recorded into the receipt.
237
+ committer: committerAddr,
238
+ salt,
239
+ commitment,
240
+ parent, // null for a lineage root; the normalized predecessor hash when --parent was given.
241
+ // The commit() tx does NOT carry it (the edge rides the reveal leg); runCommit persists it.
242
+ };
243
+ }
244
+
245
+ /**
246
+ * Build (without sending) the `reveal` transaction. Requires the salt produced at commit time.
247
+ *
248
+ * @param {object} opts
249
+ * @param {string} opts.contentHash the digest committed to
250
+ * @param {string} opts.salt the secret salt used to build the commitment
251
+ * @param {string} [opts.uri] optional untrusted off-chain pointer hint
252
+ * @param {string} [opts.parent] optional predecessor contentHash (T-10.1 lineage edge);
253
+ * non-zero routes to revealWithParent() and records the edge
254
+ * @param {string} opts.contractAddress ContributionRegistry address (tx `to`)
255
+ * @param {object} [opts.ethers] ethers v6 module
256
+ * @returns {{ to: string, data: string, value: string,
257
+ * functionName: "reveal"|"revealWithParent",
258
+ * contentHash: string, salt: string, uri: string, parent: string|null }}
259
+ */
260
+ function buildRevealTx(opts) {
261
+ const { ethersLib, to } = _resolveContext(opts);
262
+ const { contentHash, salt } = opts;
263
+ if (!contentHash) throw new Error("reveal requires the committed contentHash");
264
+ if (!salt) throw new Error("reveal requires the secret salt from the commit step");
265
+ const uri = opts.uri == null ? "" : String(opts.uri);
266
+
267
+ // Resolve the optional lineage edge (same convention/validation as `vh anchor --parent`): a
268
+ // missing/zero parent is a root via the legacy reveal(); a non-zero 32-byte hash routes to
269
+ // revealWithParent(). Self-reference is rejected here; the contract enforces UnknownParent/SelfParent.
270
+ const { normalizeParent } = require("./anchor");
271
+ const parent = normalizeParent(opts.parent, ethersLib);
272
+ if (parent !== null && parent.toLowerCase() === contentHash.toLowerCase()) {
273
+ throw new Error(
274
+ "refusing to reveal a record as its own parent (self-reference; the contract rejects it as SelfParent)"
275
+ );
276
+ }
277
+
278
+ const iface = new ethersLib.Interface(ABI);
279
+ const functionName = parent === null ? "reveal" : "revealWithParent";
280
+ const data =
281
+ parent === null
282
+ ? iface.encodeFunctionData("reveal", [contentHash, salt, uri])
283
+ : iface.encodeFunctionData("revealWithParent", [contentHash, salt, uri, parent]);
284
+
285
+ return {
286
+ to,
287
+ data,
288
+ value: "0x0", // reveal()/revealWithParent() are non-payable.
289
+ functionName,
290
+ contentHash,
291
+ salt,
292
+ uri,
293
+ parent, // null for a lineage root; the predecessor hash when --parent was given.
294
+ };
295
+ }
296
+
297
+ /**
298
+ * Render the commit/reveal plan a `--dry-run` claim prints (no key, no network).
299
+ *
300
+ * The optional `revealTx` (the built reveal leg from buildRevealTx) carries the lineage edge: when a
301
+ * `--parent` was given it routes the Step-2 reveal to `revealWithParent(contentHash, salt, uri, parent)`
302
+ * and the parent hash is shown so a user previewing a `vh claim --parent` write SEES the lineage edge
303
+ * they are about to record (parity with `vh anchor --dry-run`, which prints `parent:`). Without a
304
+ * parent the plan reads exactly as before — the legacy `reveal(contentHash, salt, uri)` line, byte for
305
+ * byte. A `revealTx` is always passed by runClaim; the parameter stays optional so an older caller that
306
+ * omits it degrades to the no-parent rendering rather than throwing.
307
+ *
308
+ * @param {object} commitTx the built commit leg (from buildCommitTx)
309
+ * @param {object} [revealTx] the built reveal leg (from buildRevealTx); carries `parent`/`functionName`
310
+ */
311
+ function formatDryRun(commitTx, revealTx) {
312
+ // The lineage edge to preview: a non-null parent means this claim routes its reveal to
313
+ // revealWithParent() and records the edge; null/absent means a lineage root via the legacy reveal().
314
+ const parent = revealTx && revealTx.parent != null ? revealTx.parent : null;
315
+ const revealFn =
316
+ revealTx && revealTx.functionName ? revealTx.functionName : parent == null ? "reveal" : "revealWithParent";
317
+
318
+ const lines = [
319
+ "DRY RUN — no transaction will be sent (commit-reveal attribution).",
320
+ "",
321
+ ` path: ${commitTx.path} (${commitTx.kind})`,
322
+ ` contentHash: ${commitTx.contentHash}`,
323
+ ` committer: ${commitTx.committer}`,
324
+ ` salt: ${commitTx.salt} <-- SECRET: keep this to reveal later`,
325
+ ` commitment: ${commitTx.commitment}`,
326
+ // Lineage edge (T-10.1): show whether this claim is a root or a child of `parent`, and which reveal
327
+ // path (reveal vs revealWithParent) it routes to — so a dry-run reader sees the edge they'd record.
328
+ ` parent: ${parent == null ? "(none) — lineage root" : parent}`,
329
+ ];
330
+ if (commitTx.git) {
331
+ lines.push(
332
+ ` git commit: ${commitTx.git.commit} (untrusted provenance hint)`,
333
+ ` git scope: ${commitTx.git.scope}`
334
+ );
335
+ }
336
+ // The Step-2 line names the EXACT reveal function and (when parented) the predecessor hash, so the
337
+ // printed plan never silently omits a lineage edge the user is about to record.
338
+ const step2 =
339
+ parent == null
340
+ ? ` Step 2 — after MIN_REVEAL_DELAY blocks, ${revealFn}(contentHash, salt, uri) is sent.`
341
+ : ` Step 2 — after MIN_REVEAL_DELAY blocks, ${revealFn}(contentHash, salt, uri, parent) is sent,\n` +
342
+ ` recording the lineage edge -> parent ${parent}.`;
343
+ lines.push(
344
+ "",
345
+ " Step 1 — commit() that WOULD be sent:",
346
+ ` to: ${commitTx.to}`,
347
+ ` value: ${commitTx.value}`,
348
+ ` data: ${commitTx.data}`,
349
+ "",
350
+ step2,
351
+ " A mempool copier who lifts your reveal cannot win: their commitment (bound to THEIR",
352
+ " address) was never registered, so their reveal reverts. Attribution stays yours.",
353
+ ""
354
+ );
355
+ return lines.join("\n");
356
+ }
357
+
358
+ /**
359
+ * Resolve and guard the chain to submit to. Determines the chainId (from opts or the provider) and
360
+ * enforces the same testnet guard policy as `anchor`: refuse a non-testnet chain unless the caller
361
+ * explicitly passed `iUnderstandMainnet`.
362
+ * @param {object} opts {chainId?, provider, iUnderstandMainnet?, verb?}
363
+ * @returns {Promise<bigint>} the resolved chainId
364
+ */
365
+ async function _resolveChainGuard(opts) {
366
+ let chainId = opts.chainId;
367
+ if (chainId == null && opts.provider) {
368
+ const net = await opts.provider.getNetwork();
369
+ chainId = net.chainId;
370
+ }
371
+ // Reuse the same testnet guard policy as anchor (imported lazily to avoid a cycle at load time).
372
+ const { isTestnetChainId } = require("./anchor");
373
+ if (chainId == null) {
374
+ throw new Error("cannot determine chainId; refusing to submit without knowing the network");
375
+ }
376
+ if (!isTestnetChainId(chainId) && !opts.iUnderstandMainnet) {
377
+ const verb = opts.verb || "claim";
378
+ throw new Error(
379
+ `refusing to ${verb} on chainId ${BigInt(chainId).toString()} (not a known testnet). ` +
380
+ "If you really mean to write to this chain, re-run with --i-understand-mainnet."
381
+ );
382
+ }
383
+ return BigInt(chainId);
384
+ }
385
+
386
+ /**
387
+ * Wait until the chain has advanced past the MIN_REVEAL_DELAY window for a commit that mined in
388
+ * `commitBlock`. A reveal requires `current > commitBlock + minDelay`, so we wait for
389
+ * `commitBlock + minDelay + 1`.
390
+ * @param {object} args {provider, commitBlock: bigint, minDelay: bigint, waitForBlock?}
391
+ */
392
+ async function _waitRevealWindow(args) {
393
+ const { provider, commitBlock, minDelay } = args;
394
+ const revealAfter = commitBlock + minDelay;
395
+ if (args.waitForBlock) {
396
+ await args.waitForBlock(revealAfter + 1n);
397
+ } else if (provider) {
398
+ // Poll until the chain advances past the window. (On a live testnet this just waits for blocks
399
+ // to be produced.)
400
+ /* eslint-disable no-await-in-loop */
401
+ while (BigInt(await provider.getBlockNumber()) <= revealAfter) {
402
+ await new Promise((r) => setTimeout(r, 1500));
403
+ }
404
+ /* eslint-enable no-await-in-loop */
405
+ }
406
+ }
407
+
408
+ /** Parse the first `Revealed` event out of a transaction receipt's logs, or return null. */
409
+ function _parseRevealed(receipt, ethersLib) {
410
+ const iface = new ethersLib.Interface(ABI);
411
+ for (const lg of receipt.logs) {
412
+ try {
413
+ const parsed = iface.parseLog({ topics: lg.topics, data: lg.data });
414
+ if (parsed && parsed.name === "Revealed") {
415
+ return {
416
+ contentHash: parsed.args.contentHash,
417
+ contributor: parsed.args.contributor,
418
+ index: parsed.args.index,
419
+ commitment: parsed.args.commitment,
420
+ timestamp: parsed.args.timestamp,
421
+ uri: parsed.args.uri,
422
+ };
423
+ }
424
+ } catch (_) {
425
+ /* not our event */
426
+ }
427
+ }
428
+ return null;
429
+ }
430
+
431
+ /**
432
+ * Run ONLY the commit leg of a resumable claim, persisting a durable receipt BEFORE it returns.
433
+ *
434
+ * This is the safe, restartable half of commit-reveal: it sends `commit()`, waits for it to mine,
435
+ * reads MIN_REVEAL_DELAY, then writes the receipt (salt + commitment + everything `reveal()` needs)
436
+ * to disk so a separate `runReveal` process can finish the claim even after a crash/restart.
437
+ *
438
+ * @param {object} opts
439
+ * @param {string} opts.path
440
+ * @param {string} [opts.uri]
441
+ * @param {string} [opts.parent] optional predecessor contentHash (B-10.1 lineage edge);
442
+ * validated up front and persisted into the receipt so the
443
+ * later `runReveal` routes to revealWithParent(). The commit()
444
+ * tx itself is unchanged (the edge is recorded at reveal time).
445
+ * @param {boolean}[opts.git] hash EXACTLY the git-tracked files (T-8.1 enumeration)
446
+ * @param {string} [opts.ref] with git: which commit's tracked set (default HEAD)
447
+ * @param {string} opts.contractAddress
448
+ * @param {string} [opts.receiptPath] explicit full path to write the receipt to (--receipt)
449
+ * @param {string} [opts.receiptDir] explicit destination DIRECTORY (--receipt-dir); the tidy
450
+ * default file name is used inside it
451
+ * @param {string} [opts.baseDir] base dir for the bare default name (default process.cwd())
452
+ * @param {boolean}[opts.iUnderstandMainnet]
453
+ * @param {object} opts.signer ethers Signer
454
+ * @param {object} [opts.provider]
455
+ * @param {bigint|number}[opts.chainId]
456
+ * @param {string} [opts.salt] reuse a salt (else random)
457
+ * @param {object} [opts.ethers]
458
+ * @param {(s:string)=>void}[opts.log]
459
+ * @returns {Promise<{commitTx, commitTxHash, commitBlockNumber, minRevealDelay, chainId, receiptPath, receipt}>}
460
+ *
461
+ * The receipt path is resolved to an ABSOLUTE path (see resolveReceiptPath) and the EXACT file is
462
+ * named in the success log so the user can always see/relocate/delete the secret-bearing receipt.
463
+ * When no `--receipt`/`--receipt-dir` is given the default lands in `baseDir` (cwd) — but only ever
464
+ * after the success line names that exact resolved file, so it is never a silent secret drop.
465
+ */
466
+ async function runCommit(opts) {
467
+ const ethersLib = opts.ethers || require("ethers");
468
+ const log = opts.log || ((s) => process.stdout.write(s));
469
+
470
+ if (!opts.signer) {
471
+ throw new Error("no signer available to submit the commit (set PRIVATE_KEY?)");
472
+ }
473
+
474
+ let committer = opts.committer;
475
+ if (!committer) committer = await opts.signer.getAddress();
476
+
477
+ const commitTx = buildCommitTx({
478
+ path: opts.path,
479
+ committer,
480
+ git: opts.git,
481
+ ref: opts.ref,
482
+ contractAddress: opts.contractAddress,
483
+ salt: opts.salt,
484
+ parent: opts.parent, // validated up front in buildCommitTx (parity with anchor); persisted below
485
+ ethers: ethersLib,
486
+ });
487
+
488
+ const provider = opts.provider || opts.signer.provider;
489
+ const chainId = await _resolveChainGuard({
490
+ chainId: opts.chainId,
491
+ provider,
492
+ iUnderstandMainnet: opts.iUnderstandMainnet,
493
+ verb: "commit",
494
+ });
495
+
496
+ const contract = new ethersLib.Contract(commitTx.to, ABI, opts.signer);
497
+
498
+ // Name whether a lineage edge will be recorded at reveal time, so the operator SEES it from the
499
+ // commit step (the commit() tx is identical with or without a parent; the edge rides the reveal leg).
500
+ const parentNote = commitTx.parent ? ` (-> parent ${commitTx.parent} will be recorded at reveal)` : "";
501
+ log(
502
+ `commit: committing ${commitTx.path} (${commitTx.kind}) as ${commitTx.committer}${parentNote}...\n`
503
+ );
504
+ const commitSent = await contract.commit(commitTx.commitment);
505
+ log(` commit tx: ${commitSent.hash}\n`);
506
+ const commitReceiptTx = await commitSent.wait();
507
+ const commitBlock = BigInt(commitReceiptTx.blockNumber);
508
+ const minDelay = BigInt(await contract.MIN_REVEAL_DELAY());
509
+
510
+ // Persist the receipt BEFORE returning/waiting, so the salt survives a crash from here on.
511
+ // Resolve to an ABSOLUTE path from the caller's explicit choices; we name it exactly below so the
512
+ // secret-bearing file is never silently dropped somewhere the user can't find.
513
+ const receiptPath = resolveReceiptPath({
514
+ receiptPath: opts.receiptPath,
515
+ receiptDir: opts.receiptDir,
516
+ baseDir: opts.baseDir,
517
+ contentHash: commitTx.contentHash,
518
+ });
519
+ const receipt = buildReceipt({
520
+ contentHash: commitTx.contentHash,
521
+ committer: commitTx.committer,
522
+ salt: commitTx.salt,
523
+ commitment: commitTx.commitment,
524
+ contractAddress: commitTx.to,
525
+ chainId,
526
+ uri: opts.uri,
527
+ path: commitTx.path,
528
+ kind: commitTx.kind,
529
+ manifest: commitTx.manifest || undefined,
530
+ git: commitTx.git || undefined, // untrusted provenance hint: { commit, scope } when --git
531
+ parent: commitTx.parent || undefined, // B-10.1 lineage edge: recorded only when --parent was given
532
+ commitTxHash: commitReceiptTx.hash,
533
+ commitBlockNumber: commitBlock,
534
+ minRevealDelay: minDelay,
535
+ });
536
+ writeReceipt(receipt, receiptPath);
537
+ log(
538
+ ` receipt written: ${receiptPath}\n` +
539
+ ` KEEP THIS PRIVATE — it holds the secret salt. Resume with: vh reveal --receipt ${receiptPath}\n`
540
+ );
541
+
542
+ return {
543
+ commitTx,
544
+ commitTxHash: commitReceiptTx.hash,
545
+ commitBlockNumber: commitBlock,
546
+ minRevealDelay: minDelay,
547
+ chainId,
548
+ receiptPath,
549
+ receipt,
550
+ };
551
+ }
552
+
553
+ /**
554
+ * Resume a claim from a persisted receipt and submit the reveal leg once the MIN_REVEAL_DELAY window
555
+ * has matured. Loads the salt/commitment/uri (and, B-10.1, the optional lineage `parent`) from the
556
+ * receipt — it needs NO information that wasn't durably written at commit time, so it works from a
557
+ * completely fresh process. When the receipt records a `parent` it routes to
558
+ * `revealWithParent(contentHash, salt, uri, parent)` (recording the lineage edge); otherwise it uses
559
+ * the legacy `reveal(contentHash, salt, uri)`, byte-for-byte unchanged.
560
+ *
561
+ * If the window has not yet matured the contract reverts with `RevealTooSoon`; if the receipt names a
562
+ * `parent` that was never anchored the contract reverts `UnknownParent`. In BOTH cases this function
563
+ * lets the error propagate and leaves the receipt file untouched so the user can simply retry later
564
+ * (the secret salt is never lost to a failed reveal).
565
+ *
566
+ * @param {object} opts
567
+ * @param {string} opts.receiptPath the receipt written by runCommit
568
+ * @param {object} opts.signer ethers Signer (must be the original committer)
569
+ * @param {object} [opts.provider]
570
+ * @param {bigint|number}[opts.chainId]
571
+ * @param {boolean}[opts.iUnderstandMainnet]
572
+ * @param {object} [opts.ethers]
573
+ * @param {(s:string)=>void}[opts.log]
574
+ * @param {(target:bigint)=>Promise<void>}[opts.waitForBlock] test hook to advance/await blocks
575
+ * @param {boolean}[opts.noWait] skip the maturation wait (let the contract enforce it)
576
+ * @returns {Promise<{revealed, revealTxHash, chainId, receiptPath, receipt}>}
577
+ */
578
+ async function runReveal(opts) {
579
+ const ethersLib = opts.ethers || require("ethers");
580
+ const log = opts.log || ((s) => process.stdout.write(s));
581
+
582
+ if (!opts.receiptPath) throw new Error("runReveal requires a receiptPath");
583
+ if (!opts.signer) {
584
+ throw new Error("no signer available to submit the reveal (set PRIVATE_KEY?)");
585
+ }
586
+
587
+ // Strict read: a corrupt/partial receipt throws here rather than producing a wrong reveal.
588
+ const receipt = readReceipt(opts.receiptPath);
589
+
590
+ const provider = opts.provider || opts.signer.provider;
591
+ const chainId = await _resolveChainGuard({
592
+ chainId: opts.chainId,
593
+ provider,
594
+ iUnderstandMainnet: opts.iUnderstandMainnet,
595
+ verb: "reveal",
596
+ });
597
+
598
+ // Sanity check: the signer must be the address bound into the commitment, else reveal would hit
599
+ // NoSuchCommitment. Fail fast with a clear message instead.
600
+ const signerAddr = ethersLib.getAddress(await opts.signer.getAddress());
601
+ if (ethersLib.getAddress(receipt.committer) !== signerAddr) {
602
+ throw new Error(
603
+ `signer ${signerAddr} is not the committer ${receipt.committer} bound in this receipt; ` +
604
+ "only the original committer can reveal it."
605
+ );
606
+ }
607
+
608
+ const contract = new ethersLib.Contract(receipt.contractAddress, ABI, opts.signer);
609
+
610
+ // Wait out MIN_REVEAL_DELAY when we know the commit block (unless the caller opts out / handles it).
611
+ if (!opts.noWait && receipt.commitBlockNumber != null) {
612
+ const minDelay =
613
+ receipt.minRevealDelay != null
614
+ ? BigInt(receipt.minRevealDelay)
615
+ : BigInt(await contract.MIN_REVEAL_DELAY());
616
+ await _waitRevealWindow({
617
+ provider,
618
+ commitBlock: BigInt(receipt.commitBlockNumber),
619
+ minDelay,
620
+ waitForBlock: opts.waitForBlock,
621
+ });
622
+ }
623
+
624
+ // Route the reveal leg from what the receipt durably recorded at commit time (B-10.1). When the
625
+ // receipt carries a `parent` (a `vh commit --parent` claim), reuse buildRevealTx — which already
626
+ // supports a parent and routes to revealWithParent(contentHash, salt, uri, parent), recording the
627
+ // lineage edge. When absent it routes to the legacy reveal(), byte-for-byte unchanged (no regression).
628
+ // The contract checks the parent at REVEAL time: if the parent was never anchored it reverts
629
+ // UnknownParent and (since we let that propagate) the receipt is left intact for a later retry.
630
+ const revealTx = buildRevealTx({
631
+ contentHash: receipt.contentHash,
632
+ salt: receipt.salt,
633
+ uri: receipt.uri || "",
634
+ parent: receipt.parent, // null/undefined -> legacy reveal(); a hash -> revealWithParent()
635
+ contractAddress: receipt.contractAddress,
636
+ ethers: ethersLib,
637
+ });
638
+ const lineageNote = revealTx.parent ? ` with parent ${revealTx.parent}` : "";
639
+ log(`reveal: revealing ${receipt.contentHash}${lineageNote} as ${receipt.committer}...\n`);
640
+ const revealSent =
641
+ revealTx.parent == null
642
+ ? await contract.reveal(receipt.contentHash, receipt.salt, receipt.uri || "")
643
+ : await contract.revealWithParent(
644
+ receipt.contentHash,
645
+ receipt.salt,
646
+ receipt.uri || "",
647
+ revealTx.parent
648
+ );
649
+ log(` reveal tx: ${revealSent.hash}\n`);
650
+ const revealReceiptTx = await revealSent.wait();
651
+
652
+ const revealed = _parseRevealed(revealReceiptTx, ethersLib);
653
+ if (revealed) {
654
+ log(
655
+ ` Claimed (authorBound) at index ${revealed.index} by ${revealed.contributor} ` +
656
+ `in tx ${revealReceiptTx.hash}\n`
657
+ );
658
+ }
659
+
660
+ return {
661
+ revealed,
662
+ revealTxHash: revealReceiptTx.hash,
663
+ chainId,
664
+ receiptPath: opts.receiptPath,
665
+ receipt,
666
+ };
667
+ }
668
+
669
+ /**
670
+ * Run the full commit-reveal claim end to end (the one-shot convenience, both legs in one process).
671
+ *
672
+ * In `--dry-run` mode it only builds the commitment + both txs and returns them (no key, no
673
+ * network). Otherwise it: enforces the testnet guard, sends commit(), persists a durable receipt,
674
+ * waits for the MIN_REVEAL_DELAY window to pass, sends reveal(), and parses the Revealed event.
675
+ *
676
+ * RECEIPT POLICY (T-9.1). This one-shot helper NEVER silently drops a secret-bearing receipt into
677
+ * the current working directory. A claim receipt holds the secret `salt`, so persisting it is OPT-IN:
678
+ * - if an explicit `receiptPath` (or `receiptDir`) is given (and `writeReceiptFile !== false`), the
679
+ * receipt is written and the exact resolved file is named in the success log;
680
+ * - if NEITHER is given, NOTHING is written — the validated receipt object is returned in-memory on
681
+ * the result as `receipt` (and `receiptPath` stays undefined). The caller that wants a durable,
682
+ * resumable artifact should use `runCommit`/`vh commit` (the intended durable command, which
683
+ * resolves a documented default path), or pass an explicit `receiptPath`/`receiptDir` here.
684
+ * `writeReceiptFile: false` still hard-disables the write even when a destination is present.
685
+ *
686
+ * @param {object} opts
687
+ * @param {string} opts.path
688
+ * @param {string} [opts.uri]
689
+ * @param {string} [opts.parent] optional predecessor contentHash (T-10.1 lineage edge);
690
+ * non-zero routes the reveal leg to revealWithParent()
691
+ * @param {boolean}[opts.git] hash EXACTLY the git-tracked files (T-8.1 enumeration)
692
+ * @param {string} [opts.ref] with git: which commit's tracked set (default HEAD)
693
+ * @param {string} opts.contractAddress
694
+ * @param {boolean}[opts.dryRun]
695
+ * @param {boolean}[opts.iUnderstandMainnet]
696
+ * @param {object} [opts.signer] ethers Signer (required unless dryRun)
697
+ * @param {object} [opts.provider] ethers Provider (chainId + block waits)
698
+ * @param {bigint|number}[opts.chainId] override chainId lookup (tests)
699
+ * @param {string} [opts.salt] reuse a salt (else random)
700
+ * @param {string} [opts.receiptPath] explicit full path to persist the receipt (else nothing)
701
+ * @param {string} [opts.receiptDir] explicit destination DIR to persist the receipt into (else nothing)
702
+ * @param {boolean}[opts.writeReceiptFile] set false to hard-disable the write even with a destination
703
+ * @param {object} [opts.ethers]
704
+ * @param {(s:string)=>void}[opts.log]
705
+ * @param {(target:bigint)=>Promise<void>}[opts.waitForBlock] test hook to advance/await blocks
706
+ * @returns {Promise<object>} includes `receipt` (the in-memory receipt object) and `receiptPath`
707
+ * (the file written, or undefined when none was)
708
+ */
709
+ async function runClaim(opts) {
710
+ const ethersLib = opts.ethers || require("ethers");
711
+ const log = opts.log || ((s) => process.stdout.write(s));
712
+
713
+ // Resolve who the committer is. For a dry run we may not have a signer; allow an explicit
714
+ // committer address so the plan can still be shown.
715
+ let committer = opts.committer;
716
+ if (!committer && opts.signer) {
717
+ committer = await opts.signer.getAddress();
718
+ }
719
+
720
+ const commitTx = buildCommitTx({
721
+ path: opts.path,
722
+ committer,
723
+ git: opts.git,
724
+ ref: opts.ref,
725
+ contractAddress: opts.contractAddress,
726
+ salt: opts.salt,
727
+ ethers: ethersLib,
728
+ });
729
+
730
+ // Validate the optional `--parent` lineage edge BEFORE any network call (parser parity with
731
+ // `vh anchor`, whose buildAnchorTx runs normalizeParent up front). The edge is recorded only on the
732
+ // REVEAL leg (revealWithParent), but a malformed/self-referential parent is a typo the user must
733
+ // learn about immediately — NOT after commit() has already been broadcast (a real gas-spending,
734
+ // MIN_REVEAL_DELAY-waiting write) only to have the reveal reject it. A typo never silently drops the
735
+ // parent into a no-op commit. `normalizeParent` maps missing/empty/zero -> null (a lineage root) and
736
+ // hard-errors on a malformed non-zero value; the self-reference is rejected here, the contract still
737
+ // enforces UnknownParent/SelfParent authoritatively on-chain. Reuses anchor.js, not a reimplementation.
738
+ const { normalizeParent } = require("./anchor");
739
+ const parent = normalizeParent(opts.parent, ethersLib);
740
+ if (parent !== null && parent.toLowerCase() === commitTx.contentHash.toLowerCase()) {
741
+ throw new Error(
742
+ "refusing to reveal a record as its own parent (self-reference; the contract rejects it as SelfParent)"
743
+ );
744
+ }
745
+
746
+ if (opts.dryRun) {
747
+ const revealTx = buildRevealTx({
748
+ contentHash: commitTx.contentHash,
749
+ salt: commitTx.salt,
750
+ uri: opts.uri,
751
+ parent, // already validated above (parity with the real submission path below)
752
+ contractAddress: opts.contractAddress,
753
+ ethers: ethersLib,
754
+ });
755
+ // Pass the built revealTx so the printed plan shows the lineage edge (parent + revealWithParent)
756
+ // it would record — without it the preview would silently omit a `--parent` the user passed.
757
+ log(formatDryRun(commitTx, revealTx) + "\n");
758
+ return { dryRun: true, commitTx, revealTx };
759
+ }
760
+
761
+ // Real submission from here on.
762
+ if (!opts.signer) {
763
+ throw new Error("no signer available to submit the claim (set PRIVATE_KEY?)");
764
+ }
765
+ const provider = opts.provider || opts.signer.provider;
766
+
767
+ const chainId = await _resolveChainGuard({
768
+ chainId: opts.chainId,
769
+ provider,
770
+ iUnderstandMainnet: opts.iUnderstandMainnet,
771
+ verb: "claim",
772
+ });
773
+
774
+ const contract = new ethersLib.Contract(commitTx.to, ABI, opts.signer);
775
+
776
+ // --- Step 1: commit ---
777
+ log(`claim: committing ${commitTx.path} (${commitTx.kind}) as ${commitTx.committer}...\n`);
778
+ const commitSent = await contract.commit(commitTx.commitment);
779
+ log(` commit tx: ${commitSent.hash}\n`);
780
+ const commitReceipt = await commitSent.wait();
781
+ const commitBlock = BigInt(commitReceipt.blockNumber);
782
+ const minDelay = BigInt(await contract.MIN_REVEAL_DELAY());
783
+
784
+ // Build the validated receipt object in memory regardless — it is always returned so a caller can
785
+ // persist it itself. We PERSIST it to disk only when the caller explicitly opted in with a
786
+ // `receiptPath` (and did not set writeReceiptFile:false). A claim receipt holds the secret salt, so
787
+ // this one-shot convenience never silently drops it into cwd; for a durable, resumable artifact use
788
+ // `runCommit`/`vh commit` (which resolves a documented default path and names the exact file).
789
+ let receiptPath;
790
+ const receipt = buildReceipt({
791
+ contentHash: commitTx.contentHash,
792
+ committer: commitTx.committer,
793
+ salt: commitTx.salt,
794
+ commitment: commitTx.commitment,
795
+ contractAddress: commitTx.to,
796
+ chainId,
797
+ uri: opts.uri,
798
+ path: commitTx.path,
799
+ kind: commitTx.kind,
800
+ manifest: commitTx.manifest || undefined,
801
+ git: commitTx.git || undefined, // untrusted provenance hint: { commit, scope } when --git
802
+ commitTxHash: commitReceipt.hash,
803
+ commitBlockNumber: commitBlock,
804
+ minRevealDelay: minDelay,
805
+ });
806
+ const persistOptIn = opts.receiptPath != null || opts.receiptDir != null;
807
+ if (opts.writeReceiptFile !== false && persistOptIn) {
808
+ receiptPath = resolveReceiptPath({
809
+ receiptPath: opts.receiptPath,
810
+ receiptDir: opts.receiptDir,
811
+ contentHash: commitTx.contentHash,
812
+ });
813
+ writeReceipt(receipt, receiptPath);
814
+ log(
815
+ ` receipt written: ${receiptPath}\n` +
816
+ ` KEEP THIS PRIVATE — it holds the secret salt.\n`
817
+ );
818
+ } else if (opts.writeReceiptFile !== false) {
819
+ // No explicit destination: do NOT silently write a secret receipt to cwd. Tell the user how to
820
+ // persist one if they want a resumable artifact.
821
+ log(
822
+ " (no --receipt given: not persisting a claim receipt. " +
823
+ "Pass --receipt <path> to persist a resumable receipt, or use `vh commit`.)\n"
824
+ );
825
+ }
826
+
827
+ // --- Wait out MIN_REVEAL_DELAY ---
828
+ await _waitRevealWindow({
829
+ provider,
830
+ commitBlock,
831
+ minDelay,
832
+ waitForBlock: opts.waitForBlock,
833
+ });
834
+
835
+ // --- Step 2: reveal ---
836
+ // Route to revealWithParent() iff a non-zero predecessor was given (T-10.1); otherwise the legacy
837
+ // reveal(), byte-for-byte unchanged. `parent` was validated up front (before any network call) so a
838
+ // malformed/self-referential value already hard-errored before commit() was ever broadcast.
839
+ const lineageNote = parent == null ? "" : ` with parent ${parent}`;
840
+ log(`claim: revealing ${commitTx.contentHash}${lineageNote}...\n`);
841
+ const revealUri = opts.uri == null ? "" : String(opts.uri);
842
+ const revealSent =
843
+ parent == null
844
+ ? await contract.reveal(commitTx.contentHash, commitTx.salt, revealUri)
845
+ : await contract.revealWithParent(commitTx.contentHash, commitTx.salt, revealUri, parent);
846
+ log(` reveal tx: ${revealSent.hash}\n`);
847
+ const revealReceipt = await revealSent.wait();
848
+
849
+ const revealed = _parseRevealed(revealReceipt, ethersLib);
850
+ if (revealed) {
851
+ log(
852
+ ` Claimed (authorBound) at index ${revealed.index} by ${revealed.contributor} ` +
853
+ `in tx ${revealReceipt.hash}\n`
854
+ );
855
+ }
856
+
857
+ return {
858
+ dryRun: false,
859
+ chainId,
860
+ commitTx,
861
+ commitTxHash: commitReceipt.hash,
862
+ revealTxHash: revealReceipt.hash,
863
+ revealed,
864
+ receiptPath, // undefined when no receipt file was written (the default, safe behaviour)
865
+ receipt, // the validated receipt object, always returned in-memory for the caller to persist
866
+ };
867
+ }
868
+
869
+ module.exports = {
870
+ contentHashForPath,
871
+ newSalt,
872
+ computeCommitment,
873
+ buildCommitTx,
874
+ buildRevealTx,
875
+ formatDryRun,
876
+ resolveReceiptPath,
877
+ runClaim,
878
+ runCommit,
879
+ runReveal,
880
+ ABI,
881
+ };