@forestrie/receipt-verify 0.8.2 → 0.9.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/dist/index.d.ts CHANGED
@@ -42,4 +42,19 @@ export type { LocatedLeaf } from "./find-grant-leaf.js";
42
42
  /** Re-exported so callers of findGrantLeafInMassif can catch it (FOR-344). */
43
43
  export { MissingIndexError } from "@forestrie/merklelog";
44
44
  export { accumulatorPayload, checkpointConsistencyProof, computeCheckpointAccumulator, verifyCheckpointChain, type CheckpointChainLink, type CheckpointChainResult, type CheckpointConsistencyProof, } from "./checkpoint-chain.js";
45
+ /**
46
+ * Univocity leaf commitment hash. Was CLI-private (forestrie-cli's own
47
+ * mirror, "hoist to the library when the FOR-297 multi-hop resolver lands");
48
+ * exported here as of plan-2607-34 slice 02 Part B so consumers stop
49
+ * duplicating it.
50
+ */
51
+ export { univocityLeafHash } from "./leaf-commitment.js";
52
+ /**
53
+ * Known-accumulator snapshot (FOR-297 D5): verify a receipt against a
54
+ * caller-supplied on-chain accumulator read, fully offline. Hoisted from
55
+ * forestrie-cli (plan-2607-34 slice 02 Part B) — see known-accumulator.ts
56
+ * for the trust model and what this package does NOT do (fetch the
57
+ * snapshot over RPC, or proof-path-extend a stale one via massif nodes).
58
+ */
59
+ export { assertSnapshotBinding, decodeKnownAccumulator, encodeKnownAccumulator, verifyReceiptOfflineAgainstKnownAccumulator, type KnownAccumulator, } from "./known-accumulator.js";
45
60
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,mBAAmB,EACnB,kBAAkB,GACnB,MAAM,4BAA4B,CAAC;AACpC,oFAAoF;AACpF,YAAY,EAAE,KAAK,EAAE,MAAM,qBAAqB,CAAC;AACjD,YAAY,EAAE,8BAA8B,EAAE,MAAM,mCAAmC,CAAC;AACxF,YAAY,EAAE,yBAAyB,EAAE,MAAM,mCAAmC,CAAC;AACnF,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,YAAY,EACV,wBAAwB,EACxB,uBAAuB,EACvB,eAAe,EACf,gBAAgB,GACjB,MAAM,4BAA4B,CAAC;AACpC,OAAO,EACL,wBAAwB,EACxB,mBAAmB,EACnB,sBAAsB,EACtB,mBAAmB,EACnB,eAAe,GAChB,MAAM,4BAA4B,CAAC;AACpC,+EAA+E;AAC/E,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,YAAY,EACV,mBAAmB,EACnB,oBAAoB,GACrB,MAAM,sBAAsB,CAAC;AAC9B;;;GAGG;AACH,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,EAAE,0BAA0B,EAAE,MAAM,qCAAqC,CAAC;AACjF,OAAO,EAAE,yBAAyB,EAAE,MAAM,mCAAmC,CAAC;AAC9E,OAAO,EAAE,oBAAoB,EAAE,MAAM,mCAAmC,CAAC;AACzE;;;;GAIG;AACH,OAAO,EACL,iCAAiC,EACjC,4BAA4B,GAC7B,MAAM,mCAAmC,CAAC;AAC3C,YAAY,EACV,sCAAsC,EACtC,iCAAiC,GAClC,MAAM,mCAAmC,CAAC;AAC3C,OAAO,EAAE,yBAAyB,EAAE,MAAM,mCAAmC,CAAC;AAC9E,YAAY,EAAE,mBAAmB,EAAE,MAAM,mCAAmC,CAAC;AAC7E,yEAAyE;AACzE,OAAO,EAAE,qCAAqC,EAAE,MAAM,6BAA6B,CAAC;AACpF,OAAO,EAAE,wBAAwB,EAAE,MAAM,kCAAkC,CAAC;AAC5E,OAAO,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAC;AAE3E,0EAA0E;AAC1E,OAAO,EACL,cAAc,EACd,iDAAiD,GAClD,MAAM,2CAA2C,CAAC;AACnD,OAAO,EAAE,gBAAgB,EAAE,0BAA0B,EAAE,MAAM,eAAe,CAAC;AAC7E,YAAY,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAEpD,6EAA6E;AAC7E,OAAO,EAAE,4BAA4B,EAAE,MAAM,uBAAuB,CAAC;AACrE,oEAAoE;AACpE,OAAO,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAC7D,YAAY,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AACxD,8EAA8E;AAC9E,OAAO,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AACzD,OAAO,EACL,kBAAkB,EAClB,0BAA0B,EAC1B,4BAA4B,EAC5B,qBAAqB,EACrB,KAAK,mBAAmB,EACxB,KAAK,qBAAqB,EAC1B,KAAK,0BAA0B,GAChC,MAAM,uBAAuB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,mBAAmB,EACnB,kBAAkB,GACnB,MAAM,4BAA4B,CAAC;AACpC,oFAAoF;AACpF,YAAY,EAAE,KAAK,EAAE,MAAM,qBAAqB,CAAC;AACjD,YAAY,EAAE,8BAA8B,EAAE,MAAM,mCAAmC,CAAC;AACxF,YAAY,EAAE,yBAAyB,EAAE,MAAM,mCAAmC,CAAC;AACnF,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,YAAY,EACV,wBAAwB,EACxB,uBAAuB,EACvB,eAAe,EACf,gBAAgB,GACjB,MAAM,4BAA4B,CAAC;AACpC,OAAO,EACL,wBAAwB,EACxB,mBAAmB,EACnB,sBAAsB,EACtB,mBAAmB,EACnB,eAAe,GAChB,MAAM,4BAA4B,CAAC;AACpC,+EAA+E;AAC/E,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,YAAY,EACV,mBAAmB,EACnB,oBAAoB,GACrB,MAAM,sBAAsB,CAAC;AAC9B;;;GAGG;AACH,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,EAAE,0BAA0B,EAAE,MAAM,qCAAqC,CAAC;AACjF,OAAO,EAAE,yBAAyB,EAAE,MAAM,mCAAmC,CAAC;AAC9E,OAAO,EAAE,oBAAoB,EAAE,MAAM,mCAAmC,CAAC;AACzE;;;;GAIG;AACH,OAAO,EACL,iCAAiC,EACjC,4BAA4B,GAC7B,MAAM,mCAAmC,CAAC;AAC3C,YAAY,EACV,sCAAsC,EACtC,iCAAiC,GAClC,MAAM,mCAAmC,CAAC;AAC3C,OAAO,EAAE,yBAAyB,EAAE,MAAM,mCAAmC,CAAC;AAC9E,YAAY,EAAE,mBAAmB,EAAE,MAAM,mCAAmC,CAAC;AAC7E,yEAAyE;AACzE,OAAO,EAAE,qCAAqC,EAAE,MAAM,6BAA6B,CAAC;AACpF,OAAO,EAAE,wBAAwB,EAAE,MAAM,kCAAkC,CAAC;AAC5E,OAAO,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAC;AAE3E,0EAA0E;AAC1E,OAAO,EACL,cAAc,EACd,iDAAiD,GAClD,MAAM,2CAA2C,CAAC;AACnD,OAAO,EAAE,gBAAgB,EAAE,0BAA0B,EAAE,MAAM,eAAe,CAAC;AAC7E,YAAY,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAEpD,6EAA6E;AAC7E,OAAO,EAAE,4BAA4B,EAAE,MAAM,uBAAuB,CAAC;AACrE,oEAAoE;AACpE,OAAO,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAC7D,YAAY,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AACxD,8EAA8E;AAC9E,OAAO,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AACzD,OAAO,EACL,kBAAkB,EAClB,0BAA0B,EAC1B,4BAA4B,EAC5B,qBAAqB,EACrB,KAAK,mBAAmB,EACxB,KAAK,qBAAqB,EAC1B,KAAK,0BAA0B,GAChC,MAAM,uBAAuB,CAAC;AAC/B;;;;;GAKG;AACH,OAAO,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AACzD;;;;;;GAMG;AACH,OAAO,EACL,qBAAqB,EACrB,sBAAsB,EACtB,sBAAsB,EACtB,2CAA2C,EAC3C,KAAK,gBAAgB,GACtB,MAAM,wBAAwB,CAAC"}
package/dist/index.js CHANGED
@@ -31,3 +31,18 @@ export { findGrantLeafInMassif } from "./find-grant-leaf.js";
31
31
  /** Re-exported so callers of findGrantLeafInMassif can catch it (FOR-344). */
32
32
  export { MissingIndexError } from "@forestrie/merklelog";
33
33
  export { accumulatorPayload, checkpointConsistencyProof, computeCheckpointAccumulator, verifyCheckpointChain, } from "./checkpoint-chain.js";
34
+ /**
35
+ * Univocity leaf commitment hash. Was CLI-private (forestrie-cli's own
36
+ * mirror, "hoist to the library when the FOR-297 multi-hop resolver lands");
37
+ * exported here as of plan-2607-34 slice 02 Part B so consumers stop
38
+ * duplicating it.
39
+ */
40
+ export { univocityLeafHash } from "./leaf-commitment.js";
41
+ /**
42
+ * Known-accumulator snapshot (FOR-297 D5): verify a receipt against a
43
+ * caller-supplied on-chain accumulator read, fully offline. Hoisted from
44
+ * forestrie-cli (plan-2607-34 slice 02 Part B) — see known-accumulator.ts
45
+ * for the trust model and what this package does NOT do (fetch the
46
+ * snapshot over RPC, or proof-path-extend a stale one via massif nodes).
47
+ */
48
+ export { assertSnapshotBinding, decodeKnownAccumulator, encodeKnownAccumulator, verifyReceiptOfflineAgainstKnownAccumulator, } from "./known-accumulator.js";
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Known-accumulator snapshot (FOR-297 D5): a cached, auditable chain read of
3
+ * a Univocity log's `logState`, letting chain-anchored verification run
4
+ * fully offline. Hoisted from `forestrie-cli/src/lib/verify-known-accumulator.ts`
5
+ * (plan-2607-34 slice 02 Part B) so the CLI and any other consumer — e2e
6
+ * harnesses, a future browser relying party — share one implementation
7
+ * instead of duplicating this crypto per caller.
8
+ *
9
+ * Trust model (freshness / split-view): `--rpc-url` was never trust-free —
10
+ * the RPC provider is itself a trusted chain reader. The snapshot makes that
11
+ * trust explicit, portable, and cacheable. It binds `(chainId, univocity,
12
+ * logId, size, block)` so anyone with RPC can re-run the read at that block
13
+ * and confirm or disprove it — auditable, falsifiable trust, unlike a bare
14
+ * known key.
15
+ *
16
+ * Staleness limits coverage, never validity: the contract's consistency
17
+ * gating makes every anchored state a committed prefix of every later one,
18
+ * so a peak match at snapshot size N proves inclusion at N and forever
19
+ * after. Entries newer than the snapshot fail closed with a refresh hint.
20
+ *
21
+ * This module is pure — no network, no `node:*` imports (browser-safety
22
+ * enforced by `tools/check-browser-safe.mjs`). Fetching the snapshot itself
23
+ * over RPC is the caller's concern (forestrie-cli `fetch-accumulator`, or an
24
+ * equivalent reader — see `verifyReceiptOfflineAgainstKnownAccumulator`'s
25
+ * doc for why that stays out of this package).
26
+ *
27
+ * NEVER source the snapshot unauthenticated from the same store as the tiles
28
+ * (the log operator's massif/checkpoint store) — that silently re-internalises
29
+ * the operator trust this anchor exists to remove.
30
+ */
31
+ import type { ReceiptVerifyResult } from "./receipt-verify-result.js";
32
+ export type KnownAccumulator = {
33
+ version: number;
34
+ chainId: bigint;
35
+ /** Univocity contract address (20 bytes). */
36
+ univocity: Uint8Array;
37
+ /** Contract log id (32 bytes, UUID zero-padded on the left). */
38
+ logId: Uint8Array;
39
+ /** Anchored MMR size at the snapshot block. */
40
+ size: bigint;
41
+ /** Anchored accumulator peaks (32 bytes each), contract order. */
42
+ accumulator: Uint8Array[];
43
+ blockNumber: bigint;
44
+ /** Block hash of the read (32 bytes) — the falsifiability handle. */
45
+ blockHash: Uint8Array;
46
+ };
47
+ /** Encode a snapshot as canonical CBOR (RFC 8949 §4.2 — hard policy). */
48
+ export declare function encodeKnownAccumulator(snapshot: KnownAccumulator): Uint8Array;
49
+ /** Strict decode + shape validation of a snapshot artifact. */
50
+ export declare function decodeKnownAccumulator(bytes: Uint8Array): KnownAccumulator;
51
+ /**
52
+ * Reject a snapshot whose binding does not match the caller's stated target
53
+ * BEFORE any peak math — a snapshot for the wrong log or contract must never
54
+ * be silently accepted as an anchor. `univocity`/`logId` are raw
55
+ * caller-supplied strings (a `0x`-address, a UUID, or hex id) — normalized
56
+ * here so callers don't have to.
57
+ */
58
+ export declare function assertSnapshotBinding(snapshot: KnownAccumulator, opts: {
59
+ univocity?: string | undefined;
60
+ logId?: string | undefined;
61
+ }): void;
62
+ /**
63
+ * Offline check: is the receipt's peak one of the known-accumulator's
64
+ * anchored peaks? Covers the two network-free cases from
65
+ * `forestrie-cli`'s `checkReceiptAnchoredToSnapshot`:
66
+ *
67
+ * 1. Fail closed when the receipt's leaf postdates the snapshot (refresh).
68
+ * 2. Exact peak match: the recomputed receipt peak is still a snapshot peak.
69
+ *
70
+ * Proof-path extension for stale snapshots (case 3 — needs a local massif
71
+ * blob) stays CLI-only for now; it is a bigger, file-shaped input this
72
+ * package's pure-bytes API doesn't take today. A receipt whose snapshot
73
+ * covers it but isn't an exact peak returns `peak_not_in_known_accumulator`
74
+ * — honest about not having tried extension, not a false negative dressed
75
+ * up as one.
76
+ *
77
+ * The live RPC read that produces `accumulator`/`size` stays the caller's
78
+ * concern (this package is pure — no network, browser-safety enforced by
79
+ * `tools/check-browser-safe.mjs`); see `system-testing/src/onchain-logstate.ts`
80
+ * or `forestrie-cli fetch-accumulator` for two independent, viem-free
81
+ * readers (`fetch()` + manual ABI decode of `logState(bytes32)`).
82
+ */
83
+ export declare function verifyReceiptOfflineAgainstKnownAccumulator(input: {
84
+ receiptCbor: Uint8Array;
85
+ idtimestampBe8: Uint8Array;
86
+ /** Leaf ContentHash: SHA-256(payload) or the grant commitment hash. */
87
+ inner: Uint8Array;
88
+ /** Trusted accumulator peaks at `size`, contract order. */
89
+ accumulator: Uint8Array[];
90
+ /** Anchored MMR size at the snapshot. */
91
+ size: bigint;
92
+ }): Promise<ReceiptVerifyResult>;
93
+ //# sourceMappingURL=known-accumulator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"known-accumulator.d.ts","sourceRoot":"","sources":["../src/known-accumulator.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAWH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AAoCtE,MAAM,MAAM,gBAAgB,GAAG;IAC7B,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,6CAA6C;IAC7C,SAAS,EAAE,UAAU,CAAC;IACtB,gEAAgE;IAChE,KAAK,EAAE,UAAU,CAAC;IAClB,+CAA+C;IAC/C,IAAI,EAAE,MAAM,CAAC;IACb,kEAAkE;IAClE,WAAW,EAAE,UAAU,EAAE,CAAC;IAC1B,WAAW,EAAE,MAAM,CAAC;IACpB,qEAAqE;IACrE,SAAS,EAAE,UAAU,CAAC;CACvB,CAAC;AAEF,yEAAyE;AACzE,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,gBAAgB,GAAG,UAAU,CAa7E;AAgBD,+DAA+D;AAC/D,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,UAAU,GAAG,gBAAgB,CAgC1E;AAMD;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CACnC,QAAQ,EAAE,gBAAgB,EAC1B,IAAI,EAAE;IAAE,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CAAE,GACnE,IAAI,CAiBN;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAsB,2CAA2C,CAAC,KAAK,EAAE;IACvE,WAAW,EAAE,UAAU,CAAC;IACxB,cAAc,EAAE,UAAU,CAAC;IAC3B,uEAAuE;IACvE,KAAK,EAAE,UAAU,CAAC;IAClB,2DAA2D;IAC3D,WAAW,EAAE,UAAU,EAAE,CAAC;IAC1B,yCAAyC;IACzC,IAAI,EAAE,MAAM,CAAC;CACd,GAAG,OAAO,CAAC,mBAAmB,CAAC,CA4C/B"}
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Known-accumulator snapshot (FOR-297 D5): a cached, auditable chain read of
3
+ * a Univocity log's `logState`, letting chain-anchored verification run
4
+ * fully offline. Hoisted from `forestrie-cli/src/lib/verify-known-accumulator.ts`
5
+ * (plan-2607-34 slice 02 Part B) so the CLI and any other consumer — e2e
6
+ * harnesses, a future browser relying party — share one implementation
7
+ * instead of duplicating this crypto per caller.
8
+ *
9
+ * Trust model (freshness / split-view): `--rpc-url` was never trust-free —
10
+ * the RPC provider is itself a trusted chain reader. The snapshot makes that
11
+ * trust explicit, portable, and cacheable. It binds `(chainId, univocity,
12
+ * logId, size, block)` so anyone with RPC can re-run the read at that block
13
+ * and confirm or disprove it — auditable, falsifiable trust, unlike a bare
14
+ * known key.
15
+ *
16
+ * Staleness limits coverage, never validity: the contract's consistency
17
+ * gating makes every anchored state a committed prefix of every later one,
18
+ * so a peak match at snapshot size N proves inclusion at N and forever
19
+ * after. Entries newer than the snapshot fail closed with a refresh hint.
20
+ *
21
+ * This module is pure — no network, no `node:*` imports (browser-safety
22
+ * enforced by `tools/check-browser-safe.mjs`). Fetching the snapshot itself
23
+ * over RPC is the caller's concern (forestrie-cli `fetch-accumulator`, or an
24
+ * equivalent reader — see `verifyReceiptOfflineAgainstKnownAccumulator`'s
25
+ * doc for why that stays out of this package).
26
+ *
27
+ * NEVER source the snapshot unauthenticated from the same store as the tiles
28
+ * (the log operator's massif/checkpoint store) — that silently re-internalises
29
+ * the operator trust this anchor exists to remove.
30
+ */
31
+ import { decodeCborDeterministic, encodeCborDeterministic, } from "@forestrie/encoding";
32
+ import { calculateRoot } from "@forestrie/merklelog";
33
+ import { normalizeHexAddress } from "@forestrie/chain-rpc";
34
+ import { parseReceipt } from "./parse-receipt.js";
35
+ import { univocityLeafHash } from "./leaf-commitment.js";
36
+ import { SubtleHasher } from "./subtle-hasher.js";
37
+ /**
38
+ * 16/32-byte hex or UUID log id -> the 32-byte contract key (UUID in the low
39
+ * bytes, zero-padded on the left — matches Univocity's `ToContractBytes32`).
40
+ * Small, pure, intentionally duplicated from `forestrie-cli`'s copy (same
41
+ * tolerance as this file's local `bytesEqual` — not security-sensitive
42
+ * enough to force a shared-utility package for a six-line formatter).
43
+ */
44
+ function toContractLogId(logId) {
45
+ const hex = logId.replace(/-/g, "").replace(/^0x/, "").toLowerCase();
46
+ if (!/^[0-9a-f]{32}$/.test(hex) && !/^[0-9a-f]{64}$/.test(hex)) {
47
+ throw new Error(`log id must be a UUID or 16/32-byte hex id, got '${logId}'`);
48
+ }
49
+ return "0x" + hex.padStart(64, "0");
50
+ }
51
+ function bytesEqual(a, b) {
52
+ if (a.length !== b.length)
53
+ return false;
54
+ let x = 0;
55
+ for (let i = 0; i < a.length; i++)
56
+ x |= a[i] ^ b[i];
57
+ return x === 0;
58
+ }
59
+ /** CBOR map labels for the snapshot artifact (strict RFC 8949 §4.2). */
60
+ const LABEL_VERSION = 1;
61
+ const LABEL_CHAIN_ID = 2;
62
+ const LABEL_UNIVOCITY = 3;
63
+ const LABEL_LOG_ID = 4;
64
+ const LABEL_SIZE = 5;
65
+ const LABEL_ACCUMULATOR = 6;
66
+ const LABEL_BLOCK_NUMBER = 7;
67
+ const LABEL_BLOCK_HASH = 8;
68
+ const SNAPSHOT_VERSION = 1;
69
+ /** Encode a snapshot as canonical CBOR (RFC 8949 §4.2 — hard policy). */
70
+ export function encodeKnownAccumulator(snapshot) {
71
+ return encodeCborDeterministic(new Map([
72
+ [LABEL_VERSION, snapshot.version],
73
+ [LABEL_CHAIN_ID, snapshot.chainId],
74
+ [LABEL_UNIVOCITY, snapshot.univocity],
75
+ [LABEL_LOG_ID, snapshot.logId],
76
+ [LABEL_SIZE, snapshot.size],
77
+ [LABEL_ACCUMULATOR, snapshot.accumulator],
78
+ [LABEL_BLOCK_NUMBER, snapshot.blockNumber],
79
+ [LABEL_BLOCK_HASH, snapshot.blockHash],
80
+ ]));
81
+ }
82
+ function asBigint(v, what) {
83
+ if (typeof v === "bigint")
84
+ return v;
85
+ if (typeof v === "number" && Number.isSafeInteger(v) && v >= 0)
86
+ return BigInt(v);
87
+ throw new Error(`known accumulator: ${what} must be an unsigned integer`);
88
+ }
89
+ function asBytes(v, length, what) {
90
+ if (!(v instanceof Uint8Array) || v.length !== length) {
91
+ throw new Error(`known accumulator: ${what} must be ${length} bytes`);
92
+ }
93
+ return v;
94
+ }
95
+ /** Strict decode + shape validation of a snapshot artifact. */
96
+ export function decodeKnownAccumulator(bytes) {
97
+ let decoded;
98
+ try {
99
+ decoded = decodeCborDeterministic(bytes);
100
+ }
101
+ catch (err) {
102
+ const message = err instanceof Error ? err.message : String(err);
103
+ throw new Error(`known accumulator is not canonical CBOR: ${message}`);
104
+ }
105
+ if (!(decoded instanceof Map)) {
106
+ throw new Error("known accumulator must be a CBOR map");
107
+ }
108
+ const version = Number(asBigint(decoded.get(LABEL_VERSION), "version"));
109
+ if (version !== SNAPSHOT_VERSION) {
110
+ throw new Error(`known accumulator version ${version} not supported`);
111
+ }
112
+ const accRaw = decoded.get(LABEL_ACCUMULATOR);
113
+ if (!Array.isArray(accRaw)) {
114
+ throw new Error("known accumulator: accumulator must be an array");
115
+ }
116
+ const accumulator = accRaw.map((p, i) => asBytes(p, 32, `accumulator peak ${i}`));
117
+ return {
118
+ version,
119
+ chainId: asBigint(decoded.get(LABEL_CHAIN_ID), "chainId"),
120
+ univocity: asBytes(decoded.get(LABEL_UNIVOCITY), 20, "univocity"),
121
+ logId: asBytes(decoded.get(LABEL_LOG_ID), 32, "logId"),
122
+ size: asBigint(decoded.get(LABEL_SIZE), "size"),
123
+ accumulator,
124
+ blockNumber: asBigint(decoded.get(LABEL_BLOCK_NUMBER), "blockNumber"),
125
+ blockHash: asBytes(decoded.get(LABEL_BLOCK_HASH), 32, "blockHash"),
126
+ };
127
+ }
128
+ function bytesToHex(bytes) {
129
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
130
+ }
131
+ /**
132
+ * Reject a snapshot whose binding does not match the caller's stated target
133
+ * BEFORE any peak math — a snapshot for the wrong log or contract must never
134
+ * be silently accepted as an anchor. `univocity`/`logId` are raw
135
+ * caller-supplied strings (a `0x`-address, a UUID, or hex id) — normalized
136
+ * here so callers don't have to.
137
+ */
138
+ export function assertSnapshotBinding(snapshot, opts) {
139
+ if (opts.univocity !== undefined) {
140
+ const given = normalizeHexAddress(opts.univocity);
141
+ if (given === null || given !== bytesToHex(snapshot.univocity)) {
142
+ throw new Error(`known accumulator is bound to univocity 0x${bytesToHex(snapshot.univocity)}, not --univocity ${opts.univocity}`);
143
+ }
144
+ }
145
+ if (opts.logId !== undefined) {
146
+ const given = toContractLogId(opts.logId).slice(2);
147
+ if (given !== bytesToHex(snapshot.logId)) {
148
+ throw new Error(`known accumulator is bound to log 0x${bytesToHex(snapshot.logId)}, not --log-id ${opts.logId}`);
149
+ }
150
+ }
151
+ }
152
+ /**
153
+ * Offline check: is the receipt's peak one of the known-accumulator's
154
+ * anchored peaks? Covers the two network-free cases from
155
+ * `forestrie-cli`'s `checkReceiptAnchoredToSnapshot`:
156
+ *
157
+ * 1. Fail closed when the receipt's leaf postdates the snapshot (refresh).
158
+ * 2. Exact peak match: the recomputed receipt peak is still a snapshot peak.
159
+ *
160
+ * Proof-path extension for stale snapshots (case 3 — needs a local massif
161
+ * blob) stays CLI-only for now; it is a bigger, file-shaped input this
162
+ * package's pure-bytes API doesn't take today. A receipt whose snapshot
163
+ * covers it but isn't an exact peak returns `peak_not_in_known_accumulator`
164
+ * — honest about not having tried extension, not a false negative dressed
165
+ * up as one.
166
+ *
167
+ * The live RPC read that produces `accumulator`/`size` stays the caller's
168
+ * concern (this package is pure — no network, browser-safety enforced by
169
+ * `tools/check-browser-safe.mjs`); see `system-testing/src/onchain-logstate.ts`
170
+ * or `forestrie-cli fetch-accumulator` for two independent, viem-free
171
+ * readers (`fetch()` + manual ABI decode of `logState(bytes32)`).
172
+ */
173
+ export async function verifyReceiptOfflineAgainstKnownAccumulator(input) {
174
+ let parsed;
175
+ try {
176
+ parsed = parseReceipt(input.receiptCbor);
177
+ }
178
+ catch {
179
+ return { ok: false, stage: "parse", reason: "receipt_malformed" };
180
+ }
181
+ const leafIdx = parsed.proof.leafIndex !== undefined
182
+ ? parsed.proof.leafIndex
183
+ : parsed.proof.mmrIndex;
184
+ // 1. Newer-than-snapshot fails CLOSED — staleness limits coverage, never
185
+ // validity, so the remedy is a refresh, not a pass.
186
+ if (leafIdx >= input.size) {
187
+ return {
188
+ ok: false,
189
+ stage: "signature",
190
+ reason: "receipt_newer_than_known_accumulator",
191
+ };
192
+ }
193
+ let recomputedPeak;
194
+ if (parsed.explicitPeak !== null) {
195
+ recomputedPeak = parsed.explicitPeak;
196
+ }
197
+ else {
198
+ const hasher = new SubtleHasher();
199
+ const leafHash = await univocityLeafHash(input.idtimestampBe8, input.inner);
200
+ recomputedPeak = await calculateRoot(hasher, leafHash, parsed.proof, leafIdx);
201
+ }
202
+ // 2. Exact peak match — receipt state is a snapshot-covered accumulator.
203
+ for (const peak of input.accumulator) {
204
+ if (bytesEqual(recomputedPeak, peak)) {
205
+ return { ok: true, stage: "binding" };
206
+ }
207
+ }
208
+ return {
209
+ ok: false,
210
+ stage: "signature",
211
+ reason: "peak_not_in_known_accumulator",
212
+ };
213
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forestrie/receipt-verify",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "Offline SCITT grant receipt verification (ADR-0045)",
@@ -29,6 +29,7 @@
29
29
  "dependencies": {
30
30
  "@noble/hashes": "^1.7.1",
31
31
  "@forestrie/merklelog": "0.3.0",
32
+ "@forestrie/chain-rpc": "0.1.1",
32
33
  "@forestrie/encoding": "0.5.0"
33
34
  },
34
35
  "devDependencies": {
package/src/index.ts CHANGED
@@ -78,3 +78,24 @@ export {
78
78
  type CheckpointChainResult,
79
79
  type CheckpointConsistencyProof,
80
80
  } from "./checkpoint-chain.js";
81
+ /**
82
+ * Univocity leaf commitment hash. Was CLI-private (forestrie-cli's own
83
+ * mirror, "hoist to the library when the FOR-297 multi-hop resolver lands");
84
+ * exported here as of plan-2607-34 slice 02 Part B so consumers stop
85
+ * duplicating it.
86
+ */
87
+ export { univocityLeafHash } from "./leaf-commitment.js";
88
+ /**
89
+ * Known-accumulator snapshot (FOR-297 D5): verify a receipt against a
90
+ * caller-supplied on-chain accumulator read, fully offline. Hoisted from
91
+ * forestrie-cli (plan-2607-34 slice 02 Part B) — see known-accumulator.ts
92
+ * for the trust model and what this package does NOT do (fetch the
93
+ * snapshot over RPC, or proof-path-extend a stale one via massif nodes).
94
+ */
95
+ export {
96
+ assertSnapshotBinding,
97
+ decodeKnownAccumulator,
98
+ encodeKnownAccumulator,
99
+ verifyReceiptOfflineAgainstKnownAccumulator,
100
+ type KnownAccumulator,
101
+ } from "./known-accumulator.js";
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Known-accumulator snapshot (FOR-297 D5): a cached, auditable chain read of
3
+ * a Univocity log's `logState`, letting chain-anchored verification run
4
+ * fully offline. Hoisted from `forestrie-cli/src/lib/verify-known-accumulator.ts`
5
+ * (plan-2607-34 slice 02 Part B) so the CLI and any other consumer — e2e
6
+ * harnesses, a future browser relying party — share one implementation
7
+ * instead of duplicating this crypto per caller.
8
+ *
9
+ * Trust model (freshness / split-view): `--rpc-url` was never trust-free —
10
+ * the RPC provider is itself a trusted chain reader. The snapshot makes that
11
+ * trust explicit, portable, and cacheable. It binds `(chainId, univocity,
12
+ * logId, size, block)` so anyone with RPC can re-run the read at that block
13
+ * and confirm or disprove it — auditable, falsifiable trust, unlike a bare
14
+ * known key.
15
+ *
16
+ * Staleness limits coverage, never validity: the contract's consistency
17
+ * gating makes every anchored state a committed prefix of every later one,
18
+ * so a peak match at snapshot size N proves inclusion at N and forever
19
+ * after. Entries newer than the snapshot fail closed with a refresh hint.
20
+ *
21
+ * This module is pure — no network, no `node:*` imports (browser-safety
22
+ * enforced by `tools/check-browser-safe.mjs`). Fetching the snapshot itself
23
+ * over RPC is the caller's concern (forestrie-cli `fetch-accumulator`, or an
24
+ * equivalent reader — see `verifyReceiptOfflineAgainstKnownAccumulator`'s
25
+ * doc for why that stays out of this package).
26
+ *
27
+ * NEVER source the snapshot unauthenticated from the same store as the tiles
28
+ * (the log operator's massif/checkpoint store) — that silently re-internalises
29
+ * the operator trust this anchor exists to remove.
30
+ */
31
+
32
+ import {
33
+ decodeCborDeterministic,
34
+ encodeCborDeterministic,
35
+ } from "@forestrie/encoding";
36
+ import { calculateRoot, type Proof } from "@forestrie/merklelog";
37
+ import { normalizeHexAddress } from "@forestrie/chain-rpc";
38
+ import { parseReceipt } from "./parse-receipt.js";
39
+ import { univocityLeafHash } from "./leaf-commitment.js";
40
+ import { SubtleHasher } from "./subtle-hasher.js";
41
+ import type { ReceiptVerifyResult } from "./receipt-verify-result.js";
42
+
43
+ /**
44
+ * 16/32-byte hex or UUID log id -> the 32-byte contract key (UUID in the low
45
+ * bytes, zero-padded on the left — matches Univocity's `ToContractBytes32`).
46
+ * Small, pure, intentionally duplicated from `forestrie-cli`'s copy (same
47
+ * tolerance as this file's local `bytesEqual` — not security-sensitive
48
+ * enough to force a shared-utility package for a six-line formatter).
49
+ */
50
+ function toContractLogId(logId: string): string {
51
+ const hex = logId.replace(/-/g, "").replace(/^0x/, "").toLowerCase();
52
+ if (!/^[0-9a-f]{32}$/.test(hex) && !/^[0-9a-f]{64}$/.test(hex)) {
53
+ throw new Error(`log id must be a UUID or 16/32-byte hex id, got '${logId}'`);
54
+ }
55
+ return "0x" + hex.padStart(64, "0");
56
+ }
57
+
58
+ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
59
+ if (a.length !== b.length) return false;
60
+ let x = 0;
61
+ for (let i = 0; i < a.length; i++) x |= a[i]! ^ b[i]!;
62
+ return x === 0;
63
+ }
64
+
65
+ /** CBOR map labels for the snapshot artifact (strict RFC 8949 §4.2). */
66
+ const LABEL_VERSION = 1;
67
+ const LABEL_CHAIN_ID = 2;
68
+ const LABEL_UNIVOCITY = 3;
69
+ const LABEL_LOG_ID = 4;
70
+ const LABEL_SIZE = 5;
71
+ const LABEL_ACCUMULATOR = 6;
72
+ const LABEL_BLOCK_NUMBER = 7;
73
+ const LABEL_BLOCK_HASH = 8;
74
+
75
+ const SNAPSHOT_VERSION = 1;
76
+
77
+ export type KnownAccumulator = {
78
+ version: number;
79
+ chainId: bigint;
80
+ /** Univocity contract address (20 bytes). */
81
+ univocity: Uint8Array;
82
+ /** Contract log id (32 bytes, UUID zero-padded on the left). */
83
+ logId: Uint8Array;
84
+ /** Anchored MMR size at the snapshot block. */
85
+ size: bigint;
86
+ /** Anchored accumulator peaks (32 bytes each), contract order. */
87
+ accumulator: Uint8Array[];
88
+ blockNumber: bigint;
89
+ /** Block hash of the read (32 bytes) — the falsifiability handle. */
90
+ blockHash: Uint8Array;
91
+ };
92
+
93
+ /** Encode a snapshot as canonical CBOR (RFC 8949 §4.2 — hard policy). */
94
+ export function encodeKnownAccumulator(snapshot: KnownAccumulator): Uint8Array {
95
+ return encodeCborDeterministic(
96
+ new Map<number, unknown>([
97
+ [LABEL_VERSION, snapshot.version],
98
+ [LABEL_CHAIN_ID, snapshot.chainId],
99
+ [LABEL_UNIVOCITY, snapshot.univocity],
100
+ [LABEL_LOG_ID, snapshot.logId],
101
+ [LABEL_SIZE, snapshot.size],
102
+ [LABEL_ACCUMULATOR, snapshot.accumulator],
103
+ [LABEL_BLOCK_NUMBER, snapshot.blockNumber],
104
+ [LABEL_BLOCK_HASH, snapshot.blockHash],
105
+ ]),
106
+ );
107
+ }
108
+
109
+ function asBigint(v: unknown, what: string): bigint {
110
+ if (typeof v === "bigint") return v;
111
+ if (typeof v === "number" && Number.isSafeInteger(v) && v >= 0)
112
+ return BigInt(v);
113
+ throw new Error(`known accumulator: ${what} must be an unsigned integer`);
114
+ }
115
+
116
+ function asBytes(v: unknown, length: number, what: string): Uint8Array {
117
+ if (!(v instanceof Uint8Array) || v.length !== length) {
118
+ throw new Error(`known accumulator: ${what} must be ${length} bytes`);
119
+ }
120
+ return v;
121
+ }
122
+
123
+ /** Strict decode + shape validation of a snapshot artifact. */
124
+ export function decodeKnownAccumulator(bytes: Uint8Array): KnownAccumulator {
125
+ let decoded: unknown;
126
+ try {
127
+ decoded = decodeCborDeterministic(bytes);
128
+ } catch (err) {
129
+ const message = err instanceof Error ? err.message : String(err);
130
+ throw new Error(`known accumulator is not canonical CBOR: ${message}`);
131
+ }
132
+ if (!(decoded instanceof Map)) {
133
+ throw new Error("known accumulator must be a CBOR map");
134
+ }
135
+ const version = Number(asBigint(decoded.get(LABEL_VERSION), "version"));
136
+ if (version !== SNAPSHOT_VERSION) {
137
+ throw new Error(`known accumulator version ${version} not supported`);
138
+ }
139
+ const accRaw = decoded.get(LABEL_ACCUMULATOR);
140
+ if (!Array.isArray(accRaw)) {
141
+ throw new Error("known accumulator: accumulator must be an array");
142
+ }
143
+ const accumulator = accRaw.map((p, i) =>
144
+ asBytes(p, 32, `accumulator peak ${i}`),
145
+ );
146
+ return {
147
+ version,
148
+ chainId: asBigint(decoded.get(LABEL_CHAIN_ID), "chainId"),
149
+ univocity: asBytes(decoded.get(LABEL_UNIVOCITY), 20, "univocity"),
150
+ logId: asBytes(decoded.get(LABEL_LOG_ID), 32, "logId"),
151
+ size: asBigint(decoded.get(LABEL_SIZE), "size"),
152
+ accumulator,
153
+ blockNumber: asBigint(decoded.get(LABEL_BLOCK_NUMBER), "blockNumber"),
154
+ blockHash: asBytes(decoded.get(LABEL_BLOCK_HASH), 32, "blockHash"),
155
+ };
156
+ }
157
+
158
+ function bytesToHex(bytes: Uint8Array): string {
159
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
160
+ }
161
+
162
+ /**
163
+ * Reject a snapshot whose binding does not match the caller's stated target
164
+ * BEFORE any peak math — a snapshot for the wrong log or contract must never
165
+ * be silently accepted as an anchor. `univocity`/`logId` are raw
166
+ * caller-supplied strings (a `0x`-address, a UUID, or hex id) — normalized
167
+ * here so callers don't have to.
168
+ */
169
+ export function assertSnapshotBinding(
170
+ snapshot: KnownAccumulator,
171
+ opts: { univocity?: string | undefined; logId?: string | undefined },
172
+ ): void {
173
+ if (opts.univocity !== undefined) {
174
+ const given = normalizeHexAddress(opts.univocity);
175
+ if (given === null || given !== bytesToHex(snapshot.univocity)) {
176
+ throw new Error(
177
+ `known accumulator is bound to univocity 0x${bytesToHex(snapshot.univocity)}, not --univocity ${opts.univocity}`,
178
+ );
179
+ }
180
+ }
181
+ if (opts.logId !== undefined) {
182
+ const given = toContractLogId(opts.logId).slice(2);
183
+ if (given !== bytesToHex(snapshot.logId)) {
184
+ throw new Error(
185
+ `known accumulator is bound to log 0x${bytesToHex(snapshot.logId)}, not --log-id ${opts.logId}`,
186
+ );
187
+ }
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Offline check: is the receipt's peak one of the known-accumulator's
193
+ * anchored peaks? Covers the two network-free cases from
194
+ * `forestrie-cli`'s `checkReceiptAnchoredToSnapshot`:
195
+ *
196
+ * 1. Fail closed when the receipt's leaf postdates the snapshot (refresh).
197
+ * 2. Exact peak match: the recomputed receipt peak is still a snapshot peak.
198
+ *
199
+ * Proof-path extension for stale snapshots (case 3 — needs a local massif
200
+ * blob) stays CLI-only for now; it is a bigger, file-shaped input this
201
+ * package's pure-bytes API doesn't take today. A receipt whose snapshot
202
+ * covers it but isn't an exact peak returns `peak_not_in_known_accumulator`
203
+ * — honest about not having tried extension, not a false negative dressed
204
+ * up as one.
205
+ *
206
+ * The live RPC read that produces `accumulator`/`size` stays the caller's
207
+ * concern (this package is pure — no network, browser-safety enforced by
208
+ * `tools/check-browser-safe.mjs`); see `system-testing/src/onchain-logstate.ts`
209
+ * or `forestrie-cli fetch-accumulator` for two independent, viem-free
210
+ * readers (`fetch()` + manual ABI decode of `logState(bytes32)`).
211
+ */
212
+ export async function verifyReceiptOfflineAgainstKnownAccumulator(input: {
213
+ receiptCbor: Uint8Array;
214
+ idtimestampBe8: Uint8Array;
215
+ /** Leaf ContentHash: SHA-256(payload) or the grant commitment hash. */
216
+ inner: Uint8Array;
217
+ /** Trusted accumulator peaks at `size`, contract order. */
218
+ accumulator: Uint8Array[];
219
+ /** Anchored MMR size at the snapshot. */
220
+ size: bigint;
221
+ }): Promise<ReceiptVerifyResult> {
222
+ let parsed: { explicitPeak: Uint8Array | null; proof: Proof };
223
+ try {
224
+ parsed = parseReceipt(input.receiptCbor);
225
+ } catch {
226
+ return { ok: false, stage: "parse", reason: "receipt_malformed" };
227
+ }
228
+
229
+ const leafIdx =
230
+ parsed.proof.leafIndex !== undefined
231
+ ? parsed.proof.leafIndex
232
+ : parsed.proof.mmrIndex!;
233
+
234
+ // 1. Newer-than-snapshot fails CLOSED — staleness limits coverage, never
235
+ // validity, so the remedy is a refresh, not a pass.
236
+ if (leafIdx >= input.size) {
237
+ return {
238
+ ok: false,
239
+ stage: "signature",
240
+ reason: "receipt_newer_than_known_accumulator",
241
+ };
242
+ }
243
+
244
+ let recomputedPeak: Uint8Array;
245
+ if (parsed.explicitPeak !== null) {
246
+ recomputedPeak = parsed.explicitPeak;
247
+ } else {
248
+ const hasher = new SubtleHasher();
249
+ const leafHash = await univocityLeafHash(input.idtimestampBe8, input.inner);
250
+ recomputedPeak = await calculateRoot(hasher, leafHash, parsed.proof, leafIdx);
251
+ }
252
+
253
+ // 2. Exact peak match — receipt state is a snapshot-covered accumulator.
254
+ for (const peak of input.accumulator) {
255
+ if (bytesEqual(recomputedPeak, peak)) {
256
+ return { ok: true, stage: "binding" };
257
+ }
258
+ }
259
+
260
+ return {
261
+ ok: false,
262
+ stage: "signature",
263
+ reason: "peak_not_in_known_accumulator",
264
+ };
265
+ }