@kirkelabs/walletless-kit 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/draw.js CHANGED
@@ -17,7 +17,7 @@
17
17
  */
18
18
 
19
19
  import { createHash } from 'node:crypto';
20
- import { merkleRoot } from './audit.js';
20
+ import { merkleRoot, merkleProof, verifyMerkleProof, leafHash } from './merkle.js';
21
21
 
22
22
  const MAX_ENTRIES = 5_000_000;
23
23
  const ALGORITHM = 'fisher-yates-sha256-v1';
@@ -149,6 +149,43 @@ export function verifyDraw(proof, entries) {
149
149
  }
150
150
  }
151
151
 
152
+ // ─── Entrant inclusion proofs ────────────────────────────────────────────────
153
+ // A draw proof commits to the exact entry set via `entriesRoot`. These let a
154
+ // single entrant verify "my reference was in the set that produced the winner",
155
+ // against the published root alone — WITHOUT being handed every other entrant's
156
+ // reference (which would leak the field). Answers "were my tickets counted?".
157
+
158
+ /**
159
+ * Inclusion proof that `entries[index]` is committed by `entriesRoot`.
160
+ * Hand the returned object (plus the public `entriesRoot`) to the entrant.
161
+ * @returns {{entry:any, index:number, leaf:string, path:{side:string,hash:string}[]}}
162
+ */
163
+ export function entryProof(entries, index) {
164
+ if (!Array.isArray(entries) || index < 0 || index >= entries.length)
165
+ throw new Error('entryProof: index out of range');
166
+ const { leaf, path } = merkleProof(entries, index);
167
+ return { entry: entries[index], index, leaf, path };
168
+ }
169
+
170
+ /**
171
+ * Verify an entrant inclusion proof against the published `entriesRoot`. Checks
172
+ * BOTH that the proof recomputes the root AND that its leaf is the hash of the
173
+ * claimed `entry` (so the proof can't be replayed for a different value).
174
+ * Robust to malformed input (never throws).
175
+ * @returns {{ok:boolean, reason?:string}}
176
+ */
177
+ export function verifyEntryProof(args) {
178
+ try {
179
+ const { entry, leaf, path, entriesRoot } = args || {};
180
+ const expected = leafHash(entry).toString('hex');
181
+ if (String(leaf) !== expected) return { ok: false, reason: 'leaf_entry_mismatch' };
182
+ const r = verifyMerkleProof({ leaf, path, root: entriesRoot });
183
+ return r.ok ? { ok: true } : { ok: false, reason: r.reason ?? 'root_mismatch' };
184
+ } catch (e) {
185
+ return { ok: false, reason: `verify_error:${e.message}` };
186
+ }
187
+ }
188
+
152
189
  // ─── Seed adapters ──────────────────────────────────────────────────────────
153
190
  // Each returns { source, value, ...context }. Document manipulability honestly.
154
191
 
@@ -195,3 +232,111 @@ export function beaconSeed({ value, round, beacon = 'drand' }) {
195
232
  if (!value) throw new Error('beaconSeed: value is required');
196
233
  return { source: 'beacon', beacon, round: round == null ? null : Number(round), value: String(value) };
197
234
  }
235
+
236
+ // ─── drand: non-manipulable public randomness ────────────────────────────────
237
+ // A drand beacon (the League of Entropy "quicknet" network) emits a fresh random
238
+ // value every `period` seconds. Each value is a BLS THRESHOLD signature: no single
239
+ // operator — including you — can predict, withhold, or grind it. Committing to a
240
+ // FUTURE drand round before entries close therefore gives a seed that is both
241
+ // unpredictable in advance and independently checkable after the fact, closing the
242
+ // "a block producer could bias the block hash" hole in `blockHashSeed`.
243
+ //
244
+ // The publicly recomputable binding is `randomness == SHA256(signature)`; this
245
+ // adapter enforces it with `node:crypto` (zero deps). Full BLS verification — that
246
+ // the signature is a valid threshold signature over the round under the network's
247
+ // public key — is stronger still; pass an async `verifySignature({round,signature,
248
+ // previous})` (e.g. backed by @noble/curves bls12_381) to enforce it too.
249
+
250
+ /** drand quicknet defaults (League of Entropy). Override for other chains. */
251
+ export const DRAND_QUICKNET = {
252
+ chainHash: '52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971',
253
+ genesisTime: 1692803367,
254
+ period: 3,
255
+ url: 'https://api.drand.sh',
256
+ };
257
+
258
+ function sha256hex(hexInput) {
259
+ return sha256(Buffer.from(String(hexInput), 'hex')).toString('hex');
260
+ }
261
+
262
+ /**
263
+ * The drand round whose randomness will first be available at-or-after `unixTime`.
264
+ * Use this to COMMIT a future round before entries close (publish it via
265
+ * `commitSeedSource({ source:'drand', round })`), so the seed is fixed in advance.
266
+ * @param {number} unixTime seconds since epoch (e.g. your entry-close time)
267
+ * @param {{genesisTime?:number, period?:number}} [chain] defaults to quicknet
268
+ */
269
+ export function drandRoundAt(unixTime, chain = DRAND_QUICKNET) {
270
+ const t = Number(unixTime);
271
+ const { genesisTime, period } = { ...DRAND_QUICKNET, ...chain };
272
+ if (!Number.isFinite(t) || t < genesisTime) throw new Error('drandRoundAt: unixTime before genesis');
273
+ return Math.floor((t - genesisTime) / period) + 1;
274
+ }
275
+
276
+ /**
277
+ * Build a seed from a drand beacon value, verifying the public binding
278
+ * `randomness == SHA256(signature)`. Pass the `{round, randomness, signature}`
279
+ * you fetched from a drand HTTP endpoint (see `fetchDrandRound`).
280
+ * @param {object} beacon
281
+ * @param {number} beacon.round
282
+ * @param {string} beacon.randomness hex
283
+ * @param {string} beacon.signature hex (BLS signature)
284
+ * @param {string} [beacon.chainHash]
285
+ * @param {(b:object)=>Promise<boolean>|boolean} [verifySignature] optional BLS check
286
+ * @returns {Promise<{source:string, value:string, round:number, signature:string, chainHash:string|null, manipulable:false, blsVerified:boolean}>}
287
+ */
288
+ export async function drandSeed(
289
+ { round, randomness, signature, previousSignature = null, chainHash = null },
290
+ verifySignature,
291
+ ) {
292
+ if (round == null || !Number.isInteger(Number(round)))
293
+ throw new Error('drandSeed: round is required');
294
+ if (!/^[0-9a-f]+$/i.test(String(signature ?? '')))
295
+ throw new Error('drandSeed: signature (hex) is required');
296
+ if (!/^[0-9a-f]{64}$/i.test(String(randomness ?? '')))
297
+ throw new Error('drandSeed: randomness (32-byte hex) is required');
298
+ if (sha256hex(signature) !== String(randomness).toLowerCase())
299
+ throw new Error('drandSeed: randomness does not match SHA256(signature) — beacon value rejected');
300
+ let blsVerified = false;
301
+ if (typeof verifySignature === 'function') {
302
+ blsVerified = !!(await verifySignature({
303
+ round: Number(round),
304
+ randomness,
305
+ signature,
306
+ previousSignature,
307
+ chainHash,
308
+ }));
309
+ if (!blsVerified) throw new Error('drandSeed: BLS signature verification failed');
310
+ }
311
+ return {
312
+ source: 'drand',
313
+ value: String(randomness).toLowerCase(),
314
+ round: Number(round),
315
+ signature: String(signature),
316
+ chainHash: chainHash == null ? null : String(chainHash),
317
+ manipulable: false,
318
+ blsVerified,
319
+ };
320
+ }
321
+
322
+ /**
323
+ * Fetch a drand round over HTTP. `fetch` is injectable (defaults to global fetch)
324
+ * so this is testable offline and SSRF-scoped to the drand host you pass.
325
+ * @param {number} round
326
+ * @param {{url?:string, chainHash?:string, fetch?:Function}} [opts]
327
+ */
328
+ export async function fetchDrandRound(round, opts = {}) {
329
+ const { url, chainHash, fetch: f = globalThis.fetch } = { ...DRAND_QUICKNET, ...opts };
330
+ if (typeof f !== 'function') throw new Error('fetchDrandRound: no fetch available');
331
+ const endpoint = `${url}/${chainHash}/public/${Number(round)}`;
332
+ const res = await f(endpoint);
333
+ if (!res.ok) throw new Error(`fetchDrandRound: HTTP ${res.status}`);
334
+ const body = await res.json();
335
+ return {
336
+ round: Number(body.round),
337
+ randomness: body.randomness,
338
+ signature: body.signature,
339
+ previousSignature: body.previous_signature ?? null, // present on chained beacons
340
+ chainHash,
341
+ };
342
+ }
package/src/index.js CHANGED
@@ -27,12 +27,31 @@ export {
27
27
  merkleRoot,
28
28
  merkleProof,
29
29
  verifyMerkleProof,
30
+ consistencyProof,
31
+ verifyConsistencyProof,
32
+ trailConsistencyProof,
33
+ verifyTrailConsistency,
30
34
  trailHash,
31
35
  trailRoot,
32
36
  verifyTrail,
33
37
  anchor,
34
38
  } from './audit.js';
35
39
 
40
+ // Zero-dependency canonical JSON (the hashing primitive the whole kit shares).
41
+ export { canonicalJson } from './merkle.js';
42
+
43
+ // Independent, dependency-free verifier + portable proof bundle. `verifyDrawProof`
44
+ // re-derives a draw without trusting draw.js; `verifyBundle` checks a whole
45
+ // artifact. See SPEC.md and test/vectors.json (the conformance suite).
46
+ export { bundleProof, verifyBundle, verifyDrawProof, BUNDLE_VERSION } from './verify.js';
47
+
48
+ // NOTE: real BLS verification of drand beacons (`makeDrandVerifier`,
49
+ // `verifyDrandBeacon`, `DRAND_QUICKNET_SCHEME`, …) is intentionally NOT re-exported
50
+ // here. It needs the OPTIONAL peer dependency `@noble/curves`, so it lives only at
51
+ // the `@kirkelabs/walletless-kit/drand-bls` subpath — keeping this core entrypoint
52
+ // (and the zero-dependency verifier) free of pairing-crypto. Import it explicitly:
53
+ // import { makeDrandVerifier } from '@kirkelabs/walletless-kit/drand-bls';
54
+
36
55
  export {
37
56
  createEphemeralAccount,
38
57
  isExpired,
@@ -57,8 +76,14 @@ export {
57
76
  runDraw,
58
77
  publishDrawProof,
59
78
  verifyDraw,
79
+ entryProof,
80
+ verifyEntryProof,
60
81
  commitSeedSource,
61
82
  blockHashSeed,
62
83
  vrfSeed,
63
84
  beaconSeed,
85
+ drandSeed,
86
+ drandRoundAt,
87
+ fetchDrandRound,
88
+ DRAND_QUICKNET,
64
89
  } from './draw.js';
package/src/ledger.js CHANGED
@@ -104,6 +104,53 @@ export class Ledger {
104
104
  winnerProofLink,
105
105
  };
106
106
  }
107
+
108
+ /**
109
+ * Conservation invariant: you can never allocate (to charity + escrow) plus
110
+ * deduct in fees MORE than actually came in. A pure, deterministic check over
111
+ * the three books — the kind of statement an auditor or a charity board can
112
+ * recompute. `retainedMicro` is what's left for the operator after allocations.
113
+ *
114
+ * `fee` entries in ANY book are treated as deductions from inflow; `charity`
115
+ * and `escrow` book balances are allocations. Returns the breakdown plus a
116
+ * boolean `balanced` (allocations + fees ≤ inflow) and the residual.
117
+ * @returns {{balanced:boolean, inflowMicro:number, feesMicro:number, charityMicro:number, escrowMicro:number, retainedMicro:number}}
118
+ */
119
+ conservation() {
120
+ const sum = (arr) => arr.reduce((s, e) => s + e.amountMicro, 0);
121
+ const inflowMicro = this.balance('inflow');
122
+ const feesMicro = sum(
123
+ [...this._books.inflow, ...this._books.charity, ...this._books.escrow].filter(
124
+ (e) => e.kind === 'fee',
125
+ ),
126
+ );
127
+ const charityMicro = this.balance('charity');
128
+ const escrowMicro = this.balance('escrow');
129
+ const retainedMicro = inflowMicro - feesMicro - charityMicro - escrowMicro;
130
+ return {
131
+ balanced: retainedMicro >= 0,
132
+ inflowMicro,
133
+ feesMicro,
134
+ charityMicro,
135
+ escrowMicro,
136
+ retainedMicro,
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Throw unless the conservation invariant holds (allocations + fees ≤ inflow).
142
+ * Call after posting allocations to fail closed on an over-allocation bug
143
+ * before it reaches a reconciliation sheet or an on-chain payout.
144
+ * @returns {Ledger} this (chainable)
145
+ */
146
+ assertConservation() {
147
+ const c = this.conservation();
148
+ if (!c.balanced)
149
+ throw new Error(
150
+ `ledger: conservation violated — allocated/fees (${c.feesMicro + c.charityMicro + c.escrowMicro}) exceed inflow (${c.inflowMicro}) by ${-c.retainedMicro} microALGO`,
151
+ );
152
+ return this;
153
+ }
107
154
  }
108
155
 
109
156
  /** Convenience factory. */
package/src/merkle.js ADDED
@@ -0,0 +1,250 @@
1
+ /**
2
+ * merkle.js — the zero-dependency cryptographic core of the kit.
3
+ *
4
+ * This module deliberately depends on NOTHING but `node:crypto`. It is the shared
5
+ * spine for both the proof-PRODUCING side (`audit.js`, `draw.js`) and the
6
+ * independent proof-VERIFYING side (`verify.js`). Keeping it dependency-free is
7
+ * what lets the verifier run anywhere — a browser, an air-gapped box, a second
8
+ * implementation — without trusting algosdk, oaa-core, or even this package.
9
+ *
10
+ * It provides:
11
+ * - `canonicalJson` — deterministic JSON (sorted keys), byte-identical to
12
+ * @kirkelabs/open-agent-access-core's canonicalizeJson
13
+ * (cross-checked by test), so roots match either path.
14
+ * - `merkleRoot` / proofs — RFC 6962 domain-separated tree (leaf 0x00, node 0x01)
15
+ * with lone-node promotion (no CVE-2012-2459).
16
+ * - `consistencyProof` — RFC 6962 §2.1.2 proof that an append-only log only
17
+ * GREW between two sizes and was never rewritten.
18
+ *
19
+ * Every function here is pure and deterministic.
20
+ */
21
+
22
+ import { createHash } from 'node:crypto';
23
+
24
+ const MAX_LEAVES = 1_000_000; // DoS bound (mirrors audit.js)
25
+ export const LEAF = Buffer.from([0x00]);
26
+ export const NODE = Buffer.from([0x01]);
27
+
28
+ export function sha256(...bufs) {
29
+ const h = createHash('sha256');
30
+ for (const b of bufs) h.update(b);
31
+ return h.digest();
32
+ }
33
+
34
+ /**
35
+ * Deterministic JSON: object keys sorted recursively, `undefined` dropped, no
36
+ * insignificant whitespace. Byte-identical to oaa-core's canonicalizeJson for all
37
+ * JSON values the kit hashes (guarded by `test/canonical.test.js`). A primitive
38
+ * (string/number/boolean/null) round-trips through standard JSON.
39
+ */
40
+ export function canonicalJson(value) {
41
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
42
+ if (Array.isArray(value))
43
+ return '[' + value.map((v) => canonicalJson(v === undefined ? null : v)).join(',') + ']';
44
+ const keys = Object.keys(value)
45
+ .filter((k) => value[k] !== undefined)
46
+ .sort();
47
+ return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalJson(value[k])).join(',') + '}';
48
+ }
49
+
50
+ /** Injective leaf encoding: domain-tagged hash of the item's canonical JSON. */
51
+ export function leafHash(item) {
52
+ return sha256(LEAF, Buffer.from(canonicalJson(item), 'utf8'));
53
+ }
54
+
55
+ /** Internal node hash: H(0x01 ‖ left ‖ right). */
56
+ export function nodeHash(left, right) {
57
+ return sha256(NODE, left, right);
58
+ }
59
+
60
+ /**
61
+ * RFC 6962 Merkle Tree Hash over an array of leaf buffers. Bottom-up pairing with
62
+ * lone-node PROMOTION (the odd node is carried up unchanged, never duplicated).
63
+ * This equals the recursive RFC 6962 split-at-largest-power-of-two definition
64
+ * (verified for n = 0..40 in test), so consistency proofs below are valid.
65
+ */
66
+ function mthFromLeaves(leaves) {
67
+ if (leaves.length === 0) return sha256(Buffer.alloc(0));
68
+ let level = leaves;
69
+ while (level.length > 1) {
70
+ const next = [];
71
+ for (let i = 0; i < level.length; i += 2) {
72
+ next.push(i + 1 < level.length ? nodeHash(level[i], level[i + 1]) : level[i]);
73
+ }
74
+ level = next;
75
+ }
76
+ return level[0];
77
+ }
78
+
79
+ /**
80
+ * Deterministic Merkle root (hex) over an ordered list of items.
81
+ * Empty list → H("") (RFC 6962 empty-tree root). Single item → its leaf hash.
82
+ */
83
+ export function merkleRoot(items) {
84
+ if (!Array.isArray(items)) throw new Error('merkleRoot: items must be an array');
85
+ if (items.length > MAX_LEAVES) throw new Error('merkleRoot: too many leaves');
86
+ return mthFromLeaves(items.map(leafHash)).toString('hex');
87
+ }
88
+
89
+ /**
90
+ * Inclusion proof for the item at `index`: sibling hashes (hex) + their side,
91
+ * which `verifyMerkleProof` replays to recompute the root.
92
+ */
93
+ export function merkleProof(items, index) {
94
+ if (!Array.isArray(items) || index < 0 || index >= items.length)
95
+ throw new Error('merkleProof: index out of range');
96
+ let level = items.map(leafHash);
97
+ let idx = index;
98
+ const path = [];
99
+ while (level.length > 1) {
100
+ const next = [];
101
+ for (let i = 0; i < level.length; i += 2) {
102
+ if (i + 1 < level.length) {
103
+ next.push(nodeHash(level[i], level[i + 1]));
104
+ if (i === idx) path.push({ side: 'right', hash: level[i + 1].toString('hex') });
105
+ else if (i + 1 === idx) path.push({ side: 'left', hash: level[i].toString('hex') });
106
+ } else {
107
+ next.push(level[i]); // promoted; no sibling recorded for the lone node
108
+ }
109
+ }
110
+ idx = Math.floor(idx / 2);
111
+ level = next;
112
+ }
113
+ return { index, leaf: leafHash(items[index]).toString('hex'), path };
114
+ }
115
+
116
+ /** Verify an inclusion proof against an expected root. Never throws. */
117
+ export function verifyMerkleProof({ leaf, path, root }) {
118
+ try {
119
+ let acc = Buffer.from(String(leaf), 'hex');
120
+ for (const step of path || []) {
121
+ const sib = Buffer.from(String(step.hash), 'hex');
122
+ acc = step.side === 'left' ? nodeHash(sib, acc) : nodeHash(acc, sib);
123
+ }
124
+ return { ok: acc.toString('hex') === String(root) };
125
+ } catch (e) {
126
+ return { ok: false, reason: `verify_error:${e.message}` };
127
+ }
128
+ }
129
+
130
+ // ─── RFC 6962 consistency proofs ─────────────────────────────────────────────
131
+ // A consistency proof shows that the tree of size `m` is a PREFIX of the tree of
132
+ // size `n` (m ≤ n): every leaf of the old tree is still present, unchanged, in the
133
+ // same position. This is the Certificate-Transparency primitive that turns
134
+ // "tamper-evident snapshot" into "provably append-only history": anyone holding an
135
+ // old anchored root can prove the operator only appended and never rewrote it.
136
+
137
+ /** MTH over a slice of leaf buffers (RFC 6962 recursive split). */
138
+ function mthRange(leaves, start, end) {
139
+ return mthFromLeaves(leaves.slice(start, end));
140
+ }
141
+
142
+ /** Largest power of two strictly less than n (the RFC 6962 split point). */
143
+ function splitPoint(n) {
144
+ let k = 1;
145
+ while (k * 2 < n) k *= 2;
146
+ return k;
147
+ }
148
+
149
+ /** SUBPROOF(m, leaves[start:end], b) per RFC 6962 §2.1.2. */
150
+ function subproof(m, leaves, start, end, b) {
151
+ const n = end - start;
152
+ if (m === n) {
153
+ // The subtree is fully contained in the old tree.
154
+ return b ? [] : [mthRange(leaves, start, end)];
155
+ }
156
+ const k = splitPoint(n);
157
+ if (m <= k) {
158
+ const proof = subproof(m, leaves, start, start + k, b);
159
+ proof.push(mthRange(leaves, start + k, end));
160
+ return proof;
161
+ }
162
+ const proof = subproof(m - k, leaves, start + k, end, false);
163
+ proof.push(mthRange(leaves, start, start + k));
164
+ return proof;
165
+ }
166
+
167
+ /**
168
+ * Build a consistency proof that the first `oldSize` items (the old tree) are a
169
+ * prefix of the full `items` (the new tree). Returns hex node hashes.
170
+ * @param {any[]} items the FULL current ordered list (length = new size)
171
+ * @param {number} oldSize the earlier, anchored size (1 ≤ oldSize ≤ items.length)
172
+ * @returns {{oldSize:number,newSize:number,proof:string[]}}
173
+ */
174
+ export function consistencyProof(items, oldSize) {
175
+ if (!Array.isArray(items)) throw new Error('consistencyProof: items must be an array');
176
+ const newSize = items.length;
177
+ const m = Number(oldSize);
178
+ if (!Number.isInteger(m) || m < 1 || m > newSize)
179
+ throw new Error('consistencyProof: oldSize must be an integer in [1, items.length]');
180
+ const leaves = items.map(leafHash);
181
+ const nodes = m === newSize ? [] : subproof(m, leaves, 0, newSize, true);
182
+ return { oldSize: m, newSize, proof: nodes.map((b) => b.toString('hex')) };
183
+ }
184
+
185
+ /**
186
+ * Verify a consistency proof: that `oldRoot` (size m) is a prefix of `newRoot`
187
+ * (size n). Pure replay of RFC 6962 §2.1.2 verification. Never throws.
188
+ * @returns {{ok:boolean, reason?:string}}
189
+ */
190
+ export function verifyConsistencyProof(args) {
191
+ try {
192
+ const { oldRoot, newRoot, oldSize, newSize, proof } = args || {};
193
+ const m = Number(oldSize);
194
+ const n = Number(newSize);
195
+ if (!Number.isInteger(m) || !Number.isInteger(n) || m < 1 || m > n)
196
+ return { ok: false, reason: 'bad_sizes' };
197
+
198
+ // m == n: identical trees — roots must match and the proof must be empty.
199
+ if (m === n) {
200
+ return String(oldRoot) === String(newRoot) && (proof || []).length === 0
201
+ ? { ok: true }
202
+ : { ok: false, reason: 'size_equal_mismatch' };
203
+ }
204
+
205
+ // Canonical RFC 6962 consistency-proof verification (certificate-transparency
206
+ // reference). Walk the node/last_node indices, consuming proof nodes in order.
207
+ const nodes = (proof || []).map((h) => Buffer.from(String(h), 'hex'));
208
+ let p = 0;
209
+ const next = () => {
210
+ if (p >= nodes.length) throw new Error('proof_exhausted');
211
+ return nodes[p++];
212
+ };
213
+
214
+ let node = m - 1;
215
+ let last = n - 1;
216
+ while (node % 2 === 1) {
217
+ node = Math.floor(node / 2);
218
+ last = Math.floor(last / 2);
219
+ }
220
+
221
+ // If `node` reduced to 0, old tree was a perfect subtree and oldRoot is the
222
+ // implicit first hash; otherwise the first proof node seeds both accumulators.
223
+ let oldHash = node ? next() : Buffer.from(String(oldRoot), 'hex');
224
+ let newHash = oldHash;
225
+
226
+ while (node) {
227
+ if (node % 2 === 1) {
228
+ const sib = next(); // right child → sibling on the left
229
+ oldHash = nodeHash(sib, oldHash);
230
+ newHash = nodeHash(sib, newHash);
231
+ } else if (node < last) {
232
+ newHash = nodeHash(newHash, next()); // left child with a right sibling
233
+ }
234
+ node = Math.floor(node / 2);
235
+ last = Math.floor(last / 2);
236
+ }
237
+ while (last) {
238
+ newHash = nodeHash(newHash, next());
239
+ last = Math.floor(last / 2);
240
+ }
241
+
242
+ if (p !== nodes.length) return { ok: false, reason: 'proof_too_long' };
243
+ const ok =
244
+ oldHash.toString('hex') === String(oldRoot) &&
245
+ newHash.toString('hex') === String(newRoot);
246
+ return ok ? { ok: true } : { ok: false, reason: 'root_mismatch' };
247
+ } catch (e) {
248
+ return { ok: false, reason: `verify_error:${e.message}` };
249
+ }
250
+ }