@leofcoin/chain 1.8.30 → 1.9.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/exports/chain.js CHANGED
@@ -163,8 +163,6 @@ class Transaction extends Protocol {
163
163
  transactions = await this.promiseTransactions(transactions);
164
164
  transactions = transactions.filter((tx) => tx.decoded.from === address);
165
165
  for (const transaction of transactions) {
166
- if (transaction.decoded.nonce > nonce)
167
- throw new Error(`a transaction with a higher nonce already exists`);
168
166
  if (transaction.decoded.nonce === nonce)
169
167
  throw new Error(`a transaction with the same nonce already exists`);
170
168
  }
@@ -771,20 +769,30 @@ class Machine {
771
769
  }
772
770
  await Promise.all(promises);
773
771
  }
772
+ // Helper to sort object keys for deterministic serialization
773
+ const sortedStringify = (obj, replacer) => {
774
+ const sorted = {};
775
+ const keys = Object.keys(obj).sort();
776
+ for (const key of keys) {
777
+ sorted[key] = obj[key];
778
+ }
779
+ return JSON.stringify(sorted, replacer);
780
+ };
774
781
  const tasks = [
775
- stateStore.put('lastBlock', JSON.stringify(await this.lastBlock, jsonStringifyBigInt)),
776
- stateStore.put('states', JSON.stringify(state, jsonStringifyBigInt)),
777
- stateStore.put('accounts', JSON.stringify(accounts, jsonStringifyBigInt)),
778
- stateStore.put('info', JSON.stringify({
782
+ stateStore.put('lastBlock', sortedStringify(await this.lastBlock, jsonStringifyBigInt)),
783
+ stateStore.put('states', sortedStringify(state, jsonStringifyBigInt)),
784
+ stateStore.put('accounts', sortedStringify(accounts, jsonStringifyBigInt)),
785
+ stateStore.put('info', sortedStringify({
786
+ nativeBurnAmount: await this.totalBurnAmount,
787
+ nativeBurns: await this.nativeBurns,
779
788
  nativeCalls: await this.nativeCalls,
780
789
  nativeMints: await this.nativeMints,
781
- nativeBurns: await this.nativeBurns,
782
790
  nativeTransfers: await this.nativeTransfers,
783
- totalTransactions: await this.totalTransactions,
791
+ totalBlocks: await blockStore.length,
784
792
  totalBurnAmount: await this.totalBurnAmount,
785
793
  totalMintAmount: await this.totalMintAmount,
786
- totalTransferAmount: await this.totalTransferAmount,
787
- totalBlocks: await blockStore.length
794
+ totalTransactions: await this.totalTransactions,
795
+ totalTransferAmount: await this.totalTransferAmount
788
796
  }, jsonStringifyBigInt))
789
797
  // accountsStore.clear()
790
798
  ];
@@ -1640,6 +1648,14 @@ class State extends Contract {
1640
1648
  debug$1(`Local index ${localIndex} is ahead of remote ${remoteIndex}, skipping sync`);
1641
1649
  return;
1642
1650
  }
1651
+ // CRITICAL: Prevent DoS from excessive reorgs
1652
+ const MAX_REORG_DEPTH = 6;
1653
+ const reorgDepth = localIndex - remoteIndex;
1654
+ if (reorgDepth > 0 && reorgDepth > MAX_REORG_DEPTH) {
1655
+ console.warn(`[consensus-safety] Peer proposing reorg depth of ${reorgDepth} blocks ` +
1656
+ `(limit is ${MAX_REORG_DEPTH}). Rejecting to prevent DoS.`);
1657
+ throw new Error(`Excessive reorg depth: ${reorgDepth} blocks (max ${MAX_REORG_DEPTH})`);
1658
+ }
1643
1659
  // Use state hash comparison: only resolve if remote hash differs from local state hash
1644
1660
  if (localStateHash !== remoteBlockHash) {
1645
1661
  if (this.wantList.length > 0) {
@@ -1700,7 +1716,15 @@ class State extends Contract {
1700
1716
  for (const id in globalThis.peernet.connections) {
1701
1717
  // @ts-ignore
1702
1718
  const peer = globalThis.peernet.connections[id];
1703
- if (peer.connected && peer.version === this.version) {
1719
+ // CRITICAL FIX: Use semver comparison (major.minor) not exact match
1720
+ const isVersionCompatible = () => {
1721
+ if (!peer.version || !this.version)
1722
+ return false;
1723
+ const [peerMajor, peerMinor] = peer.version.split('.');
1724
+ const [localMajor, localMinor] = this.version.split('.');
1725
+ return peerMajor === localMajor && peerMinor === localMinor;
1726
+ };
1727
+ if (peer.connected && isVersionCompatible()) {
1704
1728
  const task = async () => {
1705
1729
  try {
1706
1730
  const result = await peer.request(node.encoded);
@@ -1734,7 +1758,15 @@ class State extends Contract {
1734
1758
  throw new Error('invalid block @getLatestBlock');
1735
1759
  latest = { ...message.decoded, hash };
1736
1760
  const peer = promises[0].peer;
1737
- if (peer.connected && peer.version === this.version) {
1761
+ // CRITICAL FIX: Check version compatibility using semver
1762
+ const isVersionCompatible = () => {
1763
+ if (!peer.version || !this.version)
1764
+ return false;
1765
+ const [peerMajor, peerMinor] = peer.version.split('.');
1766
+ const [localMajor, localMinor] = this.version.split('.');
1767
+ return peerMajor === localMajor && peerMinor === localMinor;
1768
+ };
1769
+ if (peer.connected && isVersionCompatible()) {
1738
1770
  let data = await new globalThis.peernet.protos['peernet-request']({
1739
1771
  request: 'knownBlocks'
1740
1772
  });
@@ -1742,7 +1774,11 @@ class State extends Contract {
1742
1774
  try {
1743
1775
  let message = await peer.request(node.encode());
1744
1776
  message = await new globalThis.peernet.protos['peernet-response'](message);
1745
- this.wantList.push(...message.decoded.response.blocks.filter((block) => !this.knownBlocks.includes(block)));
1777
+ const MAX_WANTLIST_SIZE = 1000;
1778
+ const incoming = message.decoded.response.blocks.filter((block) => !this.knownBlocks.includes(block));
1779
+ const remaining = MAX_WANTLIST_SIZE - this.wantList.length;
1780
+ if (remaining > 0)
1781
+ this.wantList.push(...incoming.slice(0, remaining));
1746
1782
  }
1747
1783
  catch (error) {
1748
1784
  const peerId = peer?.peerId || peer?.id || peer?.address || 'unknown';
@@ -1973,6 +2009,13 @@ class VersionControl extends State {
1973
2009
  return this.#setCurrentVersion();
1974
2010
  }
1975
2011
  }
2012
+ isVersionCompatible(peerVersion) {
2013
+ if (!peerVersion || !this.version)
2014
+ return false;
2015
+ const [peerMajor, peerMinor] = peerVersion.split('.');
2016
+ const [localMajor, localMinor] = this.version.split('.');
2017
+ return peerMajor === localMajor && peerMinor === localMinor;
2018
+ }
1976
2019
  }
1977
2020
 
1978
2021
  /**
@@ -2094,14 +2137,12 @@ class ConnectionMonitor {
2094
2137
  }
2095
2138
  const connectedPeers = this.connectedPeers;
2096
2139
  const compatiblePeers = this.compatiblePeers;
2097
- console.log(`🔍 Health check: ${connectedPeers.length} connected, ${compatiblePeers.length} compatible`);
2098
- // If a reconnection is already ongoing, skip this cycle to avoid log spam/loops
2099
- if (this.#reconnecting) {
2100
- console.log('⏭️ Health check: reconnection already in progress, skipping reconnect attempt');
2101
- return;
2102
- }
2103
- // If we have no connections or none are compatible, try to reconnect
2104
- if (connectedPeers.length === 0) {
2140
+ const disconnectedPeers = this.disconnectedPeers;
2141
+ console.log(`🔍 Health check: ${connectedPeers.length} connected, ${compatiblePeers.length} compatible, ${disconnectedPeers.length} negotiating`);
2142
+ // If we have no connections or none are compatible, try to reconnect.
2143
+ // Don't trigger reconnection if peers are still in WebRTC ICE negotiation (disconnectedPeers > 0)
2144
+ // reinit() would tear down the in-flight handshake.
2145
+ if (connectedPeers.length === 0 && disconnectedPeers.length === 0) {
2105
2146
  console.warn('⚠️ No peer connections detected — attempting reconnection');
2106
2147
  await this.#attemptReconnection();
2107
2148
  }
@@ -2203,20 +2244,7 @@ class ConnectionMonitor {
2203
2244
  console.warn(' ⚠️ peernet.start() failed:', e?.message || e);
2204
2245
  }
2205
2246
  }
2206
- // Approach 3: Try client.connect if available
2207
- if (globalThis.peernet?.client &&
2208
- 'connect' in globalThis.peernet.client &&
2209
- typeof globalThis.peernet.client.connect === 'function') {
2210
- console.log(' → Trying client.connect()');
2211
- try {
2212
- await globalThis.peernet.client.connect();
2213
- console.log(' ✅ client.connect() succeeded');
2214
- }
2215
- catch (e) {
2216
- console.warn(' ⚠️ client.connect() failed:', e?.message || e);
2217
- }
2218
- }
2219
- // Approach 4: Explicitly dial star servers if available
2247
+ // Approach 3: Explicitly dial star servers if available (only if client.reinit() didn't succeed)
2220
2248
  try {
2221
2249
  const networkName = globalThis.peernet?.network;
2222
2250
  if (networkName && typeof networkName === 'string') {
@@ -2232,11 +2260,6 @@ class ConnectionMonitor {
2232
2260
  await globalThis.peernet.client.dial(star);
2233
2261
  console.log(` ✅ Connected to star server: ${star}`);
2234
2262
  }
2235
- else if (globalThis.peernet?.client && 'connect' in globalThis.peernet.client) {
2236
- // Try connect with the star URL
2237
- await globalThis.peernet.client.connect(star);
2238
- console.log(` ✅ Connected to star server: ${star}`);
2239
- }
2240
2263
  }
2241
2264
  catch (e) {
2242
2265
  console.warn(` ⚠️ Failed to dial ${star}:`, e?.message || e);
@@ -2278,14 +2301,13 @@ class ConnectionMonitor {
2278
2301
  });
2279
2302
  }
2280
2303
  async #attemptReconnection() {
2281
- if (this.#reconnecting) {
2282
- console.log('⏭️ Reconnection already in progress');
2283
- return;
2284
- }
2285
2304
  try {
2286
2305
  await this.#restoreNetwork();
2287
- // Check if reconnection was successful
2288
- const hasConnections = this.connectedPeers.length > 0;
2306
+ // Check if reconnection was successful.
2307
+ // Treat peers that are still negotiating (in connections but not yet connected) as success —
2308
+ // they were discovered via the star and WebRTC ICE is in progress. Retrying now would call
2309
+ // reinit() again and tear down the in-flight handshake.
2310
+ const hasConnections = this.connectedPeers.length > 0 || this.disconnectedPeers.length > 0;
2289
2311
  if (hasConnections) {
2290
2312
  console.log('✅ Reconnection successful, resetting backoff delay');
2291
2313
  this.#reconnectDelay = 5000;
@@ -2335,35 +2357,252 @@ class Chain extends VersionControl {
2335
2357
  #state;
2336
2358
  #slotTime;
2337
2359
  #blockTime; // 6 second target block time
2360
+ #epochLength; // Blocks per epoch (enables block-based epoch boundaries)
2338
2361
  /** {Address[]} */
2339
2362
  #validators;
2340
2363
  /** {Boolean} */
2341
2364
  #runningEpoch;
2365
+ /** Block height at which current epoch started (for block-based epoch timing) */
2366
+ #currentEpochStartHeight;
2367
+ /** {Object} Block cache by index for conflict detection: {index: {hash, ...block}} */
2368
+ #blocks;
2342
2369
  #participants;
2343
2370
  #participating;
2344
2371
  #jail;
2372
+ #jailReleaseTimers;
2345
2373
  #peerConnectionRetries;
2346
2374
  #maxPeerRetries;
2347
2375
  #peerRetryDelay;
2376
+ /** {Map} Peer reputation tracking: {peerId: {score, failures}} */
2377
+ #peerReputations;
2378
+ #minPeerScore;
2379
+ #maxPeerFailures;
2380
+ // ── Tendermint consensus state ──────────────────────────────────────────────
2381
+ /** Current consensus round (increments when proposer is unresponsive) */
2382
+ #consensusRound;
2383
+ /** Timer that advances #consensusRound when the proposer doesn't propose in time */
2384
+ #roundTimer;
2385
+ /** prevotes collected per `height:round:blockHash` key */
2386
+ #prevotes;
2387
+ /** precommits collected per `height:round:blockHash` key */
2388
+ #precommits;
2389
+ /** Index of the last block that reached 2f+1 precommits */
2390
+ #committedHeight;
2391
+ /** Prevents casting duplicate prevote/precommit per height:round */
2392
+ #castedVotes;
2393
+ // ────────────────────────────────────────────────────────────────────────────
2348
2394
  #connectionMonitor;
2349
2395
  constructor(config) {
2350
2396
  super(config);
2351
2397
  this.#slotTime = 10000;
2352
2398
  this.#blockTime = 6000; // 6 second target block time
2399
+ this.#epochLength = 10; // Blocks per epoch (enables block-based epoch boundaries)
2353
2400
  this.utils = {};
2354
2401
  /** {Address[]} */
2355
2402
  this.#validators = [];
2356
2403
  /** {Boolean} */
2357
2404
  this.#runningEpoch = false;
2405
+ /** Block height at which current epoch started (for block-based epoch timing) */
2406
+ this.#currentEpochStartHeight = 0;
2407
+ /** {Object} Block cache by index for conflict detection: {index: {hash, ...block}} */
2408
+ this.#blocks = {};
2358
2409
  this.#participants = [];
2359
2410
  this.#participating = false;
2360
- this.#jail = [];
2411
+ this.#jail = new Set();
2412
+ this.#jailReleaseTimers = new Map();
2361
2413
  this.#peerConnectionRetries = new Map();
2362
2414
  this.#maxPeerRetries = 5;
2363
2415
  this.#peerRetryDelay = 5000;
2416
+ /** {Map} Peer reputation tracking: {peerId: {score, failures}} */
2417
+ this.#peerReputations = new Map();
2418
+ this.#minPeerScore = -10;
2419
+ this.#maxPeerFailures = 100;
2420
+ // ── Tendermint consensus state ──────────────────────────────────────────────
2421
+ /** Current consensus round (increments when proposer is unresponsive) */
2422
+ this.#consensusRound = 0;
2423
+ /** Timer that advances #consensusRound when the proposer doesn't propose in time */
2424
+ this.#roundTimer = null;
2425
+ /** prevotes collected per `height:round:blockHash` key */
2426
+ this.#prevotes = new Map();
2427
+ /** precommits collected per `height:round:blockHash` key */
2428
+ this.#precommits = new Map();
2429
+ /** Index of the last block that reached 2f+1 precommits */
2430
+ this.#committedHeight = -1;
2431
+ /** Prevents casting duplicate prevote/precommit per height:round */
2432
+ this.#castedVotes = new Set();
2364
2433
  this.ready = new Promise((resolve) => {
2365
2434
  this.readyResolve = resolve;
2366
2435
  });
2436
+ // ── Tendermint consensus handlers ────────────────────────────────────────
2437
+ /**
2438
+ * Publish a prevote or precommit. Idempotent — will not cast the same
2439
+ * vote twice for the same height:round.
2440
+ */
2441
+ this.#castVote = async (type, blockHash, index, round) => {
2442
+ const voteKey = `${type}:${index}:${round}`;
2443
+ if (this.#castedVotes.has(voteKey))
2444
+ return;
2445
+ this.#castedVotes.add(voteKey);
2446
+ const from = peernet.selectedAccount;
2447
+ const payload = new TextEncoder().encode(JSON.stringify({ blockHash, index, round, from }));
2448
+ try {
2449
+ globalThis.peernet.publish(`consensus:${type}`, payload);
2450
+ }
2451
+ catch (e) {
2452
+ debug(`peernet publish failed: consensus:${type}`, e?.message ?? e);
2453
+ }
2454
+ };
2455
+ /**
2456
+ * Phase 2 — receive a block proposal from the designated proposer.
2457
+ * Validates the proposer is correct for height/round, fetches + validates
2458
+ * the block from peernet, then casts a prevote.
2459
+ */
2460
+ this.#handleProposal = async (payload) => {
2461
+ try {
2462
+ const msg = JSON.parse(new TextDecoder().decode(payload));
2463
+ const { blockHash, index, round, from } = msg;
2464
+ const validators = await this.#getConsensusValidators(index);
2465
+ const expectedProposerIdx = (index + round) % validators.length;
2466
+ if (!validators[expectedProposerIdx] || validators[expectedProposerIdx] !== from) {
2467
+ debug(`[consensus] Proposal from wrong proposer at height ${index} round ${round}`);
2468
+ return;
2469
+ }
2470
+ const localBlock = await this.lastBlock;
2471
+ const localIndex = localBlock?.index !== undefined ? Number(localBlock.index) : -1;
2472
+ if (index <= localIndex) {
2473
+ debug(`[consensus] Ignoring stale proposal at height ${index} (local: ${localIndex})`);
2474
+ return;
2475
+ }
2476
+ // Fetch block from peernet and verify its hash
2477
+ try {
2478
+ const blockData = await globalThis.peernet.get(blockHash, 'block');
2479
+ const blockMessage = await new BlockMessage(blockData);
2480
+ const actualHash = await blockMessage.hash();
2481
+ if (actualHash !== blockHash) {
2482
+ debug(`[consensus] Block hash mismatch in proposal: expected ${blockHash}, got ${actualHash}`);
2483
+ return;
2484
+ }
2485
+ }
2486
+ catch (e) {
2487
+ debug(`[consensus] Cannot fetch proposed block ${blockHash}:`, e?.message);
2488
+ return;
2489
+ }
2490
+ this.#consensusRound = round;
2491
+ if (this.#roundTimer) {
2492
+ clearTimeout(this.#roundTimer);
2493
+ this.#roundTimer = null;
2494
+ }
2495
+ if (validators.includes(peernet.selectedAccount) && !this.#isJailed(peernet.selectedAccount)) {
2496
+ await this.#castVote('prevote', blockHash, index, round);
2497
+ }
2498
+ }
2499
+ catch (e) {
2500
+ debug('[consensus] Error handling proposal:', e?.message);
2501
+ }
2502
+ };
2503
+ /**
2504
+ * Phase 2 — collect prevotes. Once 2f+1 prevotes are seen for a block,
2505
+ * cast a precommit.
2506
+ */
2507
+ this.#handlePrevote = async (payload) => {
2508
+ try {
2509
+ const msg = JSON.parse(new TextDecoder().decode(payload));
2510
+ const { blockHash, index, round, from } = msg;
2511
+ const validators = await this.#getConsensusValidators(index);
2512
+ if (!validators.includes(from))
2513
+ return;
2514
+ const localBlock = await this.lastBlock;
2515
+ const localIndex = localBlock?.index !== undefined ? Number(localBlock.index) : -1;
2516
+ if (index <= localIndex)
2517
+ return;
2518
+ const voteKey = `${index}:${round}:${blockHash}`;
2519
+ if (!this.#prevotes.has(voteKey))
2520
+ this.#prevotes.set(voteKey, new Set());
2521
+ this.#prevotes.get(voteKey).add(from);
2522
+ const threshold = Math.ceil((2 * validators.length) / 3);
2523
+ const voteCount = this.#prevotes.get(voteKey).size;
2524
+ debug(`[consensus] Prevotes ${voteKey}: ${voteCount}/${validators.length} (need ${threshold})`);
2525
+ if (voteCount >= threshold &&
2526
+ validators.includes(peernet.selectedAccount) &&
2527
+ !this.#isJailed(peernet.selectedAccount)) {
2528
+ await this.#castVote('precommit', blockHash, index, round);
2529
+ }
2530
+ }
2531
+ catch (e) {
2532
+ debug('[consensus] Error handling prevote:', e?.message);
2533
+ }
2534
+ };
2535
+ /**
2536
+ * Phase 3 — collect precommits. Once 2f+1 precommits are seen for a block,
2537
+ * commit it: non-proposers call #addBlock, then broadcast on add-block for
2538
+ * syncing nodes.
2539
+ */
2540
+ this.#handlePrecommit = async (payload) => {
2541
+ try {
2542
+ const msg = JSON.parse(new TextDecoder().decode(payload));
2543
+ const { blockHash, index, round, from } = msg;
2544
+ const validators = await this.#getConsensusValidators(index);
2545
+ if (!validators.includes(from))
2546
+ return;
2547
+ if (index <= this.#committedHeight)
2548
+ return;
2549
+ const voteKey = `${index}:${round}:${blockHash}`;
2550
+ if (!this.#precommits.has(voteKey))
2551
+ this.#precommits.set(voteKey, new Set());
2552
+ this.#precommits.get(voteKey).add(from);
2553
+ const threshold = Math.ceil((2 * validators.length) / 3);
2554
+ const voteCount = this.#precommits.get(voteKey).size;
2555
+ debug(`[consensus] Precommits ${voteKey}: ${voteCount}/${validators.length} (need ${threshold})`);
2556
+ if (voteCount >= threshold && index > this.#committedHeight) {
2557
+ this.#committedHeight = index;
2558
+ this.#consensusRound = 0;
2559
+ // Prune vote state for committed and older heights
2560
+ for (const key of [...this.#prevotes.keys()]) {
2561
+ if (Number(key.split(':')[0]) <= index)
2562
+ this.#prevotes.delete(key);
2563
+ }
2564
+ for (const key of [...this.#precommits.keys()]) {
2565
+ if (Number(key.split(':')[0]) <= index)
2566
+ this.#precommits.delete(key);
2567
+ }
2568
+ for (const key of [...this.#castedVotes]) {
2569
+ if (Number(key.split(':')[1]) <= index)
2570
+ this.#castedVotes.delete(key);
2571
+ }
2572
+ // Non-proposers add the block to local state now.
2573
+ // Proposers already committed state in #createBlock() and their
2574
+ // lastBlock.index === index, so the guard below skips them.
2575
+ const currentBlock = await this.lastBlock;
2576
+ const currentIndex = currentBlock?.index !== undefined ? Number(currentBlock.index) : -1;
2577
+ if (index > currentIndex) {
2578
+ debug(`[consensus] ✅ Committing block ${blockHash} at height ${index}`);
2579
+ try {
2580
+ const blockData = await globalThis.peernet.get(blockHash, 'block');
2581
+ await this.#addBlock(blockData);
2582
+ }
2583
+ catch (e) {
2584
+ debug(`[consensus] Failed to commit block ${blockHash}:`, e?.message);
2585
+ }
2586
+ }
2587
+ else {
2588
+ debug(`[consensus] ✅ Block ${blockHash} at height ${index} already committed (proposer path)`);
2589
+ }
2590
+ // Broadcast committed block so syncing / non-participating nodes can catch up
2591
+ try {
2592
+ const blockData = await globalThis.peernet.get(blockHash, 'block');
2593
+ globalThis.peernet.publish('add-block', blockData);
2594
+ globalThis.pubsub.publish('add-block', blockData);
2595
+ }
2596
+ catch (e) {
2597
+ debug('[consensus] Failed to broadcast committed block:', e?.message);
2598
+ }
2599
+ }
2600
+ }
2601
+ catch (e) {
2602
+ debug('[consensus] Error handling precommit:', e?.message);
2603
+ }
2604
+ };
2605
+ // ─────────────────────────────────────────────────────────────────────────
2367
2606
  this.#addTransaction = async (message) => {
2368
2607
  const transaction = new TransactionMessage(message);
2369
2608
  const hash = await transaction.hash();
@@ -2387,17 +2626,149 @@ class Chain extends VersionControl {
2387
2626
  #sleep(ms) {
2388
2627
  return new Promise((resolve) => setTimeout(resolve, ms));
2389
2628
  }
2629
+ async #recordPeerFailure(peerId, reason) {
2630
+ if (!this.#peerReputations.has(peerId)) {
2631
+ this.#peerReputations.set(peerId, { score: 0, failures: [] });
2632
+ }
2633
+ const rep = this.#peerReputations.get(peerId);
2634
+ rep.score -= 1;
2635
+ rep.failures.push(`${Date.now()}: ${reason}`);
2636
+ if (rep.failures.length > this.#maxPeerFailures) {
2637
+ rep.failures.shift();
2638
+ }
2639
+ if (rep.score < this.#minPeerScore) {
2640
+ console.warn(`[peer-ban] Peer ${peerId} banned after ${rep.failures.length} failures`);
2641
+ // Disconnect and don't reconnect
2642
+ try {
2643
+ await globalThis.peernet.disconnect(peerId);
2644
+ }
2645
+ catch (e) {
2646
+ debug(`Failed to disconnect peer ${peerId}`);
2647
+ }
2648
+ }
2649
+ }
2650
+ #isJailed(address) {
2651
+ return typeof address === 'string' && this.#jail.has(address);
2652
+ }
2653
+ async #getConsensusValidators(nextBlockIndex) {
2654
+ const localBlock = await this.lastBlock;
2655
+ const localIndex = localBlock?.index !== undefined ? Number(localBlock.index) : -1;
2656
+ if (Array.isArray(localBlock?.validators) &&
2657
+ localBlock.validators.length > 0 &&
2658
+ (nextBlockIndex === undefined || nextBlockIndex === localIndex + 1)) {
2659
+ return [
2660
+ ...new Set(localBlock.validators
2661
+ .map((validator) => validator.address)
2662
+ .filter((address) => Boolean(address)))
2663
+ ].sort();
2664
+ }
2665
+ const validators = (await this.staticCall(addresses.validators, 'validators'));
2666
+ return [...new Set(validators)].sort();
2667
+ }
2668
+ #validateBlockValidators(blockMessage) {
2669
+ const validators = blockMessage.decoded.validators || [];
2670
+ if (!Array.isArray(validators) || validators.length === 0) {
2671
+ throw new Error(`Block ${blockMessage.decoded.index} does not include validators`);
2672
+ }
2673
+ // Validate protocol version compatibility
2674
+ if (!blockMessage.decoded.protocolVersion || typeof blockMessage.decoded.protocolVersion !== 'string') {
2675
+ throw new Error(`Block ${blockMessage.decoded.index} does not have a valid protocolVersion field`);
2676
+ }
2677
+ if (!this.isVersionCompatible(blockMessage.decoded.protocolVersion)) {
2678
+ throw new Error(`Block ${blockMessage.decoded.index} uses incompatible protocol version: ${blockMessage.decoded.protocolVersion} ` +
2679
+ `(local: ${this.version}). Major.minor version must match.`);
2680
+ }
2681
+ // Validate producer field
2682
+ if (!blockMessage.decoded.producer || typeof blockMessage.decoded.producer !== 'string') {
2683
+ throw new Error(`Block ${blockMessage.decoded.index} does not have a valid producer field`);
2684
+ }
2685
+ // Validate producerProof field
2686
+ if (!blockMessage.decoded.producerProof || typeof blockMessage.decoded.producerProof !== 'string') {
2687
+ throw new Error(`Block ${blockMessage.decoded.index} does not have a valid producerProof field`);
2688
+ }
2689
+ // Verify producer is in validators list
2690
+ const producerIsValidator = validators.some((v) => v.address === blockMessage.decoded.producer);
2691
+ if (!producerIsValidator) {
2692
+ throw new Error(`Block ${blockMessage.decoded.index} producer ${blockMessage.decoded.producer} is not in validators list`);
2693
+ }
2694
+ const addresses = validators.map((validator) => validator.address);
2695
+ if (addresses.some((address) => typeof address !== 'string' || address.length === 0)) {
2696
+ throw new Error(`Block ${blockMessage.decoded.index} includes an invalid validator address`);
2697
+ }
2698
+ const canonicalAddresses = [...addresses].sort();
2699
+ if (canonicalAddresses.some((address, index) => address !== addresses[index])) {
2700
+ throw new Error(`Block ${blockMessage.decoded.index} validators are not canonically sorted`);
2701
+ }
2702
+ if (new Set(addresses).size !== addresses.length) {
2703
+ throw new Error(`Block ${blockMessage.decoded.index} validators contain duplicates`);
2704
+ }
2705
+ const validatorCount = BigInt(validators.length);
2706
+ const expectedReward = blockMessage.decoded.fees / validatorCount + blockMessage.decoded.reward / validatorCount;
2707
+ for (const validator of validators) {
2708
+ if (validator.reward !== expectedReward) {
2709
+ throw new Error(`Block ${blockMessage.decoded.index} has an invalid reward for validator ${validator.address}: ` +
2710
+ `expected ${expectedReward}, got ${validator.reward}`);
2711
+ }
2712
+ }
2713
+ }
2714
+ /** Check if the next block will cross an epoch boundary (block-based timing) */
2715
+ #isEpochBoundary(blockHeight) {
2716
+ return (blockHeight + 1) % this.#epochLength === 0;
2717
+ }
2718
+ /** Handle epoch transition when a block crosses epoch boundary */
2719
+ async #handleEpochBoundary(blockHeight) {
2720
+ if (!this.#isEpochBoundary(blockHeight))
2721
+ return;
2722
+ // Epoch boundary crossed: update epoch start, reset round, trigger validator rotation
2723
+ this.#currentEpochStartHeight = blockHeight + 1;
2724
+ this.#consensusRound = 0;
2725
+ debug(`[consensus] Epoch boundary at block ${blockHeight}: new epoch starts at height ${this.#currentEpochStartHeight}`);
2726
+ // If we're participating as a validator, trigger immediate epoch to determine new proposer
2727
+ if (this.#participating && !this.#runningEpoch) {
2728
+ await this.#runEpoch();
2729
+ }
2730
+ }
2390
2731
  async #runEpoch() {
2391
2732
  if (this.#runningEpoch)
2392
2733
  return;
2393
2734
  this.#runningEpoch = true;
2394
2735
  console.log('epoch');
2395
- const validators = await this.staticCall(addresses.validators, 'validators');
2736
+ const validators = await this.#getConsensusValidators();
2396
2737
  console.log({ validators });
2738
+ if (this.#isJailed(peernet.selectedAccount)) {
2739
+ this.#runningEpoch = false;
2740
+ return;
2741
+ }
2397
2742
  if (!validators.includes(peernet.selectedAccount)) {
2398
2743
  this.#runningEpoch = false;
2399
2744
  return;
2400
2745
  }
2746
+ // Phase 1: Deterministic proposer selection
2747
+ // proposer = validators[(nextBlockIndex + round) % validators.length]
2748
+ const localBlock = await this.lastBlock;
2749
+ const nextIndex = (localBlock?.index !== undefined ? Number(localBlock.index) : -1) + 1;
2750
+ const proposerIdx = (nextIndex + this.#consensusRound) % validators.length;
2751
+ const isProposer = validators[proposerIdx] === peernet.selectedAccount;
2752
+ if (!isProposer) {
2753
+ // Non-proposer: start round-advance timer in case proposer is unresponsive
2754
+ if (!this.#roundTimer) {
2755
+ this.#roundTimer = setTimeout(async () => {
2756
+ this.#roundTimer = null;
2757
+ this.#consensusRound++;
2758
+ debug(`[consensus] Round timed out, advancing to round ${this.#consensusRound}`);
2759
+ this.#runningEpoch = false;
2760
+ if (this.#participating)
2761
+ await this.#runEpoch();
2762
+ }, this.#slotTime);
2763
+ }
2764
+ this.#runningEpoch = false;
2765
+ return;
2766
+ }
2767
+ // We are the proposer — clear any stale round-advance timer
2768
+ if (this.#roundTimer) {
2769
+ clearTimeout(this.#roundTimer);
2770
+ this.#roundTimer = null;
2771
+ }
2401
2772
  const start = Date.now();
2402
2773
  try {
2403
2774
  await this.#createBlock();
@@ -2482,6 +2853,10 @@ class Chain extends VersionControl {
2482
2853
  globalThis.peernet.subscribe('send-transaction', this.#sendTransaction.bind(this));
2483
2854
  globalThis.peernet.subscribe('add-transaction', this.#addTransaction.bind(this));
2484
2855
  globalThis.peernet.subscribe('validator:timeout', this.#validatorTimeout.bind(this));
2856
+ // Tendermint consensus topics
2857
+ globalThis.peernet.subscribe('consensus:propose', this.#handleProposal.bind(this));
2858
+ globalThis.peernet.subscribe('consensus:prevote', this.#handlePrevote.bind(this));
2859
+ globalThis.peernet.subscribe('consensus:precommit', this.#handlePrecommit.bind(this));
2485
2860
  globalThis.pubsub.subscribe('peer:connected', this.#peerConnected.bind(this));
2486
2861
  globalThis.pubsub.publish('chain:ready', true);
2487
2862
  console.log('[chain] init:done');
@@ -2497,11 +2872,44 @@ class Chain extends VersionControl {
2497
2872
  await globalThis.transactionPoolStore.delete(hash);
2498
2873
  }
2499
2874
  async #validatorTimeout(validatorInfo) {
2500
- setTimeout(() => {
2501
- this.#jail.splice(this.#jail.indexOf(validatorInfo.address), 1);
2502
- }, validatorInfo.timeout);
2503
- this.#jail.push(validatorInfo.address);
2504
- }
2875
+ const address = validatorInfo?.address;
2876
+ if (!address)
2877
+ return;
2878
+ const timeout = Math.min(Math.max(Number(validatorInfo.timeout) || 0, 0), 60 * 60 * 1000);
2879
+ const existingRelease = this.#jailReleaseTimers.get(address);
2880
+ if (existingRelease)
2881
+ clearTimeout(existingRelease);
2882
+ this.#jail.add(address);
2883
+ const releaseTimer = setTimeout(() => {
2884
+ this.#jail.delete(address);
2885
+ this.#jailReleaseTimers.delete(address);
2886
+ }, timeout);
2887
+ this.#jailReleaseTimers.set(address, releaseTimer);
2888
+ }
2889
+ // ── Tendermint consensus handlers ────────────────────────────────────────
2890
+ /**
2891
+ * Publish a prevote or precommit. Idempotent — will not cast the same
2892
+ * vote twice for the same height:round.
2893
+ */
2894
+ #castVote;
2895
+ /**
2896
+ * Phase 2 — receive a block proposal from the designated proposer.
2897
+ * Validates the proposer is correct for height/round, fetches + validates
2898
+ * the block from peernet, then casts a prevote.
2899
+ */
2900
+ #handleProposal;
2901
+ /**
2902
+ * Phase 2 — collect prevotes. Once 2f+1 prevotes are seen for a block,
2903
+ * cast a precommit.
2904
+ */
2905
+ #handlePrevote;
2906
+ /**
2907
+ * Phase 3 — collect precommits. Once 2f+1 precommits are seen for a block,
2908
+ * commit it: non-proposers call #addBlock, then broadcast on add-block for
2909
+ * syncing nodes.
2910
+ */
2911
+ #handlePrecommit;
2912
+ // ─────────────────────────────────────────────────────────────────────────
2505
2913
  #addTransaction;
2506
2914
  async #prepareRequest(request) {
2507
2915
  let node = await new globalThis.peernet.protos['peernet-request']({ request });
@@ -2557,17 +2965,7 @@ class Chain extends VersionControl {
2557
2965
  debug(`peer connected: ${peerId}`);
2558
2966
  const peer = peernet.getConnection(peerId);
2559
2967
  debug(`peer connected with version ${peer.version}`);
2560
- // todo handle version changes
2561
- // for now just do nothing if version doesn't match
2562
- debug(`peer connected with version ${peer.version}`);
2563
- const compatibleVersion = () => {
2564
- if (!peer.version || !this.version)
2565
- return false;
2566
- const [peerMajor, peerMinor] = peer.version.split('.');
2567
- const [localMajor, localMinor] = this.version.split('.');
2568
- return peerMajor === localMajor && peerMinor === localMinor;
2569
- };
2570
- if (!compatibleVersion()) {
2968
+ if (!this.isVersionCompatible(peer.version)) {
2571
2969
  debug(`versions don't match`);
2572
2970
  return;
2573
2971
  }
@@ -2581,9 +2979,19 @@ class Chain extends VersionControl {
2581
2979
  catch (error) {
2582
2980
  const peerName = peer?.peerId || peer?.id || peer?.address || peerId || 'unknown';
2583
2981
  debug(`lastBlock request failed: ${peerName}:`, error?.message ?? error);
2982
+ await this.#recordPeerFailure(peerId, `lastBlock request failed: ${error?.message ?? error}`);
2584
2983
  return;
2585
2984
  }
2985
+ // CRITICAL: Validate the peer's claimed block height is not unreasonably ahead of our local chain
2986
+ // This prevents Byzantine nodes from claiming a fake chain length to steer our sync
2586
2987
  const localBlock = await this.lastBlock;
2988
+ const MAX_SYNC_AHEAD = 100_000;
2989
+ if (lastBlock?.index > (localBlock?.index ?? 0) + MAX_SYNC_AHEAD) {
2990
+ const peerName = peer?.peerId || peer?.id || peer?.address || peerId || 'unknown';
2991
+ debug(`Peer ${peerName} claims unreasonable block height ${lastBlock.index} (local: ${localBlock?.index ?? 0})`);
2992
+ await this.#recordPeerFailure(peerId, `unreasonable lastBlock index: ${lastBlock.index}`);
2993
+ return;
2994
+ }
2587
2995
  if (!lastBlock || !lastBlock.hash || lastBlock.hash === '0x0') {
2588
2996
  debug(`peer has no lastBlock: ${peerId}`);
2589
2997
  return;
@@ -2601,14 +3009,20 @@ class Chain extends VersionControl {
2601
3009
  console.log(e);
2602
3010
  }
2603
3011
  }
2604
- if (knownBlocksResponse.blocks)
2605
- for (const hash of knownBlocksResponse.blocks) {
2606
- this.wantList.push(hash);
3012
+ const MAX_WANTLIST_SIZE = 1000;
3013
+ if (knownBlocksResponse.blocks) {
3014
+ const remaining = MAX_WANTLIST_SIZE - this.wantList.length;
3015
+ if (remaining > 0) {
3016
+ for (const hash of knownBlocksResponse.blocks.slice(0, remaining)) {
3017
+ this.wantList.push(hash);
3018
+ }
2607
3019
  }
3020
+ }
2608
3021
  }
2609
3022
  catch (error) {
2610
3023
  const peerName = peer?.peerId || peer?.id || peer?.address || peerId || 'unknown';
2611
3024
  debug(`knownBlocks request failed: ${peerName}:`, error?.message ?? error);
3025
+ await this.#recordPeerFailure(peerId, `knownBlocks request failed: ${error?.message ?? error}`);
2612
3026
  return;
2613
3027
  }
2614
3028
  }
@@ -2651,6 +3065,7 @@ class Chain extends VersionControl {
2651
3065
  catch (error) {
2652
3066
  const peerName = peer?.peerId || peer?.id || peer?.address || peerId || 'unknown';
2653
3067
  debug(`stateInfo/syncChain failed: ${peerName}:`, error?.message ?? error);
3068
+ await this.#recordPeerFailure(peerId, `stateInfo/syncChain failed: ${error?.message ?? error}`);
2654
3069
  return;
2655
3070
  }
2656
3071
  }
@@ -2687,26 +3102,55 @@ class Chain extends VersionControl {
2687
3102
  const receivedEncoded = block instanceof BlockMessage ? block.encoded : block;
2688
3103
  const blockMessage = await new BlockMessage(block);
2689
3104
  const hash = await blockMessage.hash();
2690
- // Verify data integrity: re-encode should produce the same bytes
2691
- const canonicalEncoded = blockMessage.encoded;
2692
- if (receivedEncoded.length === canonicalEncoded.length) {
2693
- let mismatch = false;
2694
- for (let i = 0; i < receivedEncoded.length; i++) {
2695
- if (receivedEncoded[i] !== canonicalEncoded[i]) {
2696
- mismatch = true;
2697
- break;
2698
- }
3105
+ // CRITICAL: VALIDATE BEFORE TOUCHING STATE
3106
+ // 1. Check for duplicate blocks at same height
3107
+ const blockIndex = Number(blockMessage.decoded.index);
3108
+ const existingBlockAtHeight = this.#blocks[blockIndex];
3109
+ if (existingBlockAtHeight) {
3110
+ if (existingBlockAtHeight.hash !== hash) {
3111
+ console.error(`[CONSENSUS ALERT] Conflicting blocks at height ${blockIndex}:`);
3112
+ console.error(` Local: ${existingBlockAtHeight.hash}`);
3113
+ console.error(` Remote: ${hash}`);
3114
+ throw new Error(`Block conflict detected at index ${blockIndex}`);
3115
+ }
3116
+ // Already have this exact block, skip
3117
+ debug(`Block already in store: ${hash}`);
3118
+ return;
3119
+ }
3120
+ // 2. Verify previous hash chain integrity
3121
+ if (blockIndex > 0) {
3122
+ const previousBlockInfo = this.#blocks[blockIndex - 1];
3123
+ if (!previousBlockInfo) {
3124
+ throw new Error(`Missing parent block at index ${blockIndex - 1}`);
2699
3125
  }
2700
- if (mismatch) {
2701
- console.warn(`[chain] ⚠️ Block data corrupted in transit: encoded bytes don't match canonical form for block #${blockMessage.decoded.index}`);
3126
+ if (previousBlockInfo.hash !== blockMessage.decoded.previousHash) {
3127
+ throw new Error(`previousHash mismatch at index ${blockIndex}: ` +
3128
+ `expected ${previousBlockInfo.hash}, got ${blockMessage.decoded.previousHash}`);
2702
3129
  }
2703
- else {
2704
- console.log(`[chain] Block data integrity verified via codec: ${hash}`);
3130
+ }
3131
+ else if (blockMessage.decoded.previousHash !== '0x0') {
3132
+ throw new Error(`Genesis block (index 0) must have previousHash='0x0'`);
3133
+ }
3134
+ // 3. Verify data integrity
3135
+ const canonicalEncoded = blockMessage.encoded;
3136
+ const byteLengthMatch = receivedEncoded.length === canonicalEncoded.length;
3137
+ if (!byteLengthMatch) {
3138
+ throw new Error(`[FATAL] Block data size mismatch: received ${receivedEncoded.length} bytes ` +
3139
+ `but canonical encoding is ${canonicalEncoded.length} bytes for block #${blockMessage.decoded.index}`);
3140
+ }
3141
+ let mismatch = false;
3142
+ for (let i = 0; i < receivedEncoded.length; i++) {
3143
+ if (receivedEncoded[i] !== canonicalEncoded[i]) {
3144
+ mismatch = true;
3145
+ break;
2705
3146
  }
2706
3147
  }
2707
- else {
2708
- console.warn(`[chain] ⚠️ Block data size mismatch: received ${receivedEncoded.length} bytes but canonical is ${canonicalEncoded.length} bytes for block #${blockMessage.decoded.index}`);
3148
+ if (mismatch) {
3149
+ throw new Error(`[FATAL] Block data corrupted in transit for block #${blockIndex} hash ${hash}`);
2709
3150
  }
3151
+ console.log(`[chain] ✅ Block data integrity verified: ${hash}`);
3152
+ this.#validateBlockValidators(blockMessage);
3153
+ // NOW SAFE TO PROCEED with transaction processing
2710
3154
  const transactions = await Promise.all(blockMessage.decoded.transactions
2711
3155
  // @ts-ignore
2712
3156
  .map(async (hash) => {
@@ -2715,24 +3159,34 @@ class Chain extends VersionControl {
2715
3159
  return new TransactionMessage(data);
2716
3160
  }));
2717
3161
  await globalThis.blockStore.put(hash, blockMessage.encoded);
3162
+ // Cache block for conflict detection
3163
+ this.#blocks[blockIndex] = {
3164
+ hash,
3165
+ ...blockMessage.decoded
3166
+ };
2718
3167
  debug(`added block: ${hash}`);
2719
3168
  let promises = [];
2720
3169
  let contracts = [];
2721
- const normalTransactions = [];
2722
- const priorityransactions = [];
2723
- for (const transaction of transactions) {
3170
+ // Combine and sort all transactions deterministically
3171
+ const allTransactions = transactions.sort((a, b) => {
3172
+ // Primary: by priority (true first)
3173
+ if (a.decoded.priority !== b.decoded.priority) {
3174
+ return (b.decoded.priority ? 1 : 0) - (a.decoded.priority ? 1 : 0);
3175
+ }
3176
+ // Secondary: by nonce
3177
+ const nonceDiff = (a.decoded?.nonce ?? 0) - (b.decoded?.nonce ?? 0);
3178
+ if (nonceDiff !== 0)
3179
+ return nonceDiff;
3180
+ // Tertiary: in stable order (insertion order preserved)
3181
+ return 0;
3182
+ });
3183
+ // Execute sequentially (NOT concurrently) to ensure deterministic state
3184
+ for (const transaction of allTransactions) {
2724
3185
  if (!contracts.includes(transaction.decoded.to)) {
2725
3186
  contracts.push(transaction.decoded.to);
2726
3187
  }
2727
- if (transaction.decoded.priority)
2728
- priorityransactions.push(transaction);
2729
- else
2730
- normalTransactions.push(transaction);
2731
- }
2732
- for (const transaction of priorityransactions.sort((a, b) => a.decoded.nonce - b.decoded.nonce)) {
2733
3188
  await this.#handleTransaction(transaction, []);
2734
3189
  }
2735
- await Promise.all(normalTransactions.map((transaction) => this.#handleTransaction(transaction, [])));
2736
3190
  // for (let transaction of transactionsMessages) {
2737
3191
  // // await transactionStore.put(transaction.hash, transaction.encoded)
2738
3192
  // if (!contracts.includes(transaction.to)) {
@@ -2757,6 +3211,15 @@ class Chain extends VersionControl {
2757
3211
  await Promise.all(Object.entries(noncesByAddress).map(([from, nonce]) => globalThis.accountsStore.put(from, String(nonce))));
2758
3212
  if ((await this.lastBlock).index < Number(blockMessage.decoded.index)) {
2759
3213
  await this.machine.addLoadedBlock({ ...blockMessage.decoded, loaded: true, hash: await blockMessage.hash() });
3214
+ // Record validator snapshot at this block height for future consensus queries
3215
+ try {
3216
+ await this.call(addresses.validators, 'recordValidatorSnapshot', [blockMessage.decoded.index]);
3217
+ }
3218
+ catch (snapshotError) {
3219
+ debug(`failed to record validator snapshot: ${snapshotError?.message ?? snapshotError}`);
3220
+ }
3221
+ // Check if this block crosses epoch boundary and handle transition
3222
+ await this.#handleEpochBoundary(Number(blockMessage.decoded.index));
2760
3223
  await this.updateState(blockMessage);
2761
3224
  }
2762
3225
  globalThis.pubsub.publish('block-processed', blockMessage.decoded);
@@ -2848,23 +3311,31 @@ class Chain extends VersionControl {
2848
3311
  timestamp,
2849
3312
  previousHash: '',
2850
3313
  reward: BigInt(150),
2851
- index: 0
3314
+ index: 0,
3315
+ producer: '',
3316
+ producerProof: '',
3317
+ protocolVersion: this.version
2852
3318
  };
2853
3319
  const latestTransactions = await this.machine.latestTransactions();
2854
3320
  // exclude failing tx
2855
3321
  transactions = await this.promiseTransactions(transactions);
2856
- const normalTransactions = [];
2857
- const priorityransactions = [];
2858
- for (const transaction of transactions) {
2859
- if (transaction.decoded.priority)
2860
- priorityransactions.push(transaction);
2861
- else
2862
- normalTransactions.push(transaction);
2863
- }
2864
- for (const transaction of priorityransactions.sort((a, b) => a.decoded.nonce - b.decoded.nonce)) {
3322
+ // Combine priority and normal transactions, then sort deterministically
3323
+ const allTransactions = transactions.sort((a, b) => {
3324
+ // Primary: by priority (true first)
3325
+ if (a.decoded.priority !== b.decoded.priority) {
3326
+ return (b.decoded.priority ? 1 : 0) - (a.decoded.priority ? 1 : 0);
3327
+ }
3328
+ // Secondary: by nonce
3329
+ const nonceDiff = (a.decoded?.nonce ?? 0) - (b.decoded?.nonce ?? 0);
3330
+ if (nonceDiff !== 0)
3331
+ return nonceDiff;
3332
+ // Tertiary: in stable order (insertion order preserved)
3333
+ return 0;
3334
+ });
3335
+ // Execute sequentially (NOT concurrently) to ensure deterministic state
3336
+ for (const transaction of allTransactions) {
2865
3337
  await this.#handleTransaction(transaction, latestTransactions, block);
2866
3338
  }
2867
- await Promise.all(normalTransactions.map((transaction) => this.#handleTransaction(transaction, latestTransactions, block)));
2868
3339
  // don't add empty block
2869
3340
  if (block.transactions.length === 0)
2870
3341
  return;
@@ -2882,7 +3353,7 @@ class Chain extends VersionControl {
2882
3353
  }
2883
3354
  for (const validator of validators) {
2884
3355
  const peer = peers[validator];
2885
- if (peer && peer.connected && peer.version === this.version) {
3356
+ if (peer && peer.connected && this.isVersionCompatible(peer.version)) {
2886
3357
  let data = await new BWRequestMessage();
2887
3358
  const node = await globalThis.peernet.prepareMessage(data.encoded);
2888
3359
  try {
@@ -2922,26 +3393,54 @@ class Chain extends VersionControl {
2922
3393
  // block.timestamp = Date.now()
2923
3394
  // block.reward = block.reward.toString()
2924
3395
  // block.fees = block.fees.toString()
3396
+ // CRITICAL FIX: Sort validators deterministically to avoid encoding divergence
3397
+ // Use canonical validator set from contract, sorted by address
3398
+ const canonicalValidators = (await this.staticCall(addresses.validators, 'validators'));
3399
+ const sortedValidators = [...canonicalValidators].sort();
3400
+ // Apply reward to all canonical validators (deterministic)
3401
+ block.validators = sortedValidators.map((validatorAddress) => ({
3402
+ address: validatorAddress,
3403
+ reward: block.fees / BigInt(sortedValidators.length) + block.reward / BigInt(sortedValidators.length)
3404
+ }));
2925
3405
  try {
2926
3406
  await Promise.all(block.transactions.map(async (transaction) => {
2927
3407
  await globalThis.transactionStore.put(transaction, await transactionPoolStore.get(transaction));
2928
3408
  await globalThis.transactionPoolStore.delete(transaction);
2929
3409
  }));
3410
+ // Set producer to current account
3411
+ block.producer = globalThis.peernet.selectedAccount || '';
3412
+ // Sign block hash to authenticate producer (producer must be the proposer)
3413
+ if (block.producer && this.keypair) {
3414
+ const blockHashInput = JSON.stringify({
3415
+ index: block.index,
3416
+ previousHash: block.previousHash,
3417
+ timestamp: block.timestamp,
3418
+ validators: block.validators.map((v) => v.address).sort()
3419
+ });
3420
+ block.producerProof = await signTransaction(blockHashInput, this.keypair);
3421
+ }
2930
3422
  let blockMessage = await new BlockMessage(block);
2931
3423
  const hash = await blockMessage.hash();
2932
3424
  await globalThis.peernet.put(hash, blockMessage.encoded, 'block');
2933
3425
  await this.machine.addLoadedBlock({ ...blockMessage.decoded, loaded: true, hash: await blockMessage.hash() });
2934
3426
  await this.updateState(blockMessage);
2935
3427
  debug(`created block: ${hash} @${block.index}`);
2936
- // Publish canonical encoded form via codec interface
2937
- console.log(`[chain] 📤 Publishing block #${block.index} | hash: ${hash} | encoded bytes: ${blockMessage.encoded.length}`);
3428
+ // Phase 2: announce proposal for consensus voting instead of direct add-block
3429
+ console.log(`[consensus] 📤 Proposing block #${block.index} | hash: ${hash} | round: ${this.#consensusRound}`);
3430
+ const proposalPayload = new TextEncoder().encode(JSON.stringify({
3431
+ blockHash: hash,
3432
+ index: block.index,
3433
+ round: this.#consensusRound,
3434
+ from: peernet.selectedAccount
3435
+ }));
2938
3436
  try {
2939
- globalThis.peernet.publish('add-block', blockMessage.encoded);
3437
+ globalThis.peernet.publish('consensus:propose', proposalPayload);
2940
3438
  }
2941
3439
  catch (publishError) {
2942
- debug('peernet publish failed: add-block', publishError?.message ?? publishError);
3440
+ debug('peernet publish failed: consensus:propose', publishError?.message ?? publishError);
2943
3441
  }
2944
- globalThis.pubsub.publish('add-block', blockMessage.encoded);
3442
+ // Proposer casts their own prevote immediately
3443
+ await this.#castVote('prevote', hash, block.index, this.#consensusRound);
2945
3444
  }
2946
3445
  catch (error) {
2947
3446
  console.log(error);