@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/CHANGELOG.md CHANGED
@@ -3,6 +3,50 @@
3
3
  All notable changes are documented here. Format: [Keep a Changelog](https://keepachangelog.com/);
4
4
  versioning: [SemVer](https://semver.org/).
5
5
 
6
+ ## [0.2.0] — 2026-06-20
7
+
8
+ Verifiable-trust release: make every result independently checkable against a
9
+ published spec, and close the draw-fairness gap with real drand verification.
10
+ Backwards-compatible — all 0.1.0 APIs are unchanged; everything below is additive.
11
+
12
+ ### Added
13
+
14
+ - **Independent, zero-dependency verifier** (`@kirkelabs/walletless-kit/verify`) —
15
+ `verifyDrawProof`, `verifyBundle`, `bundleProof`. Re-derives a draw / receipt
16
+ chain / trail from scratch with only `node:crypto`, so a result can be checked
17
+ without trusting the producing code (runs in a browser / offline). `bundleProof`
18
+ packs a draw + entries + seed source + receipts + trail into one portable,
19
+ self-verifying artifact (`walletless-proof/v1`).
20
+ - **Proof format spec** (`SPEC.md`) + frozen conformance vectors
21
+ (`test/vectors.json`) — a second implementation that reproduces them interoperates.
22
+ - **RFC 6962 consistency proofs** (`consistencyProof`, `verifyConsistencyProof`,
23
+ `trailConsistencyProof`, `verifyTrailConsistency`) — prove an audit log only ever
24
+ grew between two anchored roots (provably append-only history).
25
+ - **Entrant inclusion proofs** for draws (`entryProof`, `verifyEntryProof`) — a
26
+ participant verifies "my entry was counted" against the published `entriesRoot`
27
+ without seeing the rest of the field.
28
+ - **Non-manipulable drand randomness** (`drandSeed`, `drandRoundAt`,
29
+ `fetchDrandRound`) — commit a future drand round before entries close; enforces
30
+ the `randomness == SHA256(signature)` binding (SHA-256 only).
31
+ - **Real BLS verification of drand beacons** (`@kirkelabs/walletless-kit/drand-bls`:
32
+ `makeDrandVerifier`, `verifyDrandBeacon`, `DRAND_QUICKNET_SCHEME`,
33
+ `DRAND_DEFAULT_SCHEME`) — proves a seed is a genuine League-of-Entropy threshold
34
+ signature (quicknet + legacy chained). Cross-checked against live beacon rounds
35
+ frozen in `test/drand-vectors.json`. Backed by `@noble/curves` as an **optional
36
+ peer dependency**, exposed only at the subpath, so the core entrypoint and the
37
+ verifier stay dependency-free.
38
+ - **Ledger conservation invariants** (`Ledger.conservation`,
39
+ `Ledger.assertConservation`) — allocations + fees can never exceed inflow.
40
+ - **`canonicalJson`** exported — the deterministic JSON primitive shared by the kit.
41
+
42
+ ### Changed
43
+
44
+ - `audit.js` now sources its Merkle math from a new zero-dependency `src/merkle.js`
45
+ core; the Merkle roots are byte-for-byte identical (frozen vectors unchanged).
46
+ - `fetchDrandRound` / `drandSeed` now carry `previousSignature` (needed for chained
47
+ beacon verification).
48
+ - Test suite expanded from 47 to 85 passing; lint and `npm audit` clean.
49
+
6
50
  ## [0.1.0] — 2026-06-20
7
51
 
8
52
  - Initial release. A walletless web-architecture toolkit built on
package/README.md CHANGED
@@ -22,11 +22,15 @@ Built on [`@kirkelabs/open-agent-access-core`](https://www.npmjs.com/package/@ki
22
22
  | **onboarding** | `createEphemeralAccount` / `rotateAccount` / `expireAccount` / `isExpired` — tightly-scoped, **round-relative auto-expiring** custodial accounts; authority bounded by an oaa-agent-kit mandate. |
23
23
  | **identity** | `OtpIdentity` — email/SMS OTP: CSPRNG codes, single-use, expiring, rate-limited, lockout, constant-time compare; stores only **keyed (peppered) pseudonymous** contact refs. |
24
24
  | **receipt** | `buildOrderReceipt` / `deterministicOrderId` / `signReceipt` / `verifyReceiptChain` / `attestOnChain` — hash-chained, signed, **non-PII** receipts; only the receipt *hash* goes on-chain. x402 actions via `chargeForAction`. |
25
- | **audit** | `createTrail` / `append` / `merkleRoot` / `merkleProof` / `verifyMerkleProof` / `anchor` / `verifyTrail` — append-only hash-chained events + an **RFC 6962** Merkle root, periodically anchored on-chain. |
26
- | **ledger** | `createLedger` — three segregated append-only books (inflow / charity / escrow), **integer-only money**, immutable snapshots, and a per-draw `reconciliationSheet`. |
27
- | **draw** | `runDraw` / `publishDrawProof` / `verifyDraw` + `commitSeedSource` / `blockHashSeed` / `vrfSeed` / `beaconSeed` — deterministic, recomputable winner selection (no `Math.random`). |
25
+ | **audit** | `createTrail` / `append` / `merkleRoot` / `merkleProof` / `verifyMerkleProof` / `anchor` / `verifyTrail` + `consistencyProof` / `verifyConsistencyProof` / `trailConsistencyProof` — append-only hash-chained events + an **RFC 6962** Merkle root and **consistency proofs** (provably append-only between two anchors), periodically anchored on-chain. |
26
+ | **ledger** | `createLedger` — three segregated append-only books (inflow / charity / escrow), **integer-only money**, immutable snapshots, a per-draw `reconciliationSheet`, and `conservation()` / `assertConservation()` invariants (allocations + fees can never exceed inflow). |
27
+ | **draw** | `runDraw` / `publishDrawProof` / `verifyDraw` + `entryProof` / `verifyEntryProof` (entrant "was my ticket counted?" proofs) + `commitSeedSource` / `blockHashSeed` / `vrfSeed` / `beaconSeed` / `drandSeed` / `drandRoundAt` / `fetchDrandRound` — deterministic, recomputable winner selection (no `Math.random`) with **non-manipulable drand** randomness. |
28
+ | **verify** | `bundleProof` / `verifyBundle` / `verifyDrawProof` — a **zero-dependency** verifier (runs in a browser / offline) and a portable, self-verifying **proof bundle**. Don't trust the producer — recompute it. |
29
+ | **drand-bls** | `makeDrandVerifier` / `verifyDrandBeacon` (`@kirkelabs/walletless-kit/drand-bls`) — **real BLS12-381 verification** that a drand seed is a genuine League-of-Entropy threshold signature, not just well-formed. **Opt-in:** install the optional peer dep (`npm i @noble/curves`); not loaded by the core entrypoint, so the rest of the kit installs no pairing crypto. |
28
30
  | **privacy** | `hashPii` / `pseudonymRef` / `eraseSubject` / `assertNoPii` — keyed hashing and random, **erasable** references; PII stays off-chain. |
29
31
 
32
+ > **Proof format spec:** the on-the-wire formats are specified in **[SPEC.md](./SPEC.md)** (`walletless-proof/v1`) with frozen conformance vectors in [`test/vectors.json`](./test/vectors.json) — anything that reproduces them interoperates and can verify a draw, trail, or receipt chain independently of this package.
33
+
30
34
  ## Quickstart
31
35
 
32
36
  ```js
package/SPEC.md ADDED
@@ -0,0 +1,189 @@
1
+ # walletless-kit proof formats — `SPEC.md`
2
+
3
+ **Status:** v1 · **Scope:** the on-the-wire formats a *second implementation* must
4
+ reproduce to interoperate with `@kirkelabs/walletless-kit`. This is the contract
5
+ behind the project's core claim — *anyone can recompute the result without trusting
6
+ the software that produced it.* The frozen conformance vectors live in
7
+ [`test/vectors.json`](./test/vectors.json); a conforming implementation MUST
8
+ reproduce every hash, root, and proof there byte-for-byte.
9
+
10
+ Everything in this spec is computable with only SHA-256 and canonical JSON — no
11
+ blockchain, no network, no dependency on this package. The reference verifier
12
+ ([`src/verify.js`](./src/verify.js)) depends only on
13
+ [`src/merkle.js`](./src/merkle.js), which depends only on `node:crypto`.
14
+
15
+ The key words MUST / MUST NOT / SHOULD are used per RFC 2119.
16
+
17
+ ---
18
+
19
+ ## 1. Canonical JSON
20
+
21
+ All hashing is over **canonical JSON**:
22
+
23
+ - Objects: keys sorted by Unicode code point (ascending), no insignificant
24
+ whitespace, `undefined`-valued keys omitted.
25
+ - Arrays: element order preserved.
26
+ - Primitives: standard JSON encoding (`JSON.stringify` semantics); `null` kept.
27
+
28
+ This is byte-identical to `@kirkelabs/open-agent-access-core`'s `canonicalizeJson`
29
+ (cross-checked in `test/canonical.test.js`), so roots match whichever side produced
30
+ them. Reference: [`canonicalJson`](./src/merkle.js).
31
+
32
+ ## 2. Merkle tree (RFC 6962)
33
+
34
+ Domain-separated, to prevent a leaf being reinterpreted as an internal node
35
+ (second-preimage forgery):
36
+
37
+ ```
38
+ leaf(d) = SHA256( 0x00 ‖ canonicalJSON(d) )
39
+ node(l, r) = SHA256( 0x01 ‖ l ‖ r )
40
+ MTH({}) = SHA256("") # empty tree
41
+ MTH({d0}) = leaf(d0)
42
+ MTH(D[0:n]), n>1:
43
+ k = largest power of two strictly less than n
44
+ MTH = node( MTH(D[0:k]), MTH(D[k:n]) )
45
+ ```
46
+
47
+ A lone (odd) node is **promoted** unchanged to the next level — never duplicated
48
+ (duplicating it is the Bitcoin CVE-2012-2459 ambiguity). The iterative
49
+ "pair-adjacent, promote-odd" construction in this kit equals the recursive
50
+ definition above for all `n` (verified `n = 0..40`).
51
+
52
+ - **Root:** lowercase hex of `MTH`.
53
+ - **Inclusion proof:** `{ index, leaf, path[] }`, each `path` step
54
+ `{ side: "left"|"right", hash }`. Verify by folding siblings into `leaf` and
55
+ comparing to the root. Reference: [`merkleProof` / `verifyMerkleProof`](./src/merkle.js).
56
+
57
+ ## 3. Consistency proof (RFC 6962 §2.1.2)
58
+
59
+ Proves the tree of size `m` is a **prefix** of the tree of size `n` (`m ≤ n`): the
60
+ log only grew and was never rewritten. Format:
61
+
62
+ ```
63
+ { oldSize: m, newSize: n, proof: [ hex, … ] }
64
+ ```
65
+
66
+ Verification (against `oldRoot`, `newRoot`) follows the certificate-transparency
67
+ reference algorithm; see [`verifyConsistencyProof`](./src/merkle.js). Pair with the
68
+ on-chain anchors (§7): anchor `oldRoot` at size `m`, later anchor `newRoot` at size
69
+ `n`, and the `m → n` transition is provably append-only.
70
+
71
+ ## 4. Draw proof
72
+
73
+ Winner selection is a deterministic function of `(seed, entryCount)` — no
74
+ `Math.random`. Algorithm id `fisher-yates-sha256-v1`:
75
+
76
+ ```
77
+ rng stream: block_i = SHA256( seed ‖ ":" ‖ uint64LE(i) ), i = 0,1,2,…
78
+ uniformInt(maxExclusive): # unbiased, rejection-sampled
79
+ span = 2^48
80
+ limit = floor(span / maxExclusive) * maxExclusive
81
+ draw 6 bytes big-endian as v; reject while v ≥ limit; return v mod maxExclusive
82
+ shuffle: for i = n-1 … 1: j = uniformInt(i+1); swap(a[i], a[j])
83
+ winners: first k of shuffle([0..n-1]); map indices → entries
84
+ ```
85
+
86
+ Proof object:
87
+
88
+ ```
89
+ { algorithm, seed, entryCount, entriesRoot, winners[], winnerIndices[] }
90
+ ```
91
+
92
+ `entriesRoot` is the Merkle root (§2) over the **exact ordered entry set**.
93
+ Verify by (a) recomputing `entriesRoot` from the entries, (b) re-running the
94
+ shuffle, (c) comparing indices and mapped winners. Reference:
95
+ [`verifyDrawProof`](./src/verify.js) (independent of the producer code).
96
+
97
+ ### 4.1 Entrant inclusion proof
98
+
99
+ A single entrant proves membership against `entriesRoot` alone, without seeing the
100
+ rest of the field: `{ entry, index, leaf, path }`. Verify that `leaf == leaf(entry)`
101
+ **and** the path recomputes `entriesRoot`. Reference: [`verifyEntryProof`](./src/draw.js).
102
+
103
+ ## 5. Seed sources
104
+
105
+ A seed is `{ source, value, … }`. Fairness equals the seed — no more. Sources:
106
+
107
+ | `source` | Manipulable? | Notes |
108
+ |-----------------------|--------------|-------|
109
+ | `algorand-block-seed` | **yes** (`manipulable:true`) | A proposer can withhold/grind. Only with `commitSeedSource` + low value. |
110
+ | `drand` | no | BLS threshold beacon. MUST satisfy `randomness == SHA256(signature)`; optionally full BLS-verified. |
111
+ | `vrf` | depends | Caller supplies a verified VRF value+proof. |
112
+ | `beacon` | depends | Generic public beacon wrapper. |
113
+
114
+ **drand binding (verifiable with SHA-256 only):** `randomness = SHA256(signature)`,
115
+ both lowercase hex; `signature` is the BLS signature bytes. A future round committed
116
+ **before entries close** (via `commitSeedSource` / `drandRoundAt`) is unpredictable
117
+ and non-grindable. Reference: [`drandSeed`](./src/draw.js).
118
+
119
+ **drand BLS verification (full provenance):** that the signature is a valid League-
120
+ of-Entropy threshold signature — i.e. the randomness really came from the network —
121
+ is checked with BLS12-381 pairings. Message hashing per scheme:
122
+
123
+ | `schemeID` | sig group | pubkey group | message digest | hash-to-curve DST |
124
+ |---|---|---|---|---|
125
+ | `bls-unchained-g1-rfc9380` (quicknet) | G1 (48 B) | G2 (96 B) | `SHA256(round_be8)` | `BLS_SIG_BLS12381G1_XMD:SHA-256_SSWU_RO_NUL_` |
126
+ | `pedersen-bls-chained` (legacy mainnet) | G2 (96 B) | G1 (48 B) | `SHA256(previous_signature ‖ round_be8)` | `BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_` |
127
+
128
+ `round_be8` is the round as an unsigned 64-bit big-endian integer. This check is
129
+ **optional** and lives in a separate module/import subpath
130
+ ([`src/drand-bls.js`](./src/drand-bls.js), `@kirkelabs/walletless-kit/drand-bls`).
131
+ It is backed by `@noble/curves`, declared as an **optional peer dependency** —
132
+ install it (`npm i @noble/curves`) only if you want BLS verification; the core
133
+ package and the §1–§4 / §8 verifier pull in no pairing crypto. Frozen real-beacon
134
+ vectors: [`test/drand-vectors.json`](./test/drand-vectors.json).
135
+
136
+ ## 6. Receipt chain
137
+
138
+ Each receipt is non-PII and hash-chained:
139
+
140
+ ```
141
+ receiptHash = SHA256( canonicalJSON( receipt without {receiptHash, signature} ) )
142
+ receipt[i].previousHash == receipt[i-1].receiptHash (null for the first)
143
+ ```
144
+
145
+ Verify by recomputing each `receiptHash` and checking the links. An optional
146
+ ed25519 `signature` over the receipt MAY be present (verified by the producing side
147
+ via oaa-core); the zero-dep verifier checks hashes and links only. Reference:
148
+ [`verifyReceiptChain`](./src/verify.js).
149
+
150
+ ## 7. On-chain anchors (optional, non-normative for verification)
151
+
152
+ Commitments are written on-chain as a 0-amount self-payment whose note is
153
+ ASCII-tagged and ≤ 1 KB — **never** any PII or receipt/event body:
154
+
155
+ ```
156
+ audit anchor: walletless-anchor:v1:<merkleRoot hex>
157
+ receipt attest: walletless-receipt:v1:<receiptHash hex>
158
+ ```
159
+
160
+ The chain is a timestamping/availability layer only; all verification in §1–§6 is
161
+ independent of it.
162
+
163
+ ## 8. Proof bundle (`walletless-proof/v1`)
164
+
165
+ A single portable artifact packing a draw + optional entries, seed source, receipt
166
+ chain, trail root, and anchors:
167
+
168
+ ```
169
+ { bundleVersion: "walletless-proof/v1",
170
+ draw, seedSource, entries|null, receipts|null,
171
+ trail: { root, count }|null, anchors|null, meta|null,
172
+ bundleHash } # SHA256(canonicalJSON(bundle without bundleHash))
173
+ ```
174
+
175
+ `verifyBundle` re-derives each present section and returns a per-section verdict.
176
+ A bundle **with** `entries` is fully self-verifying; **without** them it is a
177
+ commitment that entrants check with their own §4.1 inclusion proofs. Reference:
178
+ [`bundleProof` / `verifyBundle`](./src/verify.js).
179
+
180
+ ---
181
+
182
+ ## Conformance
183
+
184
+ An implementation conforms to **walletless-proof/v1** if, given
185
+ [`test/vectors.json`](./test/vectors.json), it reproduces: the Merkle roots (§2),
186
+ the consistency proof and its verification (§3), the draw proof and winners (§4),
187
+ the entrant inclusion proof (§4.1), and the drand `randomness = SHA256(signature)`
188
+ binding (§5). Version this document and the `bundleVersion` tag together; any change
189
+ to a hashing rule is a new major version with new frozen vectors.
package/package.json CHANGED
@@ -1,21 +1,26 @@
1
1
  {
2
2
  "name": "@kirkelabs/walletless-kit",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Walletless web architecture toolkit: low-friction onboarding (ephemeral custodial accounts), receipt-only on-chain proofs, a tamper-evident hash-chained + Merkle audit trail, segregated money ledgers, and a verifiable, recomputable draw. Charity prize-draws are the flagship example; every module is reusable for any walletless commerce flow. Built on @kirkelabs/open-agent-access-core and @kirkelabs/oaa-agent-kit. Free & open source from Kirke Labs.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "walletless": "bin/cli.js"
8
8
  },
9
9
  "exports": {
10
- ".": "./src/index.js"
10
+ ".": "./src/index.js",
11
+ "./drand-bls": "./src/drand-bls.js",
12
+ "./verify": "./src/verify.js"
11
13
  },
12
14
  "files": [
13
15
  "bin/",
14
16
  "src/",
15
17
  "examples/",
18
+ "test/vectors.json",
19
+ "test/drand-vectors.json",
16
20
  "LICENSE",
17
21
  "LEGAL.md",
18
22
  "README.md",
23
+ "SPEC.md",
19
24
  "CHANGELOG.md"
20
25
  ],
21
26
  "publishConfig": {
@@ -62,11 +67,20 @@
62
67
  "node": ">=20"
63
68
  },
64
69
  "dependencies": {
65
- "algosdk": "^3.6.0",
70
+ "@kirkelabs/oaa-agent-kit": "^0.7.1",
66
71
  "@kirkelabs/open-agent-access-core": "^0.1.0",
67
- "@kirkelabs/oaa-agent-kit": "^0.7.1"
72
+ "algosdk": "^3.6.0"
73
+ },
74
+ "peerDependencies": {
75
+ "@noble/curves": "^1.9.7"
76
+ },
77
+ "peerDependenciesMeta": {
78
+ "@noble/curves": {
79
+ "optional": true
80
+ }
68
81
  },
69
82
  "devDependencies": {
83
+ "@noble/curves": "^1.9.7",
70
84
  "eslint": "^9.0.0",
71
85
  "prettier": "^3.0.0"
72
86
  }
package/src/audit.js CHANGED
@@ -12,96 +12,41 @@
12
12
  * so it is built defensively (see `merkleRoot`).
13
13
  */
14
14
 
15
- import { createHash } from 'node:crypto';
16
15
  import algosdk from 'algosdk';
17
16
  import {
18
17
  appendAccessEvent,
19
18
  verifyAccessEventTrail,
20
19
  hashAccessEvents,
21
- canonicalizeJson,
22
20
  } from '@kirkelabs/open-agent-access-core';
21
+ // The Merkle math lives in the zero-dependency `merkle.js` core so the standalone
22
+ // verifier (`verify.js`) and any second implementation can reproduce these exact
23
+ // roots without pulling in algosdk or oaa-core.
24
+ import {
25
+ merkleRoot,
26
+ merkleProof,
27
+ verifyMerkleProof,
28
+ consistencyProof,
29
+ verifyConsistencyProof,
30
+ } from './merkle.js';
23
31
 
24
- const MAX_LEAVES = 1_000_000; // DoS bound
25
- // RFC 6962 domain-separation tags: leaves and internal nodes are hashed in
26
- // DIFFERENT domains so a leaf can never be reinterpreted as an internal node
27
- // (which would otherwise enable second-preimage forgery of the root).
28
- const LEAF = Buffer.from([0x00]);
29
- const NODE = Buffer.from([0x01]);
30
-
31
- function sha256(...bufs) {
32
- const h = createHash('sha256');
33
- for (const b of bufs) h.update(b);
34
- return h.digest();
35
- }
36
- /** Injective leaf encoding via canonical JSON, then domain-tagged hash. */
37
- function leafHash(item) {
38
- return sha256(LEAF, Buffer.from(canonicalizeJson(item), 'utf8'));
39
- }
40
-
41
- /**
42
- * Deterministic Merkle root (hex) over an ordered list of items.
43
- *
44
- * Defensive choices:
45
- * - Domain separation: leaf = H(0x00‖data), node = H(0x01‖left‖right).
46
- * - A lone (odd) node is PROMOTED to the next level unchanged — it is NOT
47
- * duplicated (duplicating the last node is the Bitcoin CVE-2012-2459
48
- * ambiguity, where two different trees share a root).
49
- * - Empty list → H("") (RFC 6962 empty-tree root). Single item → its leaf hash.
50
- */
51
- export function merkleRoot(items) {
52
- if (!Array.isArray(items)) throw new Error('merkleRoot: items must be an array');
53
- if (items.length > MAX_LEAVES) throw new Error('merkleRoot: too many leaves');
54
- if (items.length === 0) return sha256(Buffer.alloc(0)).toString('hex');
55
- let level = items.map(leafHash);
56
- while (level.length > 1) {
57
- const next = [];
58
- for (let i = 0; i < level.length; i += 2) {
59
- next.push(i + 1 < level.length ? sha256(NODE, level[i], level[i + 1]) : level[i]);
60
- }
61
- level = next;
62
- }
63
- return level[0].toString('hex');
64
- }
32
+ export { merkleRoot, merkleProof, verifyMerkleProof, consistencyProof, verifyConsistencyProof };
65
33
 
66
34
  /**
67
- * Inclusion proof for the item at `index`. Returns the sibling hashes (hex) and
68
- * their side, which `verifyMerkleProof` replays to recompute the root.
35
+ * Consistency proof that the first `oldSize` events of `trail` (an earlier,
36
+ * already-anchored state) are a verbatim prefix of the trail as it stands now —
37
+ * i.e. the operator only APPENDED and never rewrote history. This is the RFC 6962
38
+ * append-only guarantee; pair it with the on-chain anchors from `anchor()`:
39
+ * anchor the root at size m, later anchor the root at size n, and anyone can prove
40
+ * the m→n transition was append-only with `verifyTrailConsistency`.
41
+ * @returns {{oldSize:number,newSize:number,proof:string[]}}
69
42
  */
70
- export function merkleProof(items, index) {
71
- if (!Array.isArray(items) || index < 0 || index >= items.length)
72
- throw new Error('merkleProof: index out of range');
73
- let level = items.map(leafHash);
74
- let idx = index;
75
- const path = [];
76
- while (level.length > 1) {
77
- const next = [];
78
- for (let i = 0; i < level.length; i += 2) {
79
- if (i + 1 < level.length) {
80
- next.push(sha256(NODE, level[i], level[i + 1]));
81
- if (i === idx) path.push({ side: 'right', hash: level[i + 1].toString('hex') });
82
- else if (i + 1 === idx) path.push({ side: 'left', hash: level[i].toString('hex') });
83
- } else {
84
- next.push(level[i]); // promoted; no sibling recorded for the lone node
85
- }
86
- }
87
- idx = Math.floor(idx / 2);
88
- level = next;
89
- }
90
- return { index, leaf: leafHash(items[index]).toString('hex'), path };
43
+ export function trailConsistencyProof(trail, oldSize) {
44
+ return consistencyProof(trail?.events ?? [], oldSize);
91
45
  }
92
46
 
93
- /** Verify an inclusion proof against an expected root. */
94
- export function verifyMerkleProof({ leaf, path, root }) {
95
- try {
96
- let acc = Buffer.from(String(leaf), 'hex');
97
- for (const step of path || []) {
98
- const sib = Buffer.from(String(step.hash), 'hex');
99
- acc = step.side === 'left' ? sha256(NODE, sib, acc) : sha256(NODE, acc, sib);
100
- }
101
- return { ok: acc.toString('hex') === String(root) };
102
- } catch (e) {
103
- return { ok: false, reason: `verify_error:${e.message}` };
104
- }
47
+ /** Verify a trail consistency proof between two anchored roots. Never throws. */
48
+ export function verifyTrailConsistency(args) {
49
+ return verifyConsistencyProof(args);
105
50
  }
106
51
 
107
52
  /** A fresh empty trail. */
@@ -0,0 +1,164 @@
1
+ /**
2
+ * drand-bls.js — REAL BLS verification of drand beacons.
3
+ *
4
+ * `draw.js`'s `drandSeed` enforces the cheap, SHA-256-only binding
5
+ * `randomness == SHA256(signature)`. This module adds the strong guarantee: that
6
+ * the signature is a valid League-of-Entropy THRESHOLD signature over the round
7
+ * under the network's public key — i.e. the randomness was genuinely produced by
8
+ * the drand network and not fabricated. With this, a drand-seeded draw is
9
+ * non-manipulable AND its provenance is cryptographically proven, not asserted.
10
+ *
11
+ * It is kept in a SEPARATE module (and a separate import subpath) so the
12
+ * zero-dependency verifier in `verify.js` stays dependency-free: pull in the
13
+ * BLS18-381 pairing math (@noble/curves) only if and when you want this check.
14
+ *
15
+ * Two drand schemes are supported, matched by `schemeID`:
16
+ * - `bls-unchained-g1-rfc9380` (quicknet): signatures on G1, public key on G2,
17
+ * message = SHA256(round_be8). The recommended network for new draws.
18
+ * - `pedersen-bls-chained` (legacy mainnet): signatures on G2, public key on
19
+ * G1, message = SHA256(previous_signature ‖ round_be8).
20
+ *
21
+ * Verification math is provided by @noble/curves; its default hash-to-curve DSTs
22
+ * (`BLS_SIG_BLS12381G1_XMD:SHA-256_SSWU_RO_NUL_` / `…G2…`) match drand's exactly.
23
+ * Cross-checked against live beacon rounds, frozen into `test/drand-vectors.json`.
24
+ */
25
+
26
+ import { createHash } from 'node:crypto';
27
+
28
+ // @noble/curves is an OPTIONAL peer dependency: the BLS12-381 pairing math is only
29
+ // needed for this module, so the core package (receipts / audit / ledger / draw /
30
+ // the zero-dep verifier) installs nothing extra. Load it lazily and fail with a
31
+ // clear, actionable message if a consumer reaches for BLS without installing it.
32
+ let bls = null;
33
+ try {
34
+ ({ bls12_381: bls } = await import('@noble/curves/bls12-381'));
35
+ } catch {
36
+ bls = null;
37
+ }
38
+
39
+ function requireBls() {
40
+ if (!bls)
41
+ throw new Error(
42
+ 'drand BLS verification needs the optional peer dependency "@noble/curves". ' +
43
+ 'Install it to enable this check: npm i @noble/curves',
44
+ );
45
+ return bls;
46
+ }
47
+
48
+ function sha256(buf) {
49
+ return createHash('sha256').update(buf).digest();
50
+ }
51
+
52
+ /** drand serialises the round number as an unsigned 64-bit big-endian integer. */
53
+ function roundBytes(round) {
54
+ const b = Buffer.alloc(8);
55
+ b.writeBigUInt64BE(BigInt(round));
56
+ return b;
57
+ }
58
+
59
+ /**
60
+ * Frozen drand network parameters. Public keys are published constants; pass your
61
+ * own `publicKey` to `makeDrandVerifier` to pin a different network.
62
+ */
63
+ export const DRAND_QUICKNET_SCHEME = Object.freeze({
64
+ schemeID: 'bls-unchained-g1-rfc9380',
65
+ chainHash: '52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971',
66
+ publicKey:
67
+ '83cf0f2896adee7eb8b5f01fcad3912212c437e0073e911fb90022d3e760183c8c4b450b6a0a6c3ac6a5776a2d1064510d1fec758c921cc22b0e17e63aaf4bcb5ed66304de9cf809bd274ca73bab4af5a6e9c76a4bc09e76eae8991ef5ece45a',
68
+ genesisTime: 1692803367,
69
+ period: 3,
70
+ });
71
+
72
+ export const DRAND_DEFAULT_SCHEME = Object.freeze({
73
+ schemeID: 'pedersen-bls-chained',
74
+ chainHash: '8990e7a9aaed2ffed73dbd7092123d6f289930540d7651336225dc172e51b2ce',
75
+ publicKey:
76
+ '868f005eb8e6e4ca0a47c8a77ceaa5309a47978a7c71bc5cce96366b5d7a569937c529eeda66c7293784a9402801af31',
77
+ genesisTime: 1595431050,
78
+ period: 30,
79
+ });
80
+
81
+ /** Look up a known scheme by its `schemeID`. */
82
+ function knownScheme(schemeID) {
83
+ if (schemeID === DRAND_QUICKNET_SCHEME.schemeID) return DRAND_QUICKNET_SCHEME;
84
+ if (schemeID === DRAND_DEFAULT_SCHEME.schemeID) return DRAND_DEFAULT_SCHEME;
85
+ return null;
86
+ }
87
+
88
+ /**
89
+ * Verify a single drand beacon's BLS signature (and, by default, the
90
+ * `randomness == SHA256(signature)` binding). Returns a boolean and never throws
91
+ * on bad beacon data — but DOES throw if the optional `@noble/curves` peer
92
+ * dependency is not installed (so a missing library is never mistaken for an
93
+ * invalid beacon). Selects the curve/message construction from `schemeID`.
94
+ *
95
+ * @param {object} beacon `{ round, signature, randomness?, previousSignature? }` (hex)
96
+ * @param {object} opts
97
+ * @param {string} opts.publicKey network public key (hex)
98
+ * @param {string} opts.schemeID one of the supported scheme ids
99
+ * @param {boolean} [opts.checkRandomness=true] also enforce randomness binding
100
+ * @returns {boolean}
101
+ */
102
+ export function verifyDrandBeacon(beacon, { publicKey, schemeID, checkRandomness = true } = {}) {
103
+ const b = requireBls(); // throws a clear install error if the peer dep is absent
104
+ try {
105
+ const { round, signature, randomness } = beacon || {};
106
+ // Accept either the camelCase field (from `fetchDrandRound`) or the raw
107
+ // `previous_signature` field (straight from the drand HTTP API).
108
+ const previousSignature = beacon?.previousSignature ?? beacon?.previous_signature ?? null;
109
+ if (round == null || !signature || !publicKey || !schemeID) return false;
110
+
111
+ if (checkRandomness && randomness != null) {
112
+ if (sha256(Buffer.from(String(signature), 'hex')).toString('hex') !== String(randomness).toLowerCase())
113
+ return false;
114
+ }
115
+
116
+ if (schemeID === DRAND_QUICKNET_SCHEME.schemeID) {
117
+ // Unchained, signature on G1: message = SHA256(round_be8).
118
+ const digest = sha256(roundBytes(round));
119
+ const point = b.shortSignatures.hash(digest);
120
+ return b.shortSignatures.verify(String(signature), point, String(publicKey)) === true;
121
+ }
122
+ if (schemeID === DRAND_DEFAULT_SCHEME.schemeID) {
123
+ // Chained, signature on G2: message = SHA256(previous_signature ‖ round_be8).
124
+ if (!previousSignature) return false;
125
+ const digest = sha256(Buffer.concat([Buffer.from(String(previousSignature), 'hex'), roundBytes(round)]));
126
+ const point = b.longSignatures.hash(digest);
127
+ return b.longSignatures.verify(String(signature), point, String(publicKey)) === true;
128
+ }
129
+ return false; // unknown scheme — fail closed
130
+ } catch {
131
+ return false;
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Build a `verifySignature` function for `drandSeed`. Bind it to a network and it
137
+ * returns an async predicate `({ round, signature, randomness, previousSignature })
138
+ * => boolean` that `drandSeed` will enforce.
139
+ *
140
+ * import { drandSeed, fetchDrandRound } from '@kirkelabs/walletless-kit';
141
+ * import { makeDrandVerifier, DRAND_QUICKNET_SCHEME } from '@kirkelabs/walletless-kit/drand-bls';
142
+ *
143
+ * const verify = makeDrandVerifier(DRAND_QUICKNET_SCHEME);
144
+ * const beacon = await fetchDrandRound(committedRound); // after the round exists
145
+ * const seed = await drandSeed(beacon, verify); // throws unless BLS-valid
146
+ * // seed.blsVerified === true
147
+ *
148
+ * @param {object} scheme `{ schemeID, publicKey }` (e.g. DRAND_QUICKNET_SCHEME). A
149
+ * bare scheme id string also works, resolving a known network's public key.
150
+ * @param {object} [overrides] e.g. `{ publicKey }` to pin a custom network key
151
+ */
152
+ export function makeDrandVerifier(scheme, overrides = {}) {
153
+ const base = typeof scheme === 'string' ? knownScheme(scheme) : scheme;
154
+ if (!base) throw new Error('makeDrandVerifier: unknown scheme — pass { schemeID, publicKey }');
155
+ const schemeID = base.schemeID;
156
+ const publicKey = overrides.publicKey ?? base.publicKey;
157
+ if (!schemeID || !publicKey)
158
+ throw new Error('makeDrandVerifier: schemeID and publicKey are required');
159
+ // Defensive: public key length must match the scheme's group (G2=96B, G1=48B).
160
+ const expectBytes = schemeID === DRAND_QUICKNET_SCHEME.schemeID ? 96 : 48;
161
+ if (String(publicKey).length !== expectBytes * 2)
162
+ throw new Error(`makeDrandVerifier: publicKey must be ${expectBytes} bytes for ${schemeID}`);
163
+ return async (beacon) => verifyDrandBeacon(beacon, { publicKey, schemeID });
164
+ }