@inversealtruism/csd-light 0.1.1 → 0.1.2

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/README.md ADDED
@@ -0,0 +1,11 @@
1
+ # @inversealtruism/csd-light
2
+
3
+ Light client: headers-first sync with client-side PoW + LWMA + chainwork verification, merkle-inclusion proofs, reorg + checkpoint-start, and a verified-RPC facade with an honest trustLevel.
4
+
5
+ Part of the [Compute Substrate SDK](https://github.com/InverseAltruism/csd-sdk) (L0). Zero `Buffer` — runs in Node, browsers, and MV3 service workers. Deps: `@noble/*` only.
6
+
7
+ ```
8
+ npm i @inversealtruism/csd-light
9
+ ```
10
+
11
+ See the [repo README](https://github.com/InverseAltruism/csd-sdk#readme) and [examples/quickstart.mjs](https://github.com/InverseAltruism/csd-sdk/blob/master/examples/quickstart.mjs) for usage. MIT.
package/dist/index.cjs CHANGED
@@ -23,6 +23,7 @@ __export(index_exports, {
23
23
  CsdClient: () => import_csd_client2.CsdClient,
24
24
  LightClient: () => LightClient,
25
25
  expectedBits: () => expectedBits,
26
+ expectedBitsFromWindow: () => expectedBitsFromWindow,
26
27
  rpcHeaderToHeader: () => import_csd_client2.rpcHeaderToHeader
27
28
  });
28
29
  module.exports = __toCommonJS(index_exports);
@@ -32,29 +33,23 @@ var import_csd_client = require("@inversealtruism/csd-client");
32
33
  // src/lwma.ts
33
34
  var import_csd_codec = require("@inversealtruism/csd-codec");
34
35
  var POW_LIMIT_TARGET = (0, import_csd_codec.targetToBigInt)((0, import_csd_codec.bitsToTarget)(import_csd_codec.POW_LIMIT_BITS));
35
- function expectedBits(headers, height) {
36
+ function expectedBitsFromWindow(window, height) {
36
37
  if (height === 0) return import_csd_codec.INITIAL_BITS;
37
- const parent = headers[height - 1];
38
- if (!parent) throw new Error(`expectedBits: missing parent for height ${height}`);
38
+ const parent = window[window.length - 1];
39
+ if (!parent) throw new Error(`expectedBits: empty window for height ${height}`);
39
40
  if (height < 2) return parent.bits;
40
- let n = Math.min(import_csd_codec.LWMA_WINDOW, height);
41
+ const n = Math.min(import_csd_codec.LWMA_WINDOW, height, window.length);
41
42
  if (n < 2) return parent.bits;
42
- if (n > 1e3) n = 1e3;
43
+ const w = window.slice(window.length - n);
43
44
  const times = [];
44
45
  const targets = [];
45
- for (let i = 0; i < n; i++) {
46
- const idx = height - 1 - i;
47
- if (idx < 0) break;
48
- const h = headers[idx];
46
+ for (const h of w) {
49
47
  const tb = (0, import_csd_codec.bitsToTarget)(h.bits);
50
48
  if (tb.every((b) => b === 0)) throw new Error("expectedBits: invalid compact bits in window");
51
49
  times.push(BigInt(h.time));
52
50
  targets.push((0, import_csd_codec.targetToBigInt)(tb));
53
- if (idx === 0) break;
54
51
  }
55
52
  if (times.length < 2) return parent.bits;
56
- times.reverse();
57
- targets.reverse();
58
53
  const m = times.length;
59
54
  const t = BigInt(Math.max(import_csd_codec.TARGET_BLOCK_SECS, 1));
60
55
  const maxSolve = BigInt(Math.max(import_csd_codec.LWMA_SOLVETIME_MAX_FACTOR, 1) * Math.max(import_csd_codec.TARGET_BLOCK_SECS, 1));
@@ -63,9 +58,9 @@ function expectedBits(headers, height) {
63
58
  let dt = times[i] - times[i - 1];
64
59
  if (dt < 0n) dt = 0n;
65
60
  const st = dt < 1n ? 1n : dt > maxSolve ? maxSolve : dt;
66
- const w = BigInt(i);
67
- weightedSum += st * w;
68
- denom += w;
61
+ const ww = BigInt(i);
62
+ weightedSum += st * ww;
63
+ denom += ww;
69
64
  }
70
65
  if (denom === 0n) return parent.bits;
71
66
  const avgSolvetime = weightedSum / denom;
@@ -79,6 +74,12 @@ function expectedBits(headers, height) {
79
74
  if ((0, import_csd_codec.targetToBigInt)((0, import_csd_codec.bitsToTarget)(bits)) > POW_LIMIT_TARGET) return import_csd_codec.POW_LIMIT_BITS;
80
75
  return bits;
81
76
  }
77
+ function expectedBits(headers, height) {
78
+ if (height === 0) return import_csd_codec.INITIAL_BITS;
79
+ const n = Math.min(import_csd_codec.LWMA_WINDOW, height);
80
+ const window = headers.slice(height - n, height);
81
+ return expectedBitsFromWindow(window, height);
82
+ }
82
83
 
83
84
  // src/index.ts
84
85
  var import_csd_client2 = require("@inversealtruism/csd-client");
@@ -86,8 +87,10 @@ var LightClient = class {
86
87
  client;
87
88
  provider;
88
89
  checkpoints;
89
- /** Verified header chain, index = height. */
90
+ /** Verified header chain. chain[i].height = baseHeight + i. */
90
91
  chain = [];
92
+ /** Height of chain[0] — 0 for genesis-start, the seed start for checkpoint-start. */
93
+ baseHeight = 0;
91
94
  constructor(opts = {}) {
92
95
  this.client = opts.client ?? (opts.baseUrl ? new import_csd_client.CsdClient({ baseUrl: opts.baseUrl }) : void 0);
93
96
  this.checkpoints = opts.checkpoints ?? {};
@@ -103,12 +106,22 @@ var LightClient = class {
103
106
  get chainwork() {
104
107
  return this.tip?.chainwork ?? 0n;
105
108
  }
106
- /**
107
- * Sync + VERIFY headers [from..to] inclusive onto the chain. `from` must be 0 (genesis) or
108
- * exactly chain.length (contiguous). Throws on any consensus violation. Returns the new tip.
109
- */
110
- async sync(to, from = this.chain.length) {
111
- if (from !== this.chain.length) throw new Error(`non-contiguous sync: have ${this.chain.length}, asked from ${from}`);
109
+ /** Whether every header back to genesis was verified (vs trusted from a checkpoint). */
110
+ get fullyVerified() {
111
+ return this.baseHeight === 0;
112
+ }
113
+ at(height) {
114
+ return this.chain[height - this.baseHeight];
115
+ }
116
+ /** The chronological LWMA window (≤ LWMA_WINDOW headers) immediately preceding `height`. */
117
+ windowBefore(height) {
118
+ const startIdx = Math.max(0, height - this.baseHeight - import_csd_codec2.LWMA_WINDOW);
119
+ const endIdx = height - this.baseHeight;
120
+ return this.chain.slice(startIdx, endIdx).map((c) => c.header);
121
+ }
122
+ /** Sync + VERIFY headers [from..to] from genesis (or contiguous to the current tip). */
123
+ async sync(to, from = this.baseHeight + this.chain.length) {
124
+ if (from !== this.baseHeight + this.chain.length) throw new Error(`non-contiguous sync: tip ${this.baseHeight + this.chain.length - 1}, asked from ${from}`);
112
125
  for (let h = from; h <= to; h++) {
113
126
  const { header, hash } = await this.provider(h);
114
127
  this.ingest(h, header, hash);
@@ -116,27 +129,99 @@ var LightClient = class {
116
129
  if (!this.tip) throw new Error("sync produced no tip");
117
130
  return this.tip;
118
131
  }
119
- /** Verify a single header at the given height and append it (consensus checks). */
132
+ /** Verify a single header at `height` and append it (full consensus checks). */
120
133
  ingest(height, header, claimedHash) {
121
- if (height !== this.chain.length) throw new Error(`out-of-order ingest at ${height} (have ${this.chain.length})`);
134
+ if (height !== this.baseHeight + this.chain.length) throw new Error(`out-of-order ingest at ${height} (tip ${this.baseHeight + this.chain.length - 1})`);
135
+ const vh = this.verifyOne(height, header, this.windowBefore(height), this.at(height - 1), claimedHash);
136
+ this.chain.push(vh);
137
+ return vh;
138
+ }
139
+ /** Pure verification of one header against a window + parent (no mutation). */
140
+ verifyOne(height, header, window, parent, claimedHash) {
122
141
  const hash = (0, import_csd_codec2.headerHash)(header);
123
142
  if (claimedHash && claimedHash.toLowerCase() !== hash.toLowerCase()) throw new Error(`header hash mismatch at ${height}`);
124
143
  if (height === 0) {
125
144
  if (hash.toLowerCase() !== import_csd_codec2.GENESIS_HASH.toLowerCase()) throw new Error(`foreign genesis: ${hash}`);
126
145
  if (header.bits !== import_csd_codec2.INITIAL_BITS) throw new Error("genesis bits != INITIAL_BITS");
127
146
  } else {
128
- const parent = this.chain[height - 1];
147
+ if (!parent) throw new Error(`no parent context for height ${height}`);
129
148
  if (header.prev.toLowerCase() !== parent.hash.toLowerCase()) throw new Error(`broken prev link at ${height}`);
130
- const exp = expectedBits(this.chain.map((c) => c.header), height);
149
+ const exp = expectedBitsFromWindow(window, height);
131
150
  if (header.bits !== exp) throw new Error(`bad bits at ${height}: header ${header.bits.toString(16)} != LWMA ${exp.toString(16)}`);
132
151
  }
133
152
  if (!(0, import_csd_codec2.powOk)((0, import_csd_codec2.headerHashBytes)(header), header.bits)) throw new Error(`invalid PoW at ${height}`);
134
153
  const cp = this.checkpoints[height];
135
154
  if (cp && cp.toLowerCase() !== hash.toLowerCase()) throw new Error(`checkpoint mismatch at ${height}`);
136
- const chainwork = (this.chain[height - 1]?.chainwork ?? 0n) + (0, import_csd_codec2.workForBits)(header.bits);
137
- const vh = { height, hash, header, chainwork };
138
- this.chain.push(vh);
139
- return vh;
155
+ return { height, hash, header, chainwork: (parent?.chainwork ?? 0n) + (0, import_csd_codec2.workForBits)(header.bits) };
156
+ }
157
+ /**
158
+ * Seed a TRUSTED, contiguous header run ending at a pinned checkpoint, so forward sync needs
159
+ * only a small window — not a 27k-block genesis fetch. The seed is the trust anchor (PoW links
160
+ * are still spot-checked, but seed bits aren't LWMA-re-derived; that's the explicit trade for
161
+ * not syncing from genesis). chainwork becomes RELATIVE to the seed. `checkpointHash` MUST match
162
+ * the last seeded header.
163
+ */
164
+ seedTrusted(seed, checkpointHash) {
165
+ if (this.chain.length) throw new Error("seedTrusted must be called on a fresh client");
166
+ if (!seed.length) throw new Error("empty seed");
167
+ this.baseHeight = seed[0].height;
168
+ let prevHash = null;
169
+ for (let i = 0; i < seed.length; i++) {
170
+ const s = seed[i];
171
+ if (s.height !== this.baseHeight + i) throw new Error("seed not contiguous");
172
+ const hash = (0, import_csd_codec2.headerHash)(s.header);
173
+ if (s.hash && s.hash.toLowerCase() !== hash.toLowerCase()) throw new Error(`seed header hash mismatch at ${s.height}`);
174
+ if (prevHash && s.header.prev.toLowerCase() !== prevHash.toLowerCase()) throw new Error(`seed prev link broken at ${s.height}`);
175
+ if (!(0, import_csd_codec2.powOk)((0, import_csd_codec2.headerHashBytes)(s.header), s.header.bits)) throw new Error(`seed PoW invalid at ${s.height}`);
176
+ this.chain.push({ height: s.height, hash, header: s.header, chainwork: (this.chain[i - 1]?.chainwork ?? 0n) + (0, import_csd_codec2.workForBits)(s.header.bits), trusted: true });
177
+ prevHash = hash;
178
+ }
179
+ if (this.tip.hash.toLowerCase() !== checkpointHash.toLowerCase()) throw new Error(`checkpoint hash mismatch: seeded tip ${this.tip.hash} != ${checkpointHash}`);
180
+ }
181
+ /** Fetch + seed the LWMA window ending at `checkpointHeight`, asserting its hash, then ready to sync forward. */
182
+ async syncFromCheckpoint(checkpointHeight, checkpointHash, context = import_csd_codec2.LWMA_WINDOW) {
183
+ const start = Math.max(0, checkpointHeight - context);
184
+ const seed = [];
185
+ for (let h = start; h <= checkpointHeight; h++) {
186
+ const { header, hash } = await this.provider(h);
187
+ seed.push({ height: h, header, hash });
188
+ }
189
+ this.seedTrusted(seed, checkpointHash);
190
+ }
191
+ /**
192
+ * Offer a competing branch (contiguous headers starting one above a common ancestor we hold).
193
+ * Verifies it from the ancestor; if its cumulative chainwork EXCEEDS our current tip, we roll
194
+ * back to the ancestor and adopt it (max-work rule). Otherwise we keep our chain.
195
+ */
196
+ tryReorg(alt) {
197
+ if (!alt.length) return { adopted: false, reason: "empty branch" };
198
+ const ancestorHeight = alt[0].height - 1;
199
+ const ancestor = this.at(ancestorHeight);
200
+ if (!ancestor) return { adopted: false, reason: `no common ancestor at ${ancestorHeight}` };
201
+ const verified = [];
202
+ let prev = ancestor;
203
+ const baseWindow = this.windowBefore(ancestorHeight + 1);
204
+ const window = [...baseWindow];
205
+ for (let i = 0; i < alt.length; i++) {
206
+ const a = alt[i];
207
+ if (a.height !== ancestorHeight + 1 + i) return { adopted: false, reason: "alt not contiguous" };
208
+ let vh;
209
+ try {
210
+ vh = this.verifyOne(a.height, a.header, window, prev, a.hash);
211
+ } catch (e) {
212
+ return { adopted: false, reason: `alt invalid at ${a.height}: ${e?.message}` };
213
+ }
214
+ verified.push(vh);
215
+ prev = vh;
216
+ window.push(a.header);
217
+ if (window.length > import_csd_codec2.LWMA_WINDOW) window.shift();
218
+ }
219
+ const altTip = verified[verified.length - 1];
220
+ if (altTip.chainwork <= this.chainwork) return { adopted: false, reason: `alt work ${altTip.chainwork} \u2264 current ${this.chainwork}` };
221
+ const rolledBack = this.baseHeight + this.chain.length - 1 - ancestorHeight;
222
+ this.chain.length = ancestorHeight - this.baseHeight + 1;
223
+ for (const v of verified) this.chain.push(v);
224
+ return { adopted: true, rolledBack, newTip: altTip.height };
140
225
  }
141
226
  /** Verify a tx's inclusion against a verified header (merkle proof built from the block). */
142
227
  async verifyTxInclusion(txidHex) {
@@ -144,26 +229,26 @@ var LightClient = class {
144
229
  const t = await this.client.tx(txidHex);
145
230
  if (!t.ok || t.height == null) return { trustLevel: "rpc-trusted", included: false, reason: "tx not in a block (mempool/unknown)" };
146
231
  const height = t.height;
147
- if (height >= this.chain.length) {
148
- const gap = height - this.chain.length + 1;
149
- if (gap > 256) return { trustLevel: "rpc-trusted", included: false, reason: `tx at height ${height} is ${gap} blocks beyond the synced tip \u2014 call sync(${height}) first` };
232
+ const tipHeight = this.baseHeight + this.chain.length - 1;
233
+ if (height < this.baseHeight) return { trustLevel: "rpc-trusted", included: false, reason: `tx below the synced base (${this.baseHeight})` };
234
+ if (height > tipHeight) {
235
+ const gap = height - tipHeight;
236
+ if (gap > 256) return { trustLevel: "rpc-trusted", included: false, reason: `tx at ${height} is ${gap} blocks beyond tip \u2014 sync(${height}) first` };
150
237
  await this.sync(height);
151
238
  }
152
- const verified = this.chain[height];
239
+ const verified = this.at(height);
153
240
  if (!verified) return { trustLevel: "rpc-trusted", included: false, reason: "could not verify the containing header" };
154
241
  const b = await this.client.blockByHeight(height);
155
242
  const txids = b.txs.map((x) => x.txid);
156
243
  const pos = txids.findIndex((x) => x.toLowerCase() === txidHex.toLowerCase());
157
244
  if (pos < 0) return { trustLevel: "rpc-trusted", included: false, reason: "tx not listed in block" };
158
- const branch = (0, import_csd_codec2.merkleBranch)(txids, pos);
159
- const ok = (0, import_csd_codec2.verifyMerkleProof)(txidHex, pos, branch, verified.header.merkle);
245
+ const ok = (0, import_csd_codec2.verifyMerkleProof)(txidHex, pos, (0, import_csd_codec2.merkleBranch)(txids, pos), verified.header.merkle);
160
246
  if (!ok) return { trustLevel: "rpc-trusted", included: false, reason: "merkle proof failed" };
161
- return { trustLevel: "verified-inclusion", included: true, blockHeight: height, confirmations: this.chain.length - height };
247
+ return { trustLevel: "verified-inclusion", included: true, blockHeight: height, confirmations: tipHeight - height + 1 };
162
248
  }
163
249
  /**
164
- * Balance for an address. HONEST: this is `rpc-trusted` — a header chain cannot prove an output
165
- * is still unspent (no UTXO commitment). A future `scanBalance` will derive it from a Neutrino-
166
- * style block scan (`trustLevel: 'scanned'`). Surfaced, never hidden.
250
+ * Balance for an address. HONEST: `rpc-trusted` — a header chain cannot prove an output is still
251
+ * unspent (no UTXO commitment). A future Neutrino-style scan would yield `trustLevel:'scanned'`.
167
252
  */
168
253
  async balance(addr) {
169
254
  if (!this.client) throw new Error("no client");
@@ -176,5 +261,6 @@ var LightClient = class {
176
261
  CsdClient,
177
262
  LightClient,
178
263
  expectedBits,
264
+ expectedBitsFromWindow,
179
265
  rpcHeaderToHeader
180
266
  });
package/dist/index.d.cts CHANGED
@@ -2,9 +2,16 @@ import { BlockHeader } from '@inversealtruism/csd-codec';
2
2
  import { CsdClient } from '@inversealtruism/csd-client';
3
3
  export { CsdClient, RpcTxJson, rpcHeaderToHeader } from '@inversealtruism/csd-client';
4
4
 
5
+ /**
6
+ * Expected `bits` for the block at `height`, given the CHRONOLOGICAL window of the (up to
7
+ * LWMA_WINDOW) headers immediately preceding it — `window[last]` is the parent (height-1).
8
+ * This is the core; works for any height with only a 45-header window (cheap spot-checks /
9
+ * checkpoint-start, no full chain needed). Mirrors expected_bits_strict exactly.
10
+ */
11
+ declare function expectedBitsFromWindow(window: BlockHeader[], height: number): number;
5
12
  /**
6
13
  * Expected `bits` for the block at `height`, given the canonical chain `headers[0..=parent]`
7
- * (index = height; headers[height-1] is the parent). Mirrors expected_bits_strict exactly.
14
+ * (index = height). Convenience wrapper over expectedBitsFromWindow.
8
15
  */
9
16
  declare function expectedBits(headers: BlockHeader[], height: number): number;
10
17
 
@@ -14,6 +21,7 @@ interface VerifiedHeader {
14
21
  hash: string;
15
22
  header: BlockHeader;
16
23
  chainwork: bigint;
24
+ trusted?: boolean;
17
25
  }
18
26
  interface InclusionResult {
19
27
  trustLevel: TrustLevel;
@@ -22,7 +30,12 @@ interface InclusionResult {
22
30
  confirmations?: number;
23
31
  reason?: string;
24
32
  }
25
- /** A header provider — defaults to a CsdClient, injectable for tests. */
33
+ interface ReorgResult {
34
+ adopted: boolean;
35
+ rolledBack?: number;
36
+ newTip?: number;
37
+ reason?: string;
38
+ }
26
39
  type HeaderProvider = (height: number) => Promise<{
27
40
  header: BlockHeader;
28
41
  hash: string;
@@ -39,24 +52,53 @@ declare class LightClient {
39
52
  private readonly client?;
40
53
  private readonly provider;
41
54
  private readonly checkpoints;
42
- /** Verified header chain, index = height. */
55
+ /** Verified header chain. chain[i].height = baseHeight + i. */
43
56
  readonly chain: VerifiedHeader[];
57
+ /** Height of chain[0] — 0 for genesis-start, the seed start for checkpoint-start. */
58
+ baseHeight: number;
44
59
  constructor(opts?: LightClientOptions);
45
60
  get tip(): VerifiedHeader | undefined;
46
61
  get chainwork(): bigint;
47
- /**
48
- * Sync + VERIFY headers [from..to] inclusive onto the chain. `from` must be 0 (genesis) or
49
- * exactly chain.length (contiguous). Throws on any consensus violation. Returns the new tip.
50
- */
62
+ /** Whether every header back to genesis was verified (vs trusted from a checkpoint). */
63
+ get fullyVerified(): boolean;
64
+ private at;
65
+ /** The chronological LWMA window (≤ LWMA_WINDOW headers) immediately preceding `height`. */
66
+ private windowBefore;
67
+ /** Sync + VERIFY headers [from..to] from genesis (or contiguous to the current tip). */
51
68
  sync(to: number, from?: number): Promise<VerifiedHeader>;
52
- /** Verify a single header at the given height and append it (consensus checks). */
69
+ /** Verify a single header at `height` and append it (full consensus checks). */
53
70
  ingest(height: number, header: BlockHeader, claimedHash?: string): VerifiedHeader;
71
+ /** Pure verification of one header against a window + parent (no mutation). */
72
+ private verifyOne;
73
+ /**
74
+ * Seed a TRUSTED, contiguous header run ending at a pinned checkpoint, so forward sync needs
75
+ * only a small window — not a 27k-block genesis fetch. The seed is the trust anchor (PoW links
76
+ * are still spot-checked, but seed bits aren't LWMA-re-derived; that's the explicit trade for
77
+ * not syncing from genesis). chainwork becomes RELATIVE to the seed. `checkpointHash` MUST match
78
+ * the last seeded header.
79
+ */
80
+ seedTrusted(seed: {
81
+ height: number;
82
+ header: BlockHeader;
83
+ hash?: string;
84
+ }[], checkpointHash: string): void;
85
+ /** Fetch + seed the LWMA window ending at `checkpointHeight`, asserting its hash, then ready to sync forward. */
86
+ syncFromCheckpoint(checkpointHeight: number, checkpointHash: string, context?: number): Promise<void>;
87
+ /**
88
+ * Offer a competing branch (contiguous headers starting one above a common ancestor we hold).
89
+ * Verifies it from the ancestor; if its cumulative chainwork EXCEEDS our current tip, we roll
90
+ * back to the ancestor and adopt it (max-work rule). Otherwise we keep our chain.
91
+ */
92
+ tryReorg(alt: {
93
+ height: number;
94
+ header: BlockHeader;
95
+ hash?: string;
96
+ }[]): ReorgResult;
54
97
  /** Verify a tx's inclusion against a verified header (merkle proof built from the block). */
55
98
  verifyTxInclusion(txidHex: string): Promise<InclusionResult>;
56
99
  /**
57
- * Balance for an address. HONEST: this is `rpc-trusted` — a header chain cannot prove an output
58
- * is still unspent (no UTXO commitment). A future `scanBalance` will derive it from a Neutrino-
59
- * style block scan (`trustLevel: 'scanned'`). Surfaced, never hidden.
100
+ * Balance for an address. HONEST: `rpc-trusted` — a header chain cannot prove an output is still
101
+ * unspent (no UTXO commitment). A future Neutrino-style scan would yield `trustLevel:'scanned'`.
60
102
  */
61
103
  balance(addr: string): Promise<{
62
104
  confirmed: number;
@@ -65,4 +107,4 @@ declare class LightClient {
65
107
  }>;
66
108
  }
67
109
 
68
- export { type HeaderProvider, type InclusionResult, LightClient, type LightClientOptions, type TrustLevel, type VerifiedHeader, expectedBits };
110
+ export { type HeaderProvider, type InclusionResult, LightClient, type LightClientOptions, type ReorgResult, type TrustLevel, type VerifiedHeader, expectedBits, expectedBitsFromWindow };
package/dist/index.d.ts CHANGED
@@ -2,9 +2,16 @@ import { BlockHeader } from '@inversealtruism/csd-codec';
2
2
  import { CsdClient } from '@inversealtruism/csd-client';
3
3
  export { CsdClient, RpcTxJson, rpcHeaderToHeader } from '@inversealtruism/csd-client';
4
4
 
5
+ /**
6
+ * Expected `bits` for the block at `height`, given the CHRONOLOGICAL window of the (up to
7
+ * LWMA_WINDOW) headers immediately preceding it — `window[last]` is the parent (height-1).
8
+ * This is the core; works for any height with only a 45-header window (cheap spot-checks /
9
+ * checkpoint-start, no full chain needed). Mirrors expected_bits_strict exactly.
10
+ */
11
+ declare function expectedBitsFromWindow(window: BlockHeader[], height: number): number;
5
12
  /**
6
13
  * Expected `bits` for the block at `height`, given the canonical chain `headers[0..=parent]`
7
- * (index = height; headers[height-1] is the parent). Mirrors expected_bits_strict exactly.
14
+ * (index = height). Convenience wrapper over expectedBitsFromWindow.
8
15
  */
9
16
  declare function expectedBits(headers: BlockHeader[], height: number): number;
10
17
 
@@ -14,6 +21,7 @@ interface VerifiedHeader {
14
21
  hash: string;
15
22
  header: BlockHeader;
16
23
  chainwork: bigint;
24
+ trusted?: boolean;
17
25
  }
18
26
  interface InclusionResult {
19
27
  trustLevel: TrustLevel;
@@ -22,7 +30,12 @@ interface InclusionResult {
22
30
  confirmations?: number;
23
31
  reason?: string;
24
32
  }
25
- /** A header provider — defaults to a CsdClient, injectable for tests. */
33
+ interface ReorgResult {
34
+ adopted: boolean;
35
+ rolledBack?: number;
36
+ newTip?: number;
37
+ reason?: string;
38
+ }
26
39
  type HeaderProvider = (height: number) => Promise<{
27
40
  header: BlockHeader;
28
41
  hash: string;
@@ -39,24 +52,53 @@ declare class LightClient {
39
52
  private readonly client?;
40
53
  private readonly provider;
41
54
  private readonly checkpoints;
42
- /** Verified header chain, index = height. */
55
+ /** Verified header chain. chain[i].height = baseHeight + i. */
43
56
  readonly chain: VerifiedHeader[];
57
+ /** Height of chain[0] — 0 for genesis-start, the seed start for checkpoint-start. */
58
+ baseHeight: number;
44
59
  constructor(opts?: LightClientOptions);
45
60
  get tip(): VerifiedHeader | undefined;
46
61
  get chainwork(): bigint;
47
- /**
48
- * Sync + VERIFY headers [from..to] inclusive onto the chain. `from` must be 0 (genesis) or
49
- * exactly chain.length (contiguous). Throws on any consensus violation. Returns the new tip.
50
- */
62
+ /** Whether every header back to genesis was verified (vs trusted from a checkpoint). */
63
+ get fullyVerified(): boolean;
64
+ private at;
65
+ /** The chronological LWMA window (≤ LWMA_WINDOW headers) immediately preceding `height`. */
66
+ private windowBefore;
67
+ /** Sync + VERIFY headers [from..to] from genesis (or contiguous to the current tip). */
51
68
  sync(to: number, from?: number): Promise<VerifiedHeader>;
52
- /** Verify a single header at the given height and append it (consensus checks). */
69
+ /** Verify a single header at `height` and append it (full consensus checks). */
53
70
  ingest(height: number, header: BlockHeader, claimedHash?: string): VerifiedHeader;
71
+ /** Pure verification of one header against a window + parent (no mutation). */
72
+ private verifyOne;
73
+ /**
74
+ * Seed a TRUSTED, contiguous header run ending at a pinned checkpoint, so forward sync needs
75
+ * only a small window — not a 27k-block genesis fetch. The seed is the trust anchor (PoW links
76
+ * are still spot-checked, but seed bits aren't LWMA-re-derived; that's the explicit trade for
77
+ * not syncing from genesis). chainwork becomes RELATIVE to the seed. `checkpointHash` MUST match
78
+ * the last seeded header.
79
+ */
80
+ seedTrusted(seed: {
81
+ height: number;
82
+ header: BlockHeader;
83
+ hash?: string;
84
+ }[], checkpointHash: string): void;
85
+ /** Fetch + seed the LWMA window ending at `checkpointHeight`, asserting its hash, then ready to sync forward. */
86
+ syncFromCheckpoint(checkpointHeight: number, checkpointHash: string, context?: number): Promise<void>;
87
+ /**
88
+ * Offer a competing branch (contiguous headers starting one above a common ancestor we hold).
89
+ * Verifies it from the ancestor; if its cumulative chainwork EXCEEDS our current tip, we roll
90
+ * back to the ancestor and adopt it (max-work rule). Otherwise we keep our chain.
91
+ */
92
+ tryReorg(alt: {
93
+ height: number;
94
+ header: BlockHeader;
95
+ hash?: string;
96
+ }[]): ReorgResult;
54
97
  /** Verify a tx's inclusion against a verified header (merkle proof built from the block). */
55
98
  verifyTxInclusion(txidHex: string): Promise<InclusionResult>;
56
99
  /**
57
- * Balance for an address. HONEST: this is `rpc-trusted` — a header chain cannot prove an output
58
- * is still unspent (no UTXO commitment). A future `scanBalance` will derive it from a Neutrino-
59
- * style block scan (`trustLevel: 'scanned'`). Surfaced, never hidden.
100
+ * Balance for an address. HONEST: `rpc-trusted` — a header chain cannot prove an output is still
101
+ * unspent (no UTXO commitment). A future Neutrino-style scan would yield `trustLevel:'scanned'`.
60
102
  */
61
103
  balance(addr: string): Promise<{
62
104
  confirmed: number;
@@ -65,4 +107,4 @@ declare class LightClient {
65
107
  }>;
66
108
  }
67
109
 
68
- export { type HeaderProvider, type InclusionResult, LightClient, type LightClientOptions, type TrustLevel, type VerifiedHeader, expectedBits };
110
+ export { type HeaderProvider, type InclusionResult, LightClient, type LightClientOptions, type ReorgResult, type TrustLevel, type VerifiedHeader, expectedBits, expectedBitsFromWindow };
package/dist/index.js CHANGED
@@ -7,7 +7,8 @@ import {
7
7
  verifyMerkleProof,
8
8
  merkleBranch,
9
9
  GENESIS_HASH,
10
- INITIAL_BITS as INITIAL_BITS2
10
+ INITIAL_BITS as INITIAL_BITS2,
11
+ LWMA_WINDOW as LWMA_WINDOW2
11
12
  } from "@inversealtruism/csd-codec";
12
13
  import { CsdClient, rpcHeaderToHeader } from "@inversealtruism/csd-client";
13
14
 
@@ -24,29 +25,23 @@ import {
24
25
  TARGET_BLOCK_SECS
25
26
  } from "@inversealtruism/csd-codec";
26
27
  var POW_LIMIT_TARGET = targetToBigInt(bitsToTarget(POW_LIMIT_BITS));
27
- function expectedBits(headers, height) {
28
+ function expectedBitsFromWindow(window, height) {
28
29
  if (height === 0) return INITIAL_BITS;
29
- const parent = headers[height - 1];
30
- if (!parent) throw new Error(`expectedBits: missing parent for height ${height}`);
30
+ const parent = window[window.length - 1];
31
+ if (!parent) throw new Error(`expectedBits: empty window for height ${height}`);
31
32
  if (height < 2) return parent.bits;
32
- let n = Math.min(LWMA_WINDOW, height);
33
+ const n = Math.min(LWMA_WINDOW, height, window.length);
33
34
  if (n < 2) return parent.bits;
34
- if (n > 1e3) n = 1e3;
35
+ const w = window.slice(window.length - n);
35
36
  const times = [];
36
37
  const targets = [];
37
- for (let i = 0; i < n; i++) {
38
- const idx = height - 1 - i;
39
- if (idx < 0) break;
40
- const h = headers[idx];
38
+ for (const h of w) {
41
39
  const tb = bitsToTarget(h.bits);
42
40
  if (tb.every((b) => b === 0)) throw new Error("expectedBits: invalid compact bits in window");
43
41
  times.push(BigInt(h.time));
44
42
  targets.push(targetToBigInt(tb));
45
- if (idx === 0) break;
46
43
  }
47
44
  if (times.length < 2) return parent.bits;
48
- times.reverse();
49
- targets.reverse();
50
45
  const m = times.length;
51
46
  const t = BigInt(Math.max(TARGET_BLOCK_SECS, 1));
52
47
  const maxSolve = BigInt(Math.max(LWMA_SOLVETIME_MAX_FACTOR, 1) * Math.max(TARGET_BLOCK_SECS, 1));
@@ -55,9 +50,9 @@ function expectedBits(headers, height) {
55
50
  let dt = times[i] - times[i - 1];
56
51
  if (dt < 0n) dt = 0n;
57
52
  const st = dt < 1n ? 1n : dt > maxSolve ? maxSolve : dt;
58
- const w = BigInt(i);
59
- weightedSum += st * w;
60
- denom += w;
53
+ const ww = BigInt(i);
54
+ weightedSum += st * ww;
55
+ denom += ww;
61
56
  }
62
57
  if (denom === 0n) return parent.bits;
63
58
  const avgSolvetime = weightedSum / denom;
@@ -71,6 +66,12 @@ function expectedBits(headers, height) {
71
66
  if (targetToBigInt(bitsToTarget(bits)) > POW_LIMIT_TARGET) return POW_LIMIT_BITS;
72
67
  return bits;
73
68
  }
69
+ function expectedBits(headers, height) {
70
+ if (height === 0) return INITIAL_BITS;
71
+ const n = Math.min(LWMA_WINDOW, height);
72
+ const window = headers.slice(height - n, height);
73
+ return expectedBitsFromWindow(window, height);
74
+ }
74
75
 
75
76
  // src/index.ts
76
77
  import { CsdClient as CsdClient2, rpcHeaderToHeader as rpcHeaderToHeader2 } from "@inversealtruism/csd-client";
@@ -78,8 +79,10 @@ var LightClient = class {
78
79
  client;
79
80
  provider;
80
81
  checkpoints;
81
- /** Verified header chain, index = height. */
82
+ /** Verified header chain. chain[i].height = baseHeight + i. */
82
83
  chain = [];
84
+ /** Height of chain[0] — 0 for genesis-start, the seed start for checkpoint-start. */
85
+ baseHeight = 0;
83
86
  constructor(opts = {}) {
84
87
  this.client = opts.client ?? (opts.baseUrl ? new CsdClient({ baseUrl: opts.baseUrl }) : void 0);
85
88
  this.checkpoints = opts.checkpoints ?? {};
@@ -95,12 +98,22 @@ var LightClient = class {
95
98
  get chainwork() {
96
99
  return this.tip?.chainwork ?? 0n;
97
100
  }
98
- /**
99
- * Sync + VERIFY headers [from..to] inclusive onto the chain. `from` must be 0 (genesis) or
100
- * exactly chain.length (contiguous). Throws on any consensus violation. Returns the new tip.
101
- */
102
- async sync(to, from = this.chain.length) {
103
- if (from !== this.chain.length) throw new Error(`non-contiguous sync: have ${this.chain.length}, asked from ${from}`);
101
+ /** Whether every header back to genesis was verified (vs trusted from a checkpoint). */
102
+ get fullyVerified() {
103
+ return this.baseHeight === 0;
104
+ }
105
+ at(height) {
106
+ return this.chain[height - this.baseHeight];
107
+ }
108
+ /** The chronological LWMA window (≤ LWMA_WINDOW headers) immediately preceding `height`. */
109
+ windowBefore(height) {
110
+ const startIdx = Math.max(0, height - this.baseHeight - LWMA_WINDOW2);
111
+ const endIdx = height - this.baseHeight;
112
+ return this.chain.slice(startIdx, endIdx).map((c) => c.header);
113
+ }
114
+ /** Sync + VERIFY headers [from..to] from genesis (or contiguous to the current tip). */
115
+ async sync(to, from = this.baseHeight + this.chain.length) {
116
+ if (from !== this.baseHeight + this.chain.length) throw new Error(`non-contiguous sync: tip ${this.baseHeight + this.chain.length - 1}, asked from ${from}`);
104
117
  for (let h = from; h <= to; h++) {
105
118
  const { header, hash } = await this.provider(h);
106
119
  this.ingest(h, header, hash);
@@ -108,27 +121,99 @@ var LightClient = class {
108
121
  if (!this.tip) throw new Error("sync produced no tip");
109
122
  return this.tip;
110
123
  }
111
- /** Verify a single header at the given height and append it (consensus checks). */
124
+ /** Verify a single header at `height` and append it (full consensus checks). */
112
125
  ingest(height, header, claimedHash) {
113
- if (height !== this.chain.length) throw new Error(`out-of-order ingest at ${height} (have ${this.chain.length})`);
126
+ if (height !== this.baseHeight + this.chain.length) throw new Error(`out-of-order ingest at ${height} (tip ${this.baseHeight + this.chain.length - 1})`);
127
+ const vh = this.verifyOne(height, header, this.windowBefore(height), this.at(height - 1), claimedHash);
128
+ this.chain.push(vh);
129
+ return vh;
130
+ }
131
+ /** Pure verification of one header against a window + parent (no mutation). */
132
+ verifyOne(height, header, window, parent, claimedHash) {
114
133
  const hash = headerHash(header);
115
134
  if (claimedHash && claimedHash.toLowerCase() !== hash.toLowerCase()) throw new Error(`header hash mismatch at ${height}`);
116
135
  if (height === 0) {
117
136
  if (hash.toLowerCase() !== GENESIS_HASH.toLowerCase()) throw new Error(`foreign genesis: ${hash}`);
118
137
  if (header.bits !== INITIAL_BITS2) throw new Error("genesis bits != INITIAL_BITS");
119
138
  } else {
120
- const parent = this.chain[height - 1];
139
+ if (!parent) throw new Error(`no parent context for height ${height}`);
121
140
  if (header.prev.toLowerCase() !== parent.hash.toLowerCase()) throw new Error(`broken prev link at ${height}`);
122
- const exp = expectedBits(this.chain.map((c) => c.header), height);
141
+ const exp = expectedBitsFromWindow(window, height);
123
142
  if (header.bits !== exp) throw new Error(`bad bits at ${height}: header ${header.bits.toString(16)} != LWMA ${exp.toString(16)}`);
124
143
  }
125
144
  if (!powOk(headerHashBytes(header), header.bits)) throw new Error(`invalid PoW at ${height}`);
126
145
  const cp = this.checkpoints[height];
127
146
  if (cp && cp.toLowerCase() !== hash.toLowerCase()) throw new Error(`checkpoint mismatch at ${height}`);
128
- const chainwork = (this.chain[height - 1]?.chainwork ?? 0n) + workForBits(header.bits);
129
- const vh = { height, hash, header, chainwork };
130
- this.chain.push(vh);
131
- return vh;
147
+ return { height, hash, header, chainwork: (parent?.chainwork ?? 0n) + workForBits(header.bits) };
148
+ }
149
+ /**
150
+ * Seed a TRUSTED, contiguous header run ending at a pinned checkpoint, so forward sync needs
151
+ * only a small window — not a 27k-block genesis fetch. The seed is the trust anchor (PoW links
152
+ * are still spot-checked, but seed bits aren't LWMA-re-derived; that's the explicit trade for
153
+ * not syncing from genesis). chainwork becomes RELATIVE to the seed. `checkpointHash` MUST match
154
+ * the last seeded header.
155
+ */
156
+ seedTrusted(seed, checkpointHash) {
157
+ if (this.chain.length) throw new Error("seedTrusted must be called on a fresh client");
158
+ if (!seed.length) throw new Error("empty seed");
159
+ this.baseHeight = seed[0].height;
160
+ let prevHash = null;
161
+ for (let i = 0; i < seed.length; i++) {
162
+ const s = seed[i];
163
+ if (s.height !== this.baseHeight + i) throw new Error("seed not contiguous");
164
+ const hash = headerHash(s.header);
165
+ if (s.hash && s.hash.toLowerCase() !== hash.toLowerCase()) throw new Error(`seed header hash mismatch at ${s.height}`);
166
+ if (prevHash && s.header.prev.toLowerCase() !== prevHash.toLowerCase()) throw new Error(`seed prev link broken at ${s.height}`);
167
+ if (!powOk(headerHashBytes(s.header), s.header.bits)) throw new Error(`seed PoW invalid at ${s.height}`);
168
+ this.chain.push({ height: s.height, hash, header: s.header, chainwork: (this.chain[i - 1]?.chainwork ?? 0n) + workForBits(s.header.bits), trusted: true });
169
+ prevHash = hash;
170
+ }
171
+ if (this.tip.hash.toLowerCase() !== checkpointHash.toLowerCase()) throw new Error(`checkpoint hash mismatch: seeded tip ${this.tip.hash} != ${checkpointHash}`);
172
+ }
173
+ /** Fetch + seed the LWMA window ending at `checkpointHeight`, asserting its hash, then ready to sync forward. */
174
+ async syncFromCheckpoint(checkpointHeight, checkpointHash, context = LWMA_WINDOW2) {
175
+ const start = Math.max(0, checkpointHeight - context);
176
+ const seed = [];
177
+ for (let h = start; h <= checkpointHeight; h++) {
178
+ const { header, hash } = await this.provider(h);
179
+ seed.push({ height: h, header, hash });
180
+ }
181
+ this.seedTrusted(seed, checkpointHash);
182
+ }
183
+ /**
184
+ * Offer a competing branch (contiguous headers starting one above a common ancestor we hold).
185
+ * Verifies it from the ancestor; if its cumulative chainwork EXCEEDS our current tip, we roll
186
+ * back to the ancestor and adopt it (max-work rule). Otherwise we keep our chain.
187
+ */
188
+ tryReorg(alt) {
189
+ if (!alt.length) return { adopted: false, reason: "empty branch" };
190
+ const ancestorHeight = alt[0].height - 1;
191
+ const ancestor = this.at(ancestorHeight);
192
+ if (!ancestor) return { adopted: false, reason: `no common ancestor at ${ancestorHeight}` };
193
+ const verified = [];
194
+ let prev = ancestor;
195
+ const baseWindow = this.windowBefore(ancestorHeight + 1);
196
+ const window = [...baseWindow];
197
+ for (let i = 0; i < alt.length; i++) {
198
+ const a = alt[i];
199
+ if (a.height !== ancestorHeight + 1 + i) return { adopted: false, reason: "alt not contiguous" };
200
+ let vh;
201
+ try {
202
+ vh = this.verifyOne(a.height, a.header, window, prev, a.hash);
203
+ } catch (e) {
204
+ return { adopted: false, reason: `alt invalid at ${a.height}: ${e?.message}` };
205
+ }
206
+ verified.push(vh);
207
+ prev = vh;
208
+ window.push(a.header);
209
+ if (window.length > LWMA_WINDOW2) window.shift();
210
+ }
211
+ const altTip = verified[verified.length - 1];
212
+ if (altTip.chainwork <= this.chainwork) return { adopted: false, reason: `alt work ${altTip.chainwork} \u2264 current ${this.chainwork}` };
213
+ const rolledBack = this.baseHeight + this.chain.length - 1 - ancestorHeight;
214
+ this.chain.length = ancestorHeight - this.baseHeight + 1;
215
+ for (const v of verified) this.chain.push(v);
216
+ return { adopted: true, rolledBack, newTip: altTip.height };
132
217
  }
133
218
  /** Verify a tx's inclusion against a verified header (merkle proof built from the block). */
134
219
  async verifyTxInclusion(txidHex) {
@@ -136,26 +221,26 @@ var LightClient = class {
136
221
  const t = await this.client.tx(txidHex);
137
222
  if (!t.ok || t.height == null) return { trustLevel: "rpc-trusted", included: false, reason: "tx not in a block (mempool/unknown)" };
138
223
  const height = t.height;
139
- if (height >= this.chain.length) {
140
- const gap = height - this.chain.length + 1;
141
- if (gap > 256) return { trustLevel: "rpc-trusted", included: false, reason: `tx at height ${height} is ${gap} blocks beyond the synced tip \u2014 call sync(${height}) first` };
224
+ const tipHeight = this.baseHeight + this.chain.length - 1;
225
+ if (height < this.baseHeight) return { trustLevel: "rpc-trusted", included: false, reason: `tx below the synced base (${this.baseHeight})` };
226
+ if (height > tipHeight) {
227
+ const gap = height - tipHeight;
228
+ if (gap > 256) return { trustLevel: "rpc-trusted", included: false, reason: `tx at ${height} is ${gap} blocks beyond tip \u2014 sync(${height}) first` };
142
229
  await this.sync(height);
143
230
  }
144
- const verified = this.chain[height];
231
+ const verified = this.at(height);
145
232
  if (!verified) return { trustLevel: "rpc-trusted", included: false, reason: "could not verify the containing header" };
146
233
  const b = await this.client.blockByHeight(height);
147
234
  const txids = b.txs.map((x) => x.txid);
148
235
  const pos = txids.findIndex((x) => x.toLowerCase() === txidHex.toLowerCase());
149
236
  if (pos < 0) return { trustLevel: "rpc-trusted", included: false, reason: "tx not listed in block" };
150
- const branch = merkleBranch(txids, pos);
151
- const ok = verifyMerkleProof(txidHex, pos, branch, verified.header.merkle);
237
+ const ok = verifyMerkleProof(txidHex, pos, merkleBranch(txids, pos), verified.header.merkle);
152
238
  if (!ok) return { trustLevel: "rpc-trusted", included: false, reason: "merkle proof failed" };
153
- return { trustLevel: "verified-inclusion", included: true, blockHeight: height, confirmations: this.chain.length - height };
239
+ return { trustLevel: "verified-inclusion", included: true, blockHeight: height, confirmations: tipHeight - height + 1 };
154
240
  }
155
241
  /**
156
- * Balance for an address. HONEST: this is `rpc-trusted` — a header chain cannot prove an output
157
- * is still unspent (no UTXO commitment). A future `scanBalance` will derive it from a Neutrino-
158
- * style block scan (`trustLevel: 'scanned'`). Surfaced, never hidden.
242
+ * Balance for an address. HONEST: `rpc-trusted` — a header chain cannot prove an output is still
243
+ * unspent (no UTXO commitment). A future Neutrino-style scan would yield `trustLevel:'scanned'`.
159
244
  */
160
245
  async balance(addr) {
161
246
  if (!this.client) throw new Error("no client");
@@ -167,5 +252,6 @@ export {
167
252
  CsdClient2 as CsdClient,
168
253
  LightClient,
169
254
  expectedBits,
255
+ expectedBitsFromWindow,
170
256
  rpcHeaderToHeader2 as rpcHeaderToHeader
171
257
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inversealtruism/csd-light",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Compute Substrate light client — headers-first sync with client-side PoW/LWMA/chainwork verification + merkle-inclusion proofs, behind a Helios-style verified-RPC facade with an honest trustLevel on every read.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -15,11 +15,12 @@
15
15
  },
16
16
  "files": [
17
17
  "dist",
18
- "LICENSE"
18
+ "LICENSE",
19
+ "README.md"
19
20
  ],
20
21
  "dependencies": {
21
- "@inversealtruism/csd-codec": "0.1.1",
22
- "@inversealtruism/csd-client": "0.1.1"
22
+ "@inversealtruism/csd-client": "0.1.2",
23
+ "@inversealtruism/csd-codec": "0.1.2"
23
24
  },
24
25
  "license": "MIT",
25
26
  "sideEffects": false,