@leofcoin/chain 1.8.29 → 1.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.
@@ -1,4 +1,4 @@
1
- import { t as toBase58, T as TransactionMessage, C as ContractMessage, R as RawTransactionMessage, B as BlockMessage, u as utils, L as LastBlockMessage, P as PROTOCOL_VERSION, a as REACHED_ONE_ZERO_ZERO, b as BWMessage, c as BWRequestMessage } from './constants-BKKQytjd.js';
1
+ import { t as toBase58, T as TransactionMessage, C as ContractMessage, R as RawTransactionMessage, B as BlockMessage, u as utils, L as LastBlockMessage, P as PROTOCOL_VERSION, a as REACHED_ONE_ZERO_ZERO, b as BWMessage, c as BWRequestMessage } from './constants-BTdMMS4w.js';
2
2
  import { log } from 'console';
3
3
  import { log as log$1 } from 'node:console';
4
4
 
@@ -964,7 +964,7 @@ function parseUnits(value, unit) {
964
964
  }
965
965
 
966
966
  const jsonStringifyBigInt = (key, value) => (typeof value === 'bigint' ? { $bigint: value.toString() } : value);
967
- const jsonParseBigInt = (key, value) => typeof value === 'object' && value.$bigint ? BigInt(value.$bigint) : value;
967
+ const jsonParseBigInt = (key, value) => typeof value === 'object' && value?.$bigint ? BigInt(value.$bigint) : value;
968
968
 
969
969
  const byteFormats = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
970
970
  const formatBytes = (bytes, decimals = 2) => {
@@ -3921,8 +3921,6 @@ class Transaction extends Protocol {
3921
3921
  transactions = await this.promiseTransactions(transactions);
3922
3922
  transactions = transactions.filter((tx) => tx.decoded.from === address);
3923
3923
  for (const transaction of transactions) {
3924
- if (transaction.decoded.nonce > nonce)
3925
- throw new Error(`a transaction with a higher nonce already exists`);
3926
3924
  if (transaction.decoded.nonce === nonce)
3927
3925
  throw new Error(`a transaction with the same nonce already exists`);
3928
3926
  }
@@ -4356,82 +4354,121 @@ class Contract extends Transaction {
4356
4354
 
4357
4355
  const randombytes = (strength) => crypto.getRandomValues(new Uint8Array(strength));
4358
4356
 
4359
- class EasyWorker {
4360
- #messageEvent = 'message'
4361
- #errorEvent = 'error'
4362
- #isBrowser = false
4363
- #isWorker = false
4364
-
4365
- get isWorker() {
4366
- return this.#isWorker
4367
- }
4368
- constructor(url, options) {
4369
- return this.#init(url, options)
4370
- }
4371
-
4372
- #init(url, options = {}) {
4373
- if (url) {
4374
- if (globalThis.Worker) {
4375
- this.#isBrowser = true;
4376
- this.worker = new Worker(url, {...options});
4377
- } else {
4378
- return new Promise(async (resolve, reject) => {
4379
- const {fork} = await import('child_process');
4380
- this.worker = fork(url, ['easy-worker-child'], options);
4381
- resolve(this);
4382
- })
4383
- }
4384
- } else {
4385
- this.#isWorker = true;
4386
- if (globalThis.process?.argv[2] === 'easy-worker-child') {
4387
- this.worker = process;
4388
- } else {
4389
- this.#isBrowser = true;
4390
- this.worker = globalThis;
4391
- }
4392
- }
4393
-
4394
- return this
4395
- }
4396
-
4397
- onmessage(fn) {
4398
- if (this.#isBrowser) this.worker.onmessage = ({data}) => fn(data);
4399
- else this.worker.on(this.#messageEvent, fn);
4400
- }
4401
-
4402
- postMessage(message) {
4403
- if (this.#isBrowser) this.worker.postMessage(message);
4404
- else this.worker.send(message);
4405
- }
4406
-
4407
- terminate() {
4408
- if (this.#isBrowser) this.worker.terminate();
4409
- else this.worker.kill();
4410
- }
4411
-
4412
- onerror(fn) {
4413
- if (this.#isBrowser) this.worker.onerror = fn;
4414
- else this.worker.on(this.#errorEvent, fn);
4415
- }
4416
-
4417
- /**
4418
- *
4419
- * @param {*} data
4420
- * @returns {Promise} resolves result onmessage & rejects on error
4421
- */
4422
- once(data) {
4423
- return new Promise((resolve, reject) => {
4424
- this.onmessage(message => {
4425
- resolve(message);
4426
- this.terminate();
4427
- });
4428
- this.onerror(error => {
4429
- reject(error);
4430
- this.terminate();
4431
- });
4432
- this.postMessage(data);
4433
- })
4434
- }
4357
+ class EasyWorker {
4358
+ #messageEvent = 'message';
4359
+ #errorEvent = 'error';
4360
+ #isBrowser = false;
4361
+ #isWorker = false;
4362
+ #messageHandler = null;
4363
+ #errorHandler = null;
4364
+ worker;
4365
+ get isWorker() {
4366
+ return this.#isWorker;
4367
+ }
4368
+ constructor(url, options) {
4369
+ return this.#init(url, options);
4370
+ }
4371
+ #init(url, options = {}) {
4372
+ if (url) {
4373
+ if (globalThis.Worker) {
4374
+ this.#isBrowser = true;
4375
+ this.worker = new Worker(url, { ...options });
4376
+ }
4377
+ else {
4378
+ return new Promise((resolve, reject) => {
4379
+ import('child_process').then(({ fork }) => {
4380
+ const child = fork(url, ['easy-worker-child'], options);
4381
+ child.once('error', reject);
4382
+ this.worker = child;
4383
+ resolve(this);
4384
+ }).catch(reject);
4385
+ });
4386
+ }
4387
+ }
4388
+ else {
4389
+ this.#isWorker = true;
4390
+ if (globalThis.process?.argv[2] === 'easy-worker-child') {
4391
+ this.worker = process;
4392
+ }
4393
+ else {
4394
+ this.#isBrowser = true;
4395
+ this.worker = globalThis;
4396
+ }
4397
+ }
4398
+ return this;
4399
+ }
4400
+ onmessage(fn) {
4401
+ if (this.#isBrowser) {
4402
+ this.worker.onmessage = ({ data }) => fn(data);
4403
+ }
4404
+ else {
4405
+ if (this.#messageHandler)
4406
+ this.worker.off(this.#messageEvent, this.#messageHandler);
4407
+ this.#messageHandler = fn;
4408
+ this.worker.on(this.#messageEvent, fn);
4409
+ }
4410
+ }
4411
+ postMessage(message) {
4412
+ if (this.#isBrowser)
4413
+ this.worker.postMessage(message);
4414
+ else
4415
+ this.worker.send(message);
4416
+ }
4417
+ terminate() {
4418
+ if (this.#isBrowser)
4419
+ this.worker.terminate();
4420
+ else
4421
+ this.worker.kill();
4422
+ }
4423
+ onerror(fn) {
4424
+ if (this.#isBrowser) {
4425
+ this.worker.onerror = fn;
4426
+ }
4427
+ else {
4428
+ if (this.#errorHandler)
4429
+ this.worker.off(this.#errorEvent, this.#errorHandler);
4430
+ this.#errorHandler = fn;
4431
+ this.worker.on(this.#errorEvent, fn);
4432
+ }
4433
+ }
4434
+ /**
4435
+ *
4436
+ * @param {*} data
4437
+ * @returns {Promise} resolves result onmessage & rejects on error
4438
+ */
4439
+ once(data) {
4440
+ return new Promise((resolve, reject) => {
4441
+ if (this.#isBrowser) {
4442
+ const handleMessage = ({ data: result }) => {
4443
+ this.worker.onerror = null;
4444
+ resolve(result);
4445
+ this.terminate();
4446
+ };
4447
+ const handleError = (error) => {
4448
+ this.worker.onmessage = null;
4449
+ reject(error);
4450
+ this.terminate();
4451
+ };
4452
+ this.worker.onmessage = handleMessage;
4453
+ this.worker.onerror = handleError;
4454
+ }
4455
+ else {
4456
+ const handleMessage = (result) => {
4457
+ this.worker.off(this.#errorEvent, handleError);
4458
+ resolve(result);
4459
+ this.terminate();
4460
+ };
4461
+ const handleError = (error) => {
4462
+ this.worker.off(this.#messageEvent, handleMessage);
4463
+ reject(error);
4464
+ this.terminate();
4465
+ };
4466
+ this.worker.once(this.#messageEvent, handleMessage);
4467
+ this.worker.once(this.#errorEvent, handleError);
4468
+ }
4469
+ this.postMessage(data);
4470
+ });
4471
+ }
4435
4472
  }
4436
4473
 
4437
4474
  class LeofcoinError extends Error {
@@ -4630,20 +4667,30 @@ class Machine {
4630
4667
  }
4631
4668
  await Promise.all(promises);
4632
4669
  }
4670
+ // Helper to sort object keys for deterministic serialization
4671
+ const sortedStringify = (obj, replacer) => {
4672
+ const sorted = {};
4673
+ const keys = Object.keys(obj).sort();
4674
+ for (const key of keys) {
4675
+ sorted[key] = obj[key];
4676
+ }
4677
+ return JSON.stringify(sorted, replacer);
4678
+ };
4633
4679
  const tasks = [
4634
- stateStore.put('lastBlock', JSON.stringify(await this.lastBlock, jsonStringifyBigInt)),
4635
- stateStore.put('states', JSON.stringify(state, jsonStringifyBigInt)),
4636
- stateStore.put('accounts', JSON.stringify(accounts, jsonStringifyBigInt)),
4637
- stateStore.put('info', JSON.stringify({
4680
+ stateStore.put('lastBlock', sortedStringify(await this.lastBlock, jsonStringifyBigInt)),
4681
+ stateStore.put('states', sortedStringify(state, jsonStringifyBigInt)),
4682
+ stateStore.put('accounts', sortedStringify(accounts, jsonStringifyBigInt)),
4683
+ stateStore.put('info', sortedStringify({
4684
+ nativeBurnAmount: await this.totalBurnAmount,
4685
+ nativeBurns: await this.nativeBurns,
4638
4686
  nativeCalls: await this.nativeCalls,
4639
4687
  nativeMints: await this.nativeMints,
4640
- nativeBurns: await this.nativeBurns,
4641
4688
  nativeTransfers: await this.nativeTransfers,
4642
- totalTransactions: await this.totalTransactions,
4689
+ totalBlocks: await blockStore.length,
4643
4690
  totalBurnAmount: await this.totalBurnAmount,
4644
4691
  totalMintAmount: await this.totalMintAmount,
4645
- totalTransferAmount: await this.totalTransferAmount,
4646
- totalBlocks: await blockStore.length
4692
+ totalTransactions: await this.totalTransactions,
4693
+ totalTransferAmount: await this.totalTransferAmount
4647
4694
  }, jsonStringifyBigInt))
4648
4695
  // accountsStore.clear()
4649
4696
  ];
@@ -5016,6 +5063,7 @@ class State extends Contract {
5016
5063
  #totalSize;
5017
5064
  #machine;
5018
5065
  #loaded;
5066
+ #resolvingHashes;
5019
5067
  /**
5020
5068
  * contains transactions we need before we can successfully load
5021
5069
  */
@@ -5104,6 +5152,7 @@ class State extends Contract {
5104
5152
  this.knownBlocks = [];
5105
5153
  this.#totalSize = 0;
5106
5154
  this.#loaded = false;
5155
+ this.#resolvingHashes = new Set();
5107
5156
  this._wantList = [];
5108
5157
  this.#chainStateHandler = () => {
5109
5158
  return new globalThis.peernet.protos['peernet-response']({
@@ -5264,6 +5313,23 @@ class State extends Contract {
5264
5313
  }
5265
5314
  return block;
5266
5315
  }
5316
+ async #resolveTransactions(transactions) {
5317
+ await Promise.all(transactions
5318
+ .filter((hash) => Boolean(hash))
5319
+ .map(async (hash) => {
5320
+ // should be in a transaction store already
5321
+ const exists = await transactionStore.has(hash);
5322
+ if (!exists) {
5323
+ const data = await peernet.get(hash, 'transaction');
5324
+ if (!data)
5325
+ throw new Error(`missing transaction data for ${hash}`);
5326
+ await transactionStore.put(hash, data);
5327
+ }
5328
+ const inPool = await transactionPoolStore.has(hash);
5329
+ if (inPool)
5330
+ await transactionPoolStore.delete(hash);
5331
+ }));
5332
+ }
5267
5333
  async #resolveBlock(hash) {
5268
5334
  let index = this.#blockHashMap.get(hash);
5269
5335
  let localHash = '0x0';
@@ -5289,31 +5355,12 @@ class State extends Contract {
5289
5355
  }
5290
5356
  try {
5291
5357
  const block = await this.getAndPutBlock(hash);
5358
+ const promises = [];
5292
5359
  if (block.decoded.previousHash !== '0x0' && block.decoded.previousHash !== localHash) {
5293
- setImmediate(async () => {
5294
- try {
5295
- await this.resolveBlock(block.decoded.previousHash);
5296
- }
5297
- catch (e) {
5298
- console.error(e);
5299
- }
5300
- });
5360
+ promises.push(this.resolveBlock(block.decoded.previousHash));
5301
5361
  }
5302
- await Promise.all(block.decoded.transactions
5303
- .filter((hash) => Boolean(hash))
5304
- .map(async (hash) => {
5305
- // should be in a transaction store already
5306
- const exists = await transactionStore.has(hash);
5307
- if (!exists) {
5308
- const data = await peernet.get(hash, 'transaction');
5309
- if (!data)
5310
- throw new Error(`missing transaction data for ${hash}`);
5311
- await transactionStore.put(hash, data);
5312
- }
5313
- const inPool = await transactionPoolStore.has(hash);
5314
- if (inPool)
5315
- await transactionPoolStore.delete(hash);
5316
- }));
5362
+ promises.push(this.#resolveTransactions(block.decoded.transactions));
5363
+ await Promise.all(promises);
5317
5364
  index = block.decoded.index;
5318
5365
  const size = block.encoded.length > 0 ? block.encoded.length : block.encoded.byteLength;
5319
5366
  this.#totalSize += size;
@@ -5334,14 +5381,20 @@ class State extends Contract {
5334
5381
  throw new Error(`expected hash, got: ${hash}`);
5335
5382
  if (hash === '0x0')
5336
5383
  return;
5337
- if (this.#resolving)
5338
- return 'already resolving';
5384
+ if (this.#resolvingHashes.has(hash))
5385
+ return;
5386
+ this.#resolvingHashes.add(hash);
5387
+ const isEntering = this.#resolvingHashes.size === 1;
5339
5388
  this.#resolving = true;
5340
- if (this.jobber.busy && this.jobber.destroy)
5341
- await this.jobber.destroy();
5342
5389
  try {
5343
- await this.jobber.add(() => this.#resolveBlock(hash));
5344
- this.#resolving = false;
5390
+ if (isEntering) {
5391
+ if (this.jobber.busy && this.jobber.destroy)
5392
+ await this.jobber.destroy();
5393
+ await this.jobber.add(() => this.#resolveBlock(hash));
5394
+ }
5395
+ else {
5396
+ await this.#resolveBlock(hash);
5397
+ }
5345
5398
  try {
5346
5399
  const lastBlockHash = await globalThis.stateStore.get('lastBlock');
5347
5400
  if (lastBlockHash === hash) {
@@ -5350,19 +5403,23 @@ class State extends Contract {
5350
5403
  }
5351
5404
  }
5352
5405
  catch (error) { }
5353
- if (!this.#blockHashMap.has(this.#lastResolved.previousHash) && this.#lastResolved.previousHash !== '0x0')
5354
- return this.resolveBlock(this.#lastResolved.previousHash);
5355
5406
  }
5356
5407
  catch (error) {
5357
5408
  console.log({ error });
5358
5409
  this.#resolveErrorCount += 1;
5359
- this.#resolving = false;
5360
- if (this.#resolveErrorCount < 3)
5410
+ if (this.#resolveErrorCount < 3) {
5411
+ this.#resolvingHashes.delete(hash);
5361
5412
  return this.resolveBlock(hash);
5413
+ }
5362
5414
  this.#resolveErrorCount = 0;
5363
5415
  this.wantList.push(hash);
5364
5416
  throw new ResolveError(`block: ${hash}`, { cause: error });
5365
5417
  }
5418
+ finally {
5419
+ this.#resolvingHashes.delete(hash);
5420
+ if (this.#resolvingHashes.size === 0)
5421
+ this.#resolving = false;
5422
+ }
5366
5423
  }
5367
5424
  async resolveBlocks() {
5368
5425
  // Don't re-resolve if already syncing or resolving
@@ -5484,14 +5541,29 @@ class State extends Contract {
5484
5541
  debug$1(`Remote block hash is 0x0, skipping sync`);
5485
5542
  return;
5486
5543
  }
5544
+ // Skip if local machine is already ahead of remote
5545
+ if (localIndex > remoteIndex) {
5546
+ debug$1(`Local index ${localIndex} is ahead of remote ${remoteIndex}, skipping sync`);
5547
+ return;
5548
+ }
5549
+ // CRITICAL: Prevent DoS from excessive reorgs
5550
+ const MAX_REORG_DEPTH = 6;
5551
+ const reorgDepth = localIndex - remoteIndex;
5552
+ if (reorgDepth > 0 && reorgDepth > MAX_REORG_DEPTH) {
5553
+ console.warn(`[consensus-safety] Peer proposing reorg depth of ${reorgDepth} blocks ` +
5554
+ `(limit is ${MAX_REORG_DEPTH}). Rejecting to prevent DoS.`);
5555
+ throw new Error(`Excessive reorg depth: ${reorgDepth} blocks (max ${MAX_REORG_DEPTH})`);
5556
+ }
5487
5557
  // Use state hash comparison: only resolve if remote hash differs from local state hash
5488
5558
  if (localStateHash !== remoteBlockHash) {
5489
5559
  if (this.wantList.length > 0) {
5490
5560
  debug$1(`Fetching ${this.wantList.length} blocks before resolving`);
5491
5561
  const getBatch = async (batch) => {
5492
- return Promise.all(batch.map((hash) => this.getAndPutBlock(hash).catch((e) => {
5562
+ const blocks = await Promise.all(batch.map((hash) => this.getAndPutBlock(hash).catch((e) => {
5493
5563
  console.warn(`failed to fetch block ${hash}`, e);
5494
5564
  })));
5565
+ const transactions = blocks.filter((block) => Boolean(block)).flatMap((block) => block.decoded.transactions);
5566
+ return this.#resolveTransactions(transactions);
5495
5567
  };
5496
5568
  // Process in batches of 50 to avoid overwhelming network/memory
5497
5569
  for (let i = 0; i < this.wantList.length; i += 50) {
@@ -5542,11 +5614,19 @@ class State extends Contract {
5542
5614
  for (const id in globalThis.peernet.connections) {
5543
5615
  // @ts-ignore
5544
5616
  const peer = globalThis.peernet.connections[id];
5545
- if (peer.connected && peer.version === this.version) {
5617
+ // CRITICAL FIX: Use semver comparison (major.minor) not exact match
5618
+ const isVersionCompatible = () => {
5619
+ if (!peer.version || !this.version)
5620
+ return false;
5621
+ const [peerMajor, peerMinor] = peer.version.split('.');
5622
+ const [localMajor, localMinor] = this.version.split('.');
5623
+ return peerMajor === localMajor && peerMinor === localMinor;
5624
+ };
5625
+ if (peer.connected && isVersionCompatible()) {
5546
5626
  const task = async () => {
5547
5627
  try {
5548
5628
  const result = await peer.request(node.encoded);
5549
- debug$1({ result });
5629
+ debug$1(`lastBlock result: ${JSON.stringify(result)}`);
5550
5630
  console.log({ result });
5551
5631
  return { result: new LastBlockMessage(result), peer };
5552
5632
  }
@@ -5561,7 +5641,7 @@ class State extends Contract {
5561
5641
  }
5562
5642
  // @ts-ignore
5563
5643
  console.log({ promises });
5564
- promises = await this.promiseRequests(promises);
5644
+ promises = (await this.promiseRequests(promises));
5565
5645
  console.log({ promises });
5566
5646
  let latest = { index: 0, hash: '0x0', previousHash: '0x0' };
5567
5647
  promises = promises.sort((a, b) => b.index - a.index);
@@ -5576,7 +5656,15 @@ class State extends Contract {
5576
5656
  throw new Error('invalid block @getLatestBlock');
5577
5657
  latest = { ...message.decoded, hash };
5578
5658
  const peer = promises[0].peer;
5579
- if (peer.connected && peer.version === this.version) {
5659
+ // CRITICAL FIX: Check version compatibility using semver
5660
+ const isVersionCompatible = () => {
5661
+ if (!peer.version || !this.version)
5662
+ return false;
5663
+ const [peerMajor, peerMinor] = peer.version.split('.');
5664
+ const [localMajor, localMinor] = this.version.split('.');
5665
+ return peerMajor === localMajor && peerMinor === localMinor;
5666
+ };
5667
+ if (peer.connected && isVersionCompatible()) {
5580
5668
  let data = await new globalThis.peernet.protos['peernet-request']({
5581
5669
  request: 'knownBlocks'
5582
5670
  });
@@ -5584,7 +5672,11 @@ class State extends Contract {
5584
5672
  try {
5585
5673
  let message = await peer.request(node.encode());
5586
5674
  message = await new globalThis.peernet.protos['peernet-response'](message);
5587
- this.wantList.push(...message.decoded.response.blocks.filter((block) => !this.knownBlocks.includes(block)));
5675
+ const MAX_WANTLIST_SIZE = 1000;
5676
+ const incoming = message.decoded.response.blocks.filter((block) => !this.knownBlocks.includes(block));
5677
+ const remaining = MAX_WANTLIST_SIZE - this.wantList.length;
5678
+ if (remaining > 0)
5679
+ this.wantList.push(...incoming.slice(0, remaining));
5588
5680
  }
5589
5681
  catch (error) {
5590
5682
  const peerId = peer?.peerId || peer?.id || peer?.address || 'unknown';
@@ -5639,7 +5731,7 @@ class State extends Contract {
5639
5731
  if (block && !block.loaded) {
5640
5732
  try {
5641
5733
  debug$1(`loading block: ${Number(block.index)} ${block.hash}`);
5642
- let transactions = await this.#loadBlockTransactions([...block.transactions] || []);
5734
+ let transactions = await this.#loadBlockTransactions(block.transactions || []);
5643
5735
  // const lastTransactions = await this.#getLastTransactions()
5644
5736
  debug$1(`loading transactions: ${transactions.length} for block ${block.index}`);
5645
5737
  let priority = [];
@@ -5815,6 +5907,13 @@ class VersionControl extends State {
5815
5907
  return this.#setCurrentVersion();
5816
5908
  }
5817
5909
  }
5910
+ isVersionCompatible(peerVersion) {
5911
+ if (!peerVersion || !this.version)
5912
+ return false;
5913
+ const [peerMajor, peerMinor] = peerVersion.split('.');
5914
+ const [localMajor, localMinor] = this.version.split('.');
5915
+ return peerMajor === localMajor && peerMinor === localMinor;
5916
+ }
5818
5917
  }
5819
5918
 
5820
5919
  /**
@@ -5936,14 +6035,12 @@ class ConnectionMonitor {
5936
6035
  }
5937
6036
  const connectedPeers = this.connectedPeers;
5938
6037
  const compatiblePeers = this.compatiblePeers;
5939
- console.log(`🔍 Health check: ${connectedPeers.length} connected, ${compatiblePeers.length} compatible`);
5940
- // If a reconnection is already ongoing, skip this cycle to avoid log spam/loops
5941
- if (this.#reconnecting) {
5942
- console.log('⏭️ Health check: reconnection already in progress, skipping reconnect attempt');
5943
- return;
5944
- }
5945
- // If we have no connections or none are compatible, try to reconnect
5946
- if (connectedPeers.length === 0) {
6038
+ const disconnectedPeers = this.disconnectedPeers;
6039
+ console.log(`🔍 Health check: ${connectedPeers.length} connected, ${compatiblePeers.length} compatible, ${disconnectedPeers.length} negotiating`);
6040
+ // If we have no connections or none are compatible, try to reconnect.
6041
+ // Don't trigger reconnection if peers are still in WebRTC ICE negotiation (disconnectedPeers > 0)
6042
+ // reinit() would tear down the in-flight handshake.
6043
+ if (connectedPeers.length === 0 && disconnectedPeers.length === 0) {
5947
6044
  console.warn('⚠️ No peer connections detected — attempting reconnection');
5948
6045
  await this.#attemptReconnection();
5949
6046
  }
@@ -6045,25 +6142,12 @@ class ConnectionMonitor {
6045
6142
  console.warn(' ⚠️ peernet.start() failed:', e?.message || e);
6046
6143
  }
6047
6144
  }
6048
- // Approach 3: Try client.connect if available
6049
- if (globalThis.peernet?.client &&
6050
- 'connect' in globalThis.peernet.client &&
6051
- typeof globalThis.peernet.client.connect === 'function') {
6052
- console.log(' → Trying client.connect()');
6053
- try {
6054
- await globalThis.peernet.client.connect();
6055
- console.log(' ✅ client.connect() succeeded');
6056
- }
6057
- catch (e) {
6058
- console.warn(' ⚠️ client.connect() failed:', e?.message || e);
6059
- }
6060
- }
6061
- // Approach 4: Explicitly dial star servers if available
6145
+ // Approach 3: Explicitly dial star servers if available (only if client.reinit() didn't succeed)
6062
6146
  try {
6063
6147
  const networkName = globalThis.peernet?.network;
6064
6148
  if (networkName && typeof networkName === 'string') {
6065
6149
  // Try to import network config
6066
- const { default: networks } = await import('./constants-BKKQytjd.js').then(function (n) { return n.n; });
6150
+ const { default: networks } = await import('./constants-BTdMMS4w.js').then(function (n) { return n.n; });
6067
6151
  const [mainKey, subKey] = networkName.split(':');
6068
6152
  const networkConfig = networks?.[mainKey]?.[subKey];
6069
6153
  if (networkConfig?.stars && Array.isArray(networkConfig.stars)) {
@@ -6074,11 +6158,6 @@ class ConnectionMonitor {
6074
6158
  await globalThis.peernet.client.dial(star);
6075
6159
  console.log(` ✅ Connected to star server: ${star}`);
6076
6160
  }
6077
- else if (globalThis.peernet?.client && 'connect' in globalThis.peernet.client) {
6078
- // Try connect with the star URL
6079
- await globalThis.peernet.client.connect(star);
6080
- console.log(` ✅ Connected to star server: ${star}`);
6081
- }
6082
6161
  }
6083
6162
  catch (e) {
6084
6163
  console.warn(` ⚠️ Failed to dial ${star}:`, e?.message || e);
@@ -6120,14 +6199,13 @@ class ConnectionMonitor {
6120
6199
  });
6121
6200
  }
6122
6201
  async #attemptReconnection() {
6123
- if (this.#reconnecting) {
6124
- console.log('⏭️ Reconnection already in progress');
6125
- return;
6126
- }
6127
6202
  try {
6128
6203
  await this.#restoreNetwork();
6129
- // Check if reconnection was successful
6130
- const hasConnections = this.connectedPeers.length > 0;
6204
+ // Check if reconnection was successful.
6205
+ // Treat peers that are still negotiating (in connections but not yet connected) as success —
6206
+ // they were discovered via the star and WebRTC ICE is in progress. Retrying now would call
6207
+ // reinit() again and tear down the in-flight handshake.
6208
+ const hasConnections = this.connectedPeers.length > 0 || this.disconnectedPeers.length > 0;
6131
6209
  if (hasConnections) {
6132
6210
  console.log('✅ Reconnection successful, resetting backoff delay');
6133
6211
  this.#reconnectDelay = 5000;
@@ -6177,35 +6255,252 @@ class Chain extends VersionControl {
6177
6255
  #state;
6178
6256
  #slotTime;
6179
6257
  #blockTime; // 6 second target block time
6258
+ #epochLength; // Blocks per epoch (enables block-based epoch boundaries)
6180
6259
  /** {Address[]} */
6181
6260
  #validators;
6182
6261
  /** {Boolean} */
6183
6262
  #runningEpoch;
6263
+ /** Block height at which current epoch started (for block-based epoch timing) */
6264
+ #currentEpochStartHeight;
6265
+ /** {Object} Block cache by index for conflict detection: {index: {hash, ...block}} */
6266
+ #blocks;
6184
6267
  #participants;
6185
6268
  #participating;
6186
6269
  #jail;
6270
+ #jailReleaseTimers;
6187
6271
  #peerConnectionRetries;
6188
6272
  #maxPeerRetries;
6189
6273
  #peerRetryDelay;
6274
+ /** {Map} Peer reputation tracking: {peerId: {score, failures}} */
6275
+ #peerReputations;
6276
+ #minPeerScore;
6277
+ #maxPeerFailures;
6278
+ // ── Tendermint consensus state ──────────────────────────────────────────────
6279
+ /** Current consensus round (increments when proposer is unresponsive) */
6280
+ #consensusRound;
6281
+ /** Timer that advances #consensusRound when the proposer doesn't propose in time */
6282
+ #roundTimer;
6283
+ /** prevotes collected per `height:round:blockHash` key */
6284
+ #prevotes;
6285
+ /** precommits collected per `height:round:blockHash` key */
6286
+ #precommits;
6287
+ /** Index of the last block that reached 2f+1 precommits */
6288
+ #committedHeight;
6289
+ /** Prevents casting duplicate prevote/precommit per height:round */
6290
+ #castedVotes;
6291
+ // ────────────────────────────────────────────────────────────────────────────
6190
6292
  #connectionMonitor;
6191
6293
  constructor(config) {
6192
6294
  super(config);
6193
6295
  this.#slotTime = 10000;
6194
6296
  this.#blockTime = 6000; // 6 second target block time
6297
+ this.#epochLength = 10; // Blocks per epoch (enables block-based epoch boundaries)
6195
6298
  this.utils = {};
6196
6299
  /** {Address[]} */
6197
6300
  this.#validators = [];
6198
6301
  /** {Boolean} */
6199
6302
  this.#runningEpoch = false;
6303
+ /** Block height at which current epoch started (for block-based epoch timing) */
6304
+ this.#currentEpochStartHeight = 0;
6305
+ /** {Object} Block cache by index for conflict detection: {index: {hash, ...block}} */
6306
+ this.#blocks = {};
6200
6307
  this.#participants = [];
6201
6308
  this.#participating = false;
6202
- this.#jail = [];
6309
+ this.#jail = new Set();
6310
+ this.#jailReleaseTimers = new Map();
6203
6311
  this.#peerConnectionRetries = new Map();
6204
6312
  this.#maxPeerRetries = 5;
6205
6313
  this.#peerRetryDelay = 5000;
6314
+ /** {Map} Peer reputation tracking: {peerId: {score, failures}} */
6315
+ this.#peerReputations = new Map();
6316
+ this.#minPeerScore = -10;
6317
+ this.#maxPeerFailures = 100;
6318
+ // ── Tendermint consensus state ──────────────────────────────────────────────
6319
+ /** Current consensus round (increments when proposer is unresponsive) */
6320
+ this.#consensusRound = 0;
6321
+ /** Timer that advances #consensusRound when the proposer doesn't propose in time */
6322
+ this.#roundTimer = null;
6323
+ /** prevotes collected per `height:round:blockHash` key */
6324
+ this.#prevotes = new Map();
6325
+ /** precommits collected per `height:round:blockHash` key */
6326
+ this.#precommits = new Map();
6327
+ /** Index of the last block that reached 2f+1 precommits */
6328
+ this.#committedHeight = -1;
6329
+ /** Prevents casting duplicate prevote/precommit per height:round */
6330
+ this.#castedVotes = new Set();
6206
6331
  this.ready = new Promise((resolve) => {
6207
6332
  this.readyResolve = resolve;
6208
6333
  });
6334
+ // ── Tendermint consensus handlers ────────────────────────────────────────
6335
+ /**
6336
+ * Publish a prevote or precommit. Idempotent — will not cast the same
6337
+ * vote twice for the same height:round.
6338
+ */
6339
+ this.#castVote = async (type, blockHash, index, round) => {
6340
+ const voteKey = `${type}:${index}:${round}`;
6341
+ if (this.#castedVotes.has(voteKey))
6342
+ return;
6343
+ this.#castedVotes.add(voteKey);
6344
+ const from = peernet.selectedAccount;
6345
+ const payload = new TextEncoder().encode(JSON.stringify({ blockHash, index, round, from }));
6346
+ try {
6347
+ globalThis.peernet.publish(`consensus:${type}`, payload);
6348
+ }
6349
+ catch (e) {
6350
+ debug(`peernet publish failed: consensus:${type}`, e?.message ?? e);
6351
+ }
6352
+ };
6353
+ /**
6354
+ * Phase 2 — receive a block proposal from the designated proposer.
6355
+ * Validates the proposer is correct for height/round, fetches + validates
6356
+ * the block from peernet, then casts a prevote.
6357
+ */
6358
+ this.#handleProposal = async (payload) => {
6359
+ try {
6360
+ const msg = JSON.parse(new TextDecoder().decode(payload));
6361
+ const { blockHash, index, round, from } = msg;
6362
+ const validators = await this.#getConsensusValidators(index);
6363
+ const expectedProposerIdx = (index + round) % validators.length;
6364
+ if (!validators[expectedProposerIdx] || validators[expectedProposerIdx] !== from) {
6365
+ debug(`[consensus] Proposal from wrong proposer at height ${index} round ${round}`);
6366
+ return;
6367
+ }
6368
+ const localBlock = await this.lastBlock;
6369
+ const localIndex = localBlock?.index !== undefined ? Number(localBlock.index) : -1;
6370
+ if (index <= localIndex) {
6371
+ debug(`[consensus] Ignoring stale proposal at height ${index} (local: ${localIndex})`);
6372
+ return;
6373
+ }
6374
+ // Fetch block from peernet and verify its hash
6375
+ try {
6376
+ const blockData = await globalThis.peernet.get(blockHash, 'block');
6377
+ const blockMessage = await new BlockMessage(blockData);
6378
+ const actualHash = await blockMessage.hash();
6379
+ if (actualHash !== blockHash) {
6380
+ debug(`[consensus] Block hash mismatch in proposal: expected ${blockHash}, got ${actualHash}`);
6381
+ return;
6382
+ }
6383
+ }
6384
+ catch (e) {
6385
+ debug(`[consensus] Cannot fetch proposed block ${blockHash}:`, e?.message);
6386
+ return;
6387
+ }
6388
+ this.#consensusRound = round;
6389
+ if (this.#roundTimer) {
6390
+ clearTimeout(this.#roundTimer);
6391
+ this.#roundTimer = null;
6392
+ }
6393
+ if (validators.includes(peernet.selectedAccount) && !this.#isJailed(peernet.selectedAccount)) {
6394
+ await this.#castVote('prevote', blockHash, index, round);
6395
+ }
6396
+ }
6397
+ catch (e) {
6398
+ debug('[consensus] Error handling proposal:', e?.message);
6399
+ }
6400
+ };
6401
+ /**
6402
+ * Phase 2 — collect prevotes. Once 2f+1 prevotes are seen for a block,
6403
+ * cast a precommit.
6404
+ */
6405
+ this.#handlePrevote = async (payload) => {
6406
+ try {
6407
+ const msg = JSON.parse(new TextDecoder().decode(payload));
6408
+ const { blockHash, index, round, from } = msg;
6409
+ const validators = await this.#getConsensusValidators(index);
6410
+ if (!validators.includes(from))
6411
+ return;
6412
+ const localBlock = await this.lastBlock;
6413
+ const localIndex = localBlock?.index !== undefined ? Number(localBlock.index) : -1;
6414
+ if (index <= localIndex)
6415
+ return;
6416
+ const voteKey = `${index}:${round}:${blockHash}`;
6417
+ if (!this.#prevotes.has(voteKey))
6418
+ this.#prevotes.set(voteKey, new Set());
6419
+ this.#prevotes.get(voteKey).add(from);
6420
+ const threshold = Math.ceil((2 * validators.length) / 3);
6421
+ const voteCount = this.#prevotes.get(voteKey).size;
6422
+ debug(`[consensus] Prevotes ${voteKey}: ${voteCount}/${validators.length} (need ${threshold})`);
6423
+ if (voteCount >= threshold &&
6424
+ validators.includes(peernet.selectedAccount) &&
6425
+ !this.#isJailed(peernet.selectedAccount)) {
6426
+ await this.#castVote('precommit', blockHash, index, round);
6427
+ }
6428
+ }
6429
+ catch (e) {
6430
+ debug('[consensus] Error handling prevote:', e?.message);
6431
+ }
6432
+ };
6433
+ /**
6434
+ * Phase 3 — collect precommits. Once 2f+1 precommits are seen for a block,
6435
+ * commit it: non-proposers call #addBlock, then broadcast on add-block for
6436
+ * syncing nodes.
6437
+ */
6438
+ this.#handlePrecommit = async (payload) => {
6439
+ try {
6440
+ const msg = JSON.parse(new TextDecoder().decode(payload));
6441
+ const { blockHash, index, round, from } = msg;
6442
+ const validators = await this.#getConsensusValidators(index);
6443
+ if (!validators.includes(from))
6444
+ return;
6445
+ if (index <= this.#committedHeight)
6446
+ return;
6447
+ const voteKey = `${index}:${round}:${blockHash}`;
6448
+ if (!this.#precommits.has(voteKey))
6449
+ this.#precommits.set(voteKey, new Set());
6450
+ this.#precommits.get(voteKey).add(from);
6451
+ const threshold = Math.ceil((2 * validators.length) / 3);
6452
+ const voteCount = this.#precommits.get(voteKey).size;
6453
+ debug(`[consensus] Precommits ${voteKey}: ${voteCount}/${validators.length} (need ${threshold})`);
6454
+ if (voteCount >= threshold && index > this.#committedHeight) {
6455
+ this.#committedHeight = index;
6456
+ this.#consensusRound = 0;
6457
+ // Prune vote state for committed and older heights
6458
+ for (const key of [...this.#prevotes.keys()]) {
6459
+ if (Number(key.split(':')[0]) <= index)
6460
+ this.#prevotes.delete(key);
6461
+ }
6462
+ for (const key of [...this.#precommits.keys()]) {
6463
+ if (Number(key.split(':')[0]) <= index)
6464
+ this.#precommits.delete(key);
6465
+ }
6466
+ for (const key of [...this.#castedVotes]) {
6467
+ if (Number(key.split(':')[1]) <= index)
6468
+ this.#castedVotes.delete(key);
6469
+ }
6470
+ // Non-proposers add the block to local state now.
6471
+ // Proposers already committed state in #createBlock() and their
6472
+ // lastBlock.index === index, so the guard below skips them.
6473
+ const currentBlock = await this.lastBlock;
6474
+ const currentIndex = currentBlock?.index !== undefined ? Number(currentBlock.index) : -1;
6475
+ if (index > currentIndex) {
6476
+ debug(`[consensus] ✅ Committing block ${blockHash} at height ${index}`);
6477
+ try {
6478
+ const blockData = await globalThis.peernet.get(blockHash, 'block');
6479
+ await this.#addBlock(blockData);
6480
+ }
6481
+ catch (e) {
6482
+ debug(`[consensus] Failed to commit block ${blockHash}:`, e?.message);
6483
+ }
6484
+ }
6485
+ else {
6486
+ debug(`[consensus] ✅ Block ${blockHash} at height ${index} already committed (proposer path)`);
6487
+ }
6488
+ // Broadcast committed block so syncing / non-participating nodes can catch up
6489
+ try {
6490
+ const blockData = await globalThis.peernet.get(blockHash, 'block');
6491
+ globalThis.peernet.publish('add-block', blockData);
6492
+ globalThis.pubsub.publish('add-block', blockData);
6493
+ }
6494
+ catch (e) {
6495
+ debug('[consensus] Failed to broadcast committed block:', e?.message);
6496
+ }
6497
+ }
6498
+ }
6499
+ catch (e) {
6500
+ debug('[consensus] Error handling precommit:', e?.message);
6501
+ }
6502
+ };
6503
+ // ─────────────────────────────────────────────────────────────────────────
6209
6504
  this.#addTransaction = async (message) => {
6210
6505
  const transaction = new TransactionMessage(message);
6211
6506
  const hash = await transaction.hash();
@@ -6229,17 +6524,149 @@ class Chain extends VersionControl {
6229
6524
  #sleep(ms) {
6230
6525
  return new Promise((resolve) => setTimeout(resolve, ms));
6231
6526
  }
6527
+ async #recordPeerFailure(peerId, reason) {
6528
+ if (!this.#peerReputations.has(peerId)) {
6529
+ this.#peerReputations.set(peerId, { score: 0, failures: [] });
6530
+ }
6531
+ const rep = this.#peerReputations.get(peerId);
6532
+ rep.score -= 1;
6533
+ rep.failures.push(`${Date.now()}: ${reason}`);
6534
+ if (rep.failures.length > this.#maxPeerFailures) {
6535
+ rep.failures.shift();
6536
+ }
6537
+ if (rep.score < this.#minPeerScore) {
6538
+ console.warn(`[peer-ban] Peer ${peerId} banned after ${rep.failures.length} failures`);
6539
+ // Disconnect and don't reconnect
6540
+ try {
6541
+ await globalThis.peernet.disconnect(peerId);
6542
+ }
6543
+ catch (e) {
6544
+ debug(`Failed to disconnect peer ${peerId}`);
6545
+ }
6546
+ }
6547
+ }
6548
+ #isJailed(address) {
6549
+ return typeof address === 'string' && this.#jail.has(address);
6550
+ }
6551
+ async #getConsensusValidators(nextBlockIndex) {
6552
+ const localBlock = await this.lastBlock;
6553
+ const localIndex = localBlock?.index !== undefined ? Number(localBlock.index) : -1;
6554
+ if (Array.isArray(localBlock?.validators) &&
6555
+ localBlock.validators.length > 0 &&
6556
+ (nextBlockIndex === undefined || nextBlockIndex === localIndex + 1)) {
6557
+ return [
6558
+ ...new Set(localBlock.validators
6559
+ .map((validator) => validator.address)
6560
+ .filter((address) => Boolean(address)))
6561
+ ].sort();
6562
+ }
6563
+ const validators = (await this.staticCall(addresses.validators, 'validators'));
6564
+ return [...new Set(validators)].sort();
6565
+ }
6566
+ #validateBlockValidators(blockMessage) {
6567
+ const validators = blockMessage.decoded.validators || [];
6568
+ if (!Array.isArray(validators) || validators.length === 0) {
6569
+ throw new Error(`Block ${blockMessage.decoded.index} does not include validators`);
6570
+ }
6571
+ // Validate protocol version compatibility
6572
+ if (!blockMessage.decoded.protocolVersion || typeof blockMessage.decoded.protocolVersion !== 'string') {
6573
+ throw new Error(`Block ${blockMessage.decoded.index} does not have a valid protocolVersion field`);
6574
+ }
6575
+ if (!this.isVersionCompatible(blockMessage.decoded.protocolVersion)) {
6576
+ throw new Error(`Block ${blockMessage.decoded.index} uses incompatible protocol version: ${blockMessage.decoded.protocolVersion} ` +
6577
+ `(local: ${this.version}). Major.minor version must match.`);
6578
+ }
6579
+ // Validate producer field
6580
+ if (!blockMessage.decoded.producer || typeof blockMessage.decoded.producer !== 'string') {
6581
+ throw new Error(`Block ${blockMessage.decoded.index} does not have a valid producer field`);
6582
+ }
6583
+ // Validate producerProof field
6584
+ if (!blockMessage.decoded.producerProof || typeof blockMessage.decoded.producerProof !== 'string') {
6585
+ throw new Error(`Block ${blockMessage.decoded.index} does not have a valid producerProof field`);
6586
+ }
6587
+ // Verify producer is in validators list
6588
+ const producerIsValidator = validators.some((v) => v.address === blockMessage.decoded.producer);
6589
+ if (!producerIsValidator) {
6590
+ throw new Error(`Block ${blockMessage.decoded.index} producer ${blockMessage.decoded.producer} is not in validators list`);
6591
+ }
6592
+ const addresses = validators.map((validator) => validator.address);
6593
+ if (addresses.some((address) => typeof address !== 'string' || address.length === 0)) {
6594
+ throw new Error(`Block ${blockMessage.decoded.index} includes an invalid validator address`);
6595
+ }
6596
+ const canonicalAddresses = [...addresses].sort();
6597
+ if (canonicalAddresses.some((address, index) => address !== addresses[index])) {
6598
+ throw new Error(`Block ${blockMessage.decoded.index} validators are not canonically sorted`);
6599
+ }
6600
+ if (new Set(addresses).size !== addresses.length) {
6601
+ throw new Error(`Block ${blockMessage.decoded.index} validators contain duplicates`);
6602
+ }
6603
+ const validatorCount = BigInt(validators.length);
6604
+ const expectedReward = blockMessage.decoded.fees / validatorCount + blockMessage.decoded.reward / validatorCount;
6605
+ for (const validator of validators) {
6606
+ if (validator.reward !== expectedReward) {
6607
+ throw new Error(`Block ${blockMessage.decoded.index} has an invalid reward for validator ${validator.address}: ` +
6608
+ `expected ${expectedReward}, got ${validator.reward}`);
6609
+ }
6610
+ }
6611
+ }
6612
+ /** Check if the next block will cross an epoch boundary (block-based timing) */
6613
+ #isEpochBoundary(blockHeight) {
6614
+ return (blockHeight + 1) % this.#epochLength === 0;
6615
+ }
6616
+ /** Handle epoch transition when a block crosses epoch boundary */
6617
+ async #handleEpochBoundary(blockHeight) {
6618
+ if (!this.#isEpochBoundary(blockHeight))
6619
+ return;
6620
+ // Epoch boundary crossed: update epoch start, reset round, trigger validator rotation
6621
+ this.#currentEpochStartHeight = blockHeight + 1;
6622
+ this.#consensusRound = 0;
6623
+ debug(`[consensus] Epoch boundary at block ${blockHeight}: new epoch starts at height ${this.#currentEpochStartHeight}`);
6624
+ // If we're participating as a validator, trigger immediate epoch to determine new proposer
6625
+ if (this.#participating && !this.#runningEpoch) {
6626
+ await this.#runEpoch();
6627
+ }
6628
+ }
6232
6629
  async #runEpoch() {
6233
6630
  if (this.#runningEpoch)
6234
6631
  return;
6235
6632
  this.#runningEpoch = true;
6236
6633
  console.log('epoch');
6237
- const validators = await this.staticCall(addresses.validators, 'validators');
6634
+ const validators = await this.#getConsensusValidators();
6238
6635
  console.log({ validators });
6636
+ if (this.#isJailed(peernet.selectedAccount)) {
6637
+ this.#runningEpoch = false;
6638
+ return;
6639
+ }
6239
6640
  if (!validators.includes(peernet.selectedAccount)) {
6240
6641
  this.#runningEpoch = false;
6241
6642
  return;
6242
6643
  }
6644
+ // Phase 1: Deterministic proposer selection
6645
+ // proposer = validators[(nextBlockIndex + round) % validators.length]
6646
+ const localBlock = await this.lastBlock;
6647
+ const nextIndex = (localBlock?.index !== undefined ? Number(localBlock.index) : -1) + 1;
6648
+ const proposerIdx = (nextIndex + this.#consensusRound) % validators.length;
6649
+ const isProposer = validators[proposerIdx] === peernet.selectedAccount;
6650
+ if (!isProposer) {
6651
+ // Non-proposer: start round-advance timer in case proposer is unresponsive
6652
+ if (!this.#roundTimer) {
6653
+ this.#roundTimer = setTimeout(async () => {
6654
+ this.#roundTimer = null;
6655
+ this.#consensusRound++;
6656
+ debug(`[consensus] Round timed out, advancing to round ${this.#consensusRound}`);
6657
+ this.#runningEpoch = false;
6658
+ if (this.#participating)
6659
+ await this.#runEpoch();
6660
+ }, this.#slotTime);
6661
+ }
6662
+ this.#runningEpoch = false;
6663
+ return;
6664
+ }
6665
+ // We are the proposer — clear any stale round-advance timer
6666
+ if (this.#roundTimer) {
6667
+ clearTimeout(this.#roundTimer);
6668
+ this.#roundTimer = null;
6669
+ }
6243
6670
  const start = Date.now();
6244
6671
  try {
6245
6672
  await this.#createBlock();
@@ -6324,6 +6751,10 @@ class Chain extends VersionControl {
6324
6751
  globalThis.peernet.subscribe('send-transaction', this.#sendTransaction.bind(this));
6325
6752
  globalThis.peernet.subscribe('add-transaction', this.#addTransaction.bind(this));
6326
6753
  globalThis.peernet.subscribe('validator:timeout', this.#validatorTimeout.bind(this));
6754
+ // Tendermint consensus topics
6755
+ globalThis.peernet.subscribe('consensus:propose', this.#handleProposal.bind(this));
6756
+ globalThis.peernet.subscribe('consensus:prevote', this.#handlePrevote.bind(this));
6757
+ globalThis.peernet.subscribe('consensus:precommit', this.#handlePrecommit.bind(this));
6327
6758
  globalThis.pubsub.subscribe('peer:connected', this.#peerConnected.bind(this));
6328
6759
  globalThis.pubsub.publish('chain:ready', true);
6329
6760
  console.log('[chain] init:done');
@@ -6339,11 +6770,44 @@ class Chain extends VersionControl {
6339
6770
  await globalThis.transactionPoolStore.delete(hash);
6340
6771
  }
6341
6772
  async #validatorTimeout(validatorInfo) {
6342
- setTimeout(() => {
6343
- this.#jail.splice(this.#jail.indexOf(validatorInfo.address), 1);
6344
- }, validatorInfo.timeout);
6345
- this.#jail.push(validatorInfo.address);
6346
- }
6773
+ const address = validatorInfo?.address;
6774
+ if (!address)
6775
+ return;
6776
+ const timeout = Math.min(Math.max(Number(validatorInfo.timeout) || 0, 0), 60 * 60 * 1000);
6777
+ const existingRelease = this.#jailReleaseTimers.get(address);
6778
+ if (existingRelease)
6779
+ clearTimeout(existingRelease);
6780
+ this.#jail.add(address);
6781
+ const releaseTimer = setTimeout(() => {
6782
+ this.#jail.delete(address);
6783
+ this.#jailReleaseTimers.delete(address);
6784
+ }, timeout);
6785
+ this.#jailReleaseTimers.set(address, releaseTimer);
6786
+ }
6787
+ // ── Tendermint consensus handlers ────────────────────────────────────────
6788
+ /**
6789
+ * Publish a prevote or precommit. Idempotent — will not cast the same
6790
+ * vote twice for the same height:round.
6791
+ */
6792
+ #castVote;
6793
+ /**
6794
+ * Phase 2 — receive a block proposal from the designated proposer.
6795
+ * Validates the proposer is correct for height/round, fetches + validates
6796
+ * the block from peernet, then casts a prevote.
6797
+ */
6798
+ #handleProposal;
6799
+ /**
6800
+ * Phase 2 — collect prevotes. Once 2f+1 prevotes are seen for a block,
6801
+ * cast a precommit.
6802
+ */
6803
+ #handlePrevote;
6804
+ /**
6805
+ * Phase 3 — collect precommits. Once 2f+1 precommits are seen for a block,
6806
+ * commit it: non-proposers call #addBlock, then broadcast on add-block for
6807
+ * syncing nodes.
6808
+ */
6809
+ #handlePrecommit;
6810
+ // ─────────────────────────────────────────────────────────────────────────
6347
6811
  #addTransaction;
6348
6812
  async #prepareRequest(request) {
6349
6813
  let node = await new globalThis.peernet.protos['peernet-request']({ request });
@@ -6399,17 +6863,7 @@ class Chain extends VersionControl {
6399
6863
  debug(`peer connected: ${peerId}`);
6400
6864
  const peer = peernet.getConnection(peerId);
6401
6865
  debug(`peer connected with version ${peer.version}`);
6402
- // todo handle version changes
6403
- // for now just do nothing if version doesn't match
6404
- debug(`peer connected with version ${peer.version}`);
6405
- const compatibleVersion = () => {
6406
- if (!peer.version || !this.version)
6407
- return false;
6408
- const [peerMajor, peerMinor] = peer.version.split('.');
6409
- const [localMajor, localMinor] = this.version.split('.');
6410
- return peerMajor === localMajor && peerMinor === localMinor;
6411
- };
6412
- if (!compatibleVersion()) {
6866
+ if (!this.isVersionCompatible(peer.version)) {
6413
6867
  debug(`versions don't match`);
6414
6868
  return;
6415
6869
  }
@@ -6423,9 +6877,19 @@ class Chain extends VersionControl {
6423
6877
  catch (error) {
6424
6878
  const peerName = peer?.peerId || peer?.id || peer?.address || peerId || 'unknown';
6425
6879
  debug(`lastBlock request failed: ${peerName}:`, error?.message ?? error);
6880
+ await this.#recordPeerFailure(peerId, `lastBlock request failed: ${error?.message ?? error}`);
6426
6881
  return;
6427
6882
  }
6883
+ // CRITICAL: Validate the peer's claimed block height is not unreasonably ahead of our local chain
6884
+ // This prevents Byzantine nodes from claiming a fake chain length to steer our sync
6428
6885
  const localBlock = await this.lastBlock;
6886
+ const MAX_SYNC_AHEAD = 100_000;
6887
+ if (lastBlock?.index > (localBlock?.index ?? 0) + MAX_SYNC_AHEAD) {
6888
+ const peerName = peer?.peerId || peer?.id || peer?.address || peerId || 'unknown';
6889
+ debug(`Peer ${peerName} claims unreasonable block height ${lastBlock.index} (local: ${localBlock?.index ?? 0})`);
6890
+ await this.#recordPeerFailure(peerId, `unreasonable lastBlock index: ${lastBlock.index}`);
6891
+ return;
6892
+ }
6429
6893
  if (!lastBlock || !lastBlock.hash || lastBlock.hash === '0x0') {
6430
6894
  debug(`peer has no lastBlock: ${peerId}`);
6431
6895
  return;
@@ -6443,14 +6907,20 @@ class Chain extends VersionControl {
6443
6907
  console.log(e);
6444
6908
  }
6445
6909
  }
6446
- if (knownBlocksResponse.blocks)
6447
- for (const hash of knownBlocksResponse.blocks) {
6448
- this.wantList.push(hash);
6910
+ const MAX_WANTLIST_SIZE = 1000;
6911
+ if (knownBlocksResponse.blocks) {
6912
+ const remaining = MAX_WANTLIST_SIZE - this.wantList.length;
6913
+ if (remaining > 0) {
6914
+ for (const hash of knownBlocksResponse.blocks.slice(0, remaining)) {
6915
+ this.wantList.push(hash);
6916
+ }
6449
6917
  }
6918
+ }
6450
6919
  }
6451
6920
  catch (error) {
6452
6921
  const peerName = peer?.peerId || peer?.id || peer?.address || peerId || 'unknown';
6453
6922
  debug(`knownBlocks request failed: ${peerName}:`, error?.message ?? error);
6923
+ await this.#recordPeerFailure(peerId, `knownBlocks request failed: ${error?.message ?? error}`);
6454
6924
  return;
6455
6925
  }
6456
6926
  }
@@ -6493,6 +6963,7 @@ class Chain extends VersionControl {
6493
6963
  catch (error) {
6494
6964
  const peerName = peer?.peerId || peer?.id || peer?.address || peerId || 'unknown';
6495
6965
  debug(`stateInfo/syncChain failed: ${peerName}:`, error?.message ?? error);
6966
+ await this.#recordPeerFailure(peerId, `stateInfo/syncChain failed: ${error?.message ?? error}`);
6496
6967
  return;
6497
6968
  }
6498
6969
  }
@@ -6529,26 +7000,55 @@ class Chain extends VersionControl {
6529
7000
  const receivedEncoded = block instanceof BlockMessage ? block.encoded : block;
6530
7001
  const blockMessage = await new BlockMessage(block);
6531
7002
  const hash = await blockMessage.hash();
6532
- // Verify data integrity: re-encode should produce the same bytes
6533
- const canonicalEncoded = blockMessage.encoded;
6534
- if (receivedEncoded.length === canonicalEncoded.length) {
6535
- let mismatch = false;
6536
- for (let i = 0; i < receivedEncoded.length; i++) {
6537
- if (receivedEncoded[i] !== canonicalEncoded[i]) {
6538
- mismatch = true;
6539
- break;
6540
- }
7003
+ // CRITICAL: VALIDATE BEFORE TOUCHING STATE
7004
+ // 1. Check for duplicate blocks at same height
7005
+ const blockIndex = Number(blockMessage.decoded.index);
7006
+ const existingBlockAtHeight = this.#blocks[blockIndex];
7007
+ if (existingBlockAtHeight) {
7008
+ if (existingBlockAtHeight.hash !== hash) {
7009
+ console.error(`[CONSENSUS ALERT] Conflicting blocks at height ${blockIndex}:`);
7010
+ console.error(` Local: ${existingBlockAtHeight.hash}`);
7011
+ console.error(` Remote: ${hash}`);
7012
+ throw new Error(`Block conflict detected at index ${blockIndex}`);
6541
7013
  }
6542
- if (mismatch) {
6543
- console.warn(`[chain] ⚠️ Block data corrupted in transit: encoded bytes don't match canonical form for block #${blockMessage.decoded.index}`);
7014
+ // Already have this exact block, skip
7015
+ debug(`Block already in store: ${hash}`);
7016
+ return;
7017
+ }
7018
+ // 2. Verify previous hash chain integrity
7019
+ if (blockIndex > 0) {
7020
+ const previousBlockInfo = this.#blocks[blockIndex - 1];
7021
+ if (!previousBlockInfo) {
7022
+ throw new Error(`Missing parent block at index ${blockIndex - 1}`);
6544
7023
  }
6545
- else {
6546
- console.log(`[chain] Block data integrity verified via codec: ${hash}`);
7024
+ if (previousBlockInfo.hash !== blockMessage.decoded.previousHash) {
7025
+ throw new Error(`previousHash mismatch at index ${blockIndex}: ` +
7026
+ `expected ${previousBlockInfo.hash}, got ${blockMessage.decoded.previousHash}`);
6547
7027
  }
6548
7028
  }
6549
- else {
6550
- console.warn(`[chain] ⚠️ Block data size mismatch: received ${receivedEncoded.length} bytes but canonical is ${canonicalEncoded.length} bytes for block #${blockMessage.decoded.index}`);
7029
+ else if (blockMessage.decoded.previousHash !== '0x0') {
7030
+ throw new Error(`Genesis block (index 0) must have previousHash='0x0'`);
7031
+ }
7032
+ // 3. Verify data integrity
7033
+ const canonicalEncoded = blockMessage.encoded;
7034
+ const byteLengthMatch = receivedEncoded.length === canonicalEncoded.length;
7035
+ if (!byteLengthMatch) {
7036
+ throw new Error(`[FATAL] Block data size mismatch: received ${receivedEncoded.length} bytes ` +
7037
+ `but canonical encoding is ${canonicalEncoded.length} bytes for block #${blockMessage.decoded.index}`);
7038
+ }
7039
+ let mismatch = false;
7040
+ for (let i = 0; i < receivedEncoded.length; i++) {
7041
+ if (receivedEncoded[i] !== canonicalEncoded[i]) {
7042
+ mismatch = true;
7043
+ break;
7044
+ }
6551
7045
  }
7046
+ if (mismatch) {
7047
+ throw new Error(`[FATAL] Block data corrupted in transit for block #${blockIndex} hash ${hash}`);
7048
+ }
7049
+ console.log(`[chain] ✅ Block data integrity verified: ${hash}`);
7050
+ this.#validateBlockValidators(blockMessage);
7051
+ // NOW SAFE TO PROCEED with transaction processing
6552
7052
  const transactions = await Promise.all(blockMessage.decoded.transactions
6553
7053
  // @ts-ignore
6554
7054
  .map(async (hash) => {
@@ -6557,24 +7057,34 @@ class Chain extends VersionControl {
6557
7057
  return new TransactionMessage(data);
6558
7058
  }));
6559
7059
  await globalThis.blockStore.put(hash, blockMessage.encoded);
7060
+ // Cache block for conflict detection
7061
+ this.#blocks[blockIndex] = {
7062
+ hash,
7063
+ ...blockMessage.decoded
7064
+ };
6560
7065
  debug(`added block: ${hash}`);
6561
7066
  let promises = [];
6562
7067
  let contracts = [];
6563
- const normalTransactions = [];
6564
- const priorityransactions = [];
6565
- for (const transaction of transactions) {
7068
+ // Combine and sort all transactions deterministically
7069
+ const allTransactions = transactions.sort((a, b) => {
7070
+ // Primary: by priority (true first)
7071
+ if (a.decoded.priority !== b.decoded.priority) {
7072
+ return (b.decoded.priority ? 1 : 0) - (a.decoded.priority ? 1 : 0);
7073
+ }
7074
+ // Secondary: by nonce
7075
+ const nonceDiff = (a.decoded?.nonce ?? 0) - (b.decoded?.nonce ?? 0);
7076
+ if (nonceDiff !== 0)
7077
+ return nonceDiff;
7078
+ // Tertiary: in stable order (insertion order preserved)
7079
+ return 0;
7080
+ });
7081
+ // Execute sequentially (NOT concurrently) to ensure deterministic state
7082
+ for (const transaction of allTransactions) {
6566
7083
  if (!contracts.includes(transaction.decoded.to)) {
6567
7084
  contracts.push(transaction.decoded.to);
6568
7085
  }
6569
- if (transaction.decoded.priority)
6570
- priorityransactions.push(transaction);
6571
- else
6572
- normalTransactions.push(transaction);
6573
- }
6574
- for (const transaction of priorityransactions.sort((a, b) => a.decoded.nonce - b.decoded.nonce)) {
6575
7086
  await this.#handleTransaction(transaction, []);
6576
7087
  }
6577
- await Promise.all(normalTransactions.map((transaction) => this.#handleTransaction(transaction, [])));
6578
7088
  // for (let transaction of transactionsMessages) {
6579
7089
  // // await transactionStore.put(transaction.hash, transaction.encoded)
6580
7090
  // if (!contracts.includes(transaction.to)) {
@@ -6599,6 +7109,15 @@ class Chain extends VersionControl {
6599
7109
  await Promise.all(Object.entries(noncesByAddress).map(([from, nonce]) => globalThis.accountsStore.put(from, String(nonce))));
6600
7110
  if ((await this.lastBlock).index < Number(blockMessage.decoded.index)) {
6601
7111
  await this.machine.addLoadedBlock({ ...blockMessage.decoded, loaded: true, hash: await blockMessage.hash() });
7112
+ // Record validator snapshot at this block height for future consensus queries
7113
+ try {
7114
+ await this.call(addresses.validators, 'recordValidatorSnapshot', [blockMessage.decoded.index]);
7115
+ }
7116
+ catch (snapshotError) {
7117
+ debug(`failed to record validator snapshot: ${snapshotError?.message ?? snapshotError}`);
7118
+ }
7119
+ // Check if this block crosses epoch boundary and handle transition
7120
+ await this.#handleEpochBoundary(Number(blockMessage.decoded.index));
6602
7121
  await this.updateState(blockMessage);
6603
7122
  }
6604
7123
  globalThis.pubsub.publish('block-processed', blockMessage.decoded);
@@ -6690,23 +7209,31 @@ class Chain extends VersionControl {
6690
7209
  timestamp,
6691
7210
  previousHash: '',
6692
7211
  reward: BigInt(150),
6693
- index: 0
7212
+ index: 0,
7213
+ producer: '',
7214
+ producerProof: '',
7215
+ protocolVersion: this.version
6694
7216
  };
6695
7217
  const latestTransactions = await this.machine.latestTransactions();
6696
7218
  // exclude failing tx
6697
7219
  transactions = await this.promiseTransactions(transactions);
6698
- const normalTransactions = [];
6699
- const priorityransactions = [];
6700
- for (const transaction of transactions) {
6701
- if (transaction.decoded.priority)
6702
- priorityransactions.push(transaction);
6703
- else
6704
- normalTransactions.push(transaction);
6705
- }
6706
- for (const transaction of priorityransactions.sort((a, b) => a.decoded.nonce - b.decoded.nonce)) {
7220
+ // Combine priority and normal transactions, then sort deterministically
7221
+ const allTransactions = transactions.sort((a, b) => {
7222
+ // Primary: by priority (true first)
7223
+ if (a.decoded.priority !== b.decoded.priority) {
7224
+ return (b.decoded.priority ? 1 : 0) - (a.decoded.priority ? 1 : 0);
7225
+ }
7226
+ // Secondary: by nonce
7227
+ const nonceDiff = (a.decoded?.nonce ?? 0) - (b.decoded?.nonce ?? 0);
7228
+ if (nonceDiff !== 0)
7229
+ return nonceDiff;
7230
+ // Tertiary: in stable order (insertion order preserved)
7231
+ return 0;
7232
+ });
7233
+ // Execute sequentially (NOT concurrently) to ensure deterministic state
7234
+ for (const transaction of allTransactions) {
6707
7235
  await this.#handleTransaction(transaction, latestTransactions, block);
6708
7236
  }
6709
- await Promise.all(normalTransactions.map((transaction) => this.#handleTransaction(transaction, latestTransactions, block)));
6710
7237
  // don't add empty block
6711
7238
  if (block.transactions.length === 0)
6712
7239
  return;
@@ -6724,7 +7251,7 @@ class Chain extends VersionControl {
6724
7251
  }
6725
7252
  for (const validator of validators) {
6726
7253
  const peer = peers[validator];
6727
- if (peer && peer.connected && peer.version === this.version) {
7254
+ if (peer && peer.connected && this.isVersionCompatible(peer.version)) {
6728
7255
  let data = await new BWRequestMessage();
6729
7256
  const node = await globalThis.peernet.prepareMessage(data.encoded);
6730
7257
  try {
@@ -6764,26 +7291,54 @@ class Chain extends VersionControl {
6764
7291
  // block.timestamp = Date.now()
6765
7292
  // block.reward = block.reward.toString()
6766
7293
  // block.fees = block.fees.toString()
7294
+ // CRITICAL FIX: Sort validators deterministically to avoid encoding divergence
7295
+ // Use canonical validator set from contract, sorted by address
7296
+ const canonicalValidators = (await this.staticCall(addresses.validators, 'validators'));
7297
+ const sortedValidators = [...canonicalValidators].sort();
7298
+ // Apply reward to all canonical validators (deterministic)
7299
+ block.validators = sortedValidators.map((validatorAddress) => ({
7300
+ address: validatorAddress,
7301
+ reward: block.fees / BigInt(sortedValidators.length) + block.reward / BigInt(sortedValidators.length)
7302
+ }));
6767
7303
  try {
6768
7304
  await Promise.all(block.transactions.map(async (transaction) => {
6769
7305
  await globalThis.transactionStore.put(transaction, await transactionPoolStore.get(transaction));
6770
7306
  await globalThis.transactionPoolStore.delete(transaction);
6771
7307
  }));
7308
+ // Set producer to current account
7309
+ block.producer = globalThis.peernet.selectedAccount || '';
7310
+ // Sign block hash to authenticate producer (producer must be the proposer)
7311
+ if (block.producer && this.keypair) {
7312
+ const blockHashInput = JSON.stringify({
7313
+ index: block.index,
7314
+ previousHash: block.previousHash,
7315
+ timestamp: block.timestamp,
7316
+ validators: block.validators.map((v) => v.address).sort()
7317
+ });
7318
+ block.producerProof = await signTransaction(blockHashInput, this.keypair);
7319
+ }
6772
7320
  let blockMessage = await new BlockMessage(block);
6773
7321
  const hash = await blockMessage.hash();
6774
7322
  await globalThis.peernet.put(hash, blockMessage.encoded, 'block');
6775
7323
  await this.machine.addLoadedBlock({ ...blockMessage.decoded, loaded: true, hash: await blockMessage.hash() });
6776
7324
  await this.updateState(blockMessage);
6777
7325
  debug(`created block: ${hash} @${block.index}`);
6778
- // Publish canonical encoded form via codec interface
6779
- console.log(`[chain] 📤 Publishing block #${block.index} | hash: ${hash} | encoded bytes: ${blockMessage.encoded.length}`);
7326
+ // Phase 2: announce proposal for consensus voting instead of direct add-block
7327
+ console.log(`[consensus] 📤 Proposing block #${block.index} | hash: ${hash} | round: ${this.#consensusRound}`);
7328
+ const proposalPayload = new TextEncoder().encode(JSON.stringify({
7329
+ blockHash: hash,
7330
+ index: block.index,
7331
+ round: this.#consensusRound,
7332
+ from: peernet.selectedAccount
7333
+ }));
6780
7334
  try {
6781
- globalThis.peernet.publish('add-block', blockMessage.encoded);
7335
+ globalThis.peernet.publish('consensus:propose', proposalPayload);
6782
7336
  }
6783
7337
  catch (publishError) {
6784
- debug('peernet publish failed: add-block', publishError?.message ?? publishError);
7338
+ debug('peernet publish failed: consensus:propose', publishError?.message ?? publishError);
6785
7339
  }
6786
- globalThis.pubsub.publish('add-block', blockMessage.encoded);
7340
+ // Proposer casts their own prevote immediately
7341
+ await this.#castVote('prevote', hash, block.index, this.#consensusRound);
6787
7342
  }
6788
7343
  catch (error) {
6789
7344
  console.log(error);