@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.
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
  ];
@@ -1157,6 +1165,7 @@ class State extends Contract {
1157
1165
  #totalSize;
1158
1166
  #machine;
1159
1167
  #loaded;
1168
+ #resolvingHashes;
1160
1169
  /**
1161
1170
  * contains transactions we need before we can successfully load
1162
1171
  */
@@ -1245,6 +1254,7 @@ class State extends Contract {
1245
1254
  this.knownBlocks = [];
1246
1255
  this.#totalSize = 0;
1247
1256
  this.#loaded = false;
1257
+ this.#resolvingHashes = new Set();
1248
1258
  this._wantList = [];
1249
1259
  this.#chainStateHandler = () => {
1250
1260
  return new globalThis.peernet.protos['peernet-response']({
@@ -1405,6 +1415,23 @@ class State extends Contract {
1405
1415
  }
1406
1416
  return block;
1407
1417
  }
1418
+ async #resolveTransactions(transactions) {
1419
+ await Promise.all(transactions
1420
+ .filter((hash) => Boolean(hash))
1421
+ .map(async (hash) => {
1422
+ // should be in a transaction store already
1423
+ const exists = await transactionStore.has(hash);
1424
+ if (!exists) {
1425
+ const data = await peernet.get(hash, 'transaction');
1426
+ if (!data)
1427
+ throw new Error(`missing transaction data for ${hash}`);
1428
+ await transactionStore.put(hash, data);
1429
+ }
1430
+ const inPool = await transactionPoolStore.has(hash);
1431
+ if (inPool)
1432
+ await transactionPoolStore.delete(hash);
1433
+ }));
1434
+ }
1408
1435
  async #resolveBlock(hash) {
1409
1436
  let index = this.#blockHashMap.get(hash);
1410
1437
  let localHash = '0x0';
@@ -1430,31 +1457,12 @@ class State extends Contract {
1430
1457
  }
1431
1458
  try {
1432
1459
  const block = await this.getAndPutBlock(hash);
1460
+ const promises = [];
1433
1461
  if (block.decoded.previousHash !== '0x0' && block.decoded.previousHash !== localHash) {
1434
- setImmediate(async () => {
1435
- try {
1436
- await this.resolveBlock(block.decoded.previousHash);
1437
- }
1438
- catch (e) {
1439
- console.error(e);
1440
- }
1441
- });
1462
+ promises.push(this.resolveBlock(block.decoded.previousHash));
1442
1463
  }
1443
- await Promise.all(block.decoded.transactions
1444
- .filter((hash) => Boolean(hash))
1445
- .map(async (hash) => {
1446
- // should be in a transaction store already
1447
- const exists = await transactionStore.has(hash);
1448
- if (!exists) {
1449
- const data = await peernet.get(hash, 'transaction');
1450
- if (!data)
1451
- throw new Error(`missing transaction data for ${hash}`);
1452
- await transactionStore.put(hash, data);
1453
- }
1454
- const inPool = await transactionPoolStore.has(hash);
1455
- if (inPool)
1456
- await transactionPoolStore.delete(hash);
1457
- }));
1464
+ promises.push(this.#resolveTransactions(block.decoded.transactions));
1465
+ await Promise.all(promises);
1458
1466
  index = block.decoded.index;
1459
1467
  const size = block.encoded.length > 0 ? block.encoded.length : block.encoded.byteLength;
1460
1468
  this.#totalSize += size;
@@ -1475,14 +1483,20 @@ class State extends Contract {
1475
1483
  throw new Error(`expected hash, got: ${hash}`);
1476
1484
  if (hash === '0x0')
1477
1485
  return;
1478
- if (this.#resolving)
1479
- return 'already resolving';
1486
+ if (this.#resolvingHashes.has(hash))
1487
+ return;
1488
+ this.#resolvingHashes.add(hash);
1489
+ const isEntering = this.#resolvingHashes.size === 1;
1480
1490
  this.#resolving = true;
1481
- if (this.jobber.busy && this.jobber.destroy)
1482
- await this.jobber.destroy();
1483
1491
  try {
1484
- await this.jobber.add(() => this.#resolveBlock(hash));
1485
- this.#resolving = false;
1492
+ if (isEntering) {
1493
+ if (this.jobber.busy && this.jobber.destroy)
1494
+ await this.jobber.destroy();
1495
+ await this.jobber.add(() => this.#resolveBlock(hash));
1496
+ }
1497
+ else {
1498
+ await this.#resolveBlock(hash);
1499
+ }
1486
1500
  try {
1487
1501
  const lastBlockHash = await globalThis.stateStore.get('lastBlock');
1488
1502
  if (lastBlockHash === hash) {
@@ -1491,19 +1505,23 @@ class State extends Contract {
1491
1505
  }
1492
1506
  }
1493
1507
  catch (error) { }
1494
- if (!this.#blockHashMap.has(this.#lastResolved.previousHash) && this.#lastResolved.previousHash !== '0x0')
1495
- return this.resolveBlock(this.#lastResolved.previousHash);
1496
1508
  }
1497
1509
  catch (error) {
1498
1510
  console.log({ error });
1499
1511
  this.#resolveErrorCount += 1;
1500
- this.#resolving = false;
1501
- if (this.#resolveErrorCount < 3)
1512
+ if (this.#resolveErrorCount < 3) {
1513
+ this.#resolvingHashes.delete(hash);
1502
1514
  return this.resolveBlock(hash);
1515
+ }
1503
1516
  this.#resolveErrorCount = 0;
1504
1517
  this.wantList.push(hash);
1505
1518
  throw new ResolveError(`block: ${hash}`, { cause: error });
1506
1519
  }
1520
+ finally {
1521
+ this.#resolvingHashes.delete(hash);
1522
+ if (this.#resolvingHashes.size === 0)
1523
+ this.#resolving = false;
1524
+ }
1507
1525
  }
1508
1526
  async resolveBlocks() {
1509
1527
  // Don't re-resolve if already syncing or resolving
@@ -1625,14 +1643,29 @@ class State extends Contract {
1625
1643
  debug$1(`Remote block hash is 0x0, skipping sync`);
1626
1644
  return;
1627
1645
  }
1646
+ // Skip if local machine is already ahead of remote
1647
+ if (localIndex > remoteIndex) {
1648
+ debug$1(`Local index ${localIndex} is ahead of remote ${remoteIndex}, skipping sync`);
1649
+ return;
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
+ }
1628
1659
  // Use state hash comparison: only resolve if remote hash differs from local state hash
1629
1660
  if (localStateHash !== remoteBlockHash) {
1630
1661
  if (this.wantList.length > 0) {
1631
1662
  debug$1(`Fetching ${this.wantList.length} blocks before resolving`);
1632
1663
  const getBatch = async (batch) => {
1633
- return Promise.all(batch.map((hash) => this.getAndPutBlock(hash).catch((e) => {
1664
+ const blocks = await Promise.all(batch.map((hash) => this.getAndPutBlock(hash).catch((e) => {
1634
1665
  console.warn(`failed to fetch block ${hash}`, e);
1635
1666
  })));
1667
+ const transactions = blocks.filter((block) => Boolean(block)).flatMap((block) => block.decoded.transactions);
1668
+ return this.#resolveTransactions(transactions);
1636
1669
  };
1637
1670
  // Process in batches of 50 to avoid overwhelming network/memory
1638
1671
  for (let i = 0; i < this.wantList.length; i += 50) {
@@ -1683,11 +1716,19 @@ class State extends Contract {
1683
1716
  for (const id in globalThis.peernet.connections) {
1684
1717
  // @ts-ignore
1685
1718
  const peer = globalThis.peernet.connections[id];
1686
- 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()) {
1687
1728
  const task = async () => {
1688
1729
  try {
1689
1730
  const result = await peer.request(node.encoded);
1690
- debug$1({ result });
1731
+ debug$1(`lastBlock result: ${JSON.stringify(result)}`);
1691
1732
  console.log({ result });
1692
1733
  return { result: new LastBlockMessage(result), peer };
1693
1734
  }
@@ -1702,7 +1743,7 @@ class State extends Contract {
1702
1743
  }
1703
1744
  // @ts-ignore
1704
1745
  console.log({ promises });
1705
- promises = await this.promiseRequests(promises);
1746
+ promises = (await this.promiseRequests(promises));
1706
1747
  console.log({ promises });
1707
1748
  let latest = { index: 0, hash: '0x0', previousHash: '0x0' };
1708
1749
  promises = promises.sort((a, b) => b.index - a.index);
@@ -1717,7 +1758,15 @@ class State extends Contract {
1717
1758
  throw new Error('invalid block @getLatestBlock');
1718
1759
  latest = { ...message.decoded, hash };
1719
1760
  const peer = promises[0].peer;
1720
- 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()) {
1721
1770
  let data = await new globalThis.peernet.protos['peernet-request']({
1722
1771
  request: 'knownBlocks'
1723
1772
  });
@@ -1725,7 +1774,11 @@ class State extends Contract {
1725
1774
  try {
1726
1775
  let message = await peer.request(node.encode());
1727
1776
  message = await new globalThis.peernet.protos['peernet-response'](message);
1728
- 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));
1729
1782
  }
1730
1783
  catch (error) {
1731
1784
  const peerId = peer?.peerId || peer?.id || peer?.address || 'unknown';
@@ -1780,7 +1833,7 @@ class State extends Contract {
1780
1833
  if (block && !block.loaded) {
1781
1834
  try {
1782
1835
  debug$1(`loading block: ${Number(block.index)} ${block.hash}`);
1783
- let transactions = await this.#loadBlockTransactions([...block.transactions] || []);
1836
+ let transactions = await this.#loadBlockTransactions(block.transactions || []);
1784
1837
  // const lastTransactions = await this.#getLastTransactions()
1785
1838
  debug$1(`loading transactions: ${transactions.length} for block ${block.index}`);
1786
1839
  let priority = [];
@@ -1956,6 +2009,13 @@ class VersionControl extends State {
1956
2009
  return this.#setCurrentVersion();
1957
2010
  }
1958
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
+ }
1959
2019
  }
1960
2020
 
1961
2021
  /**
@@ -2077,14 +2137,12 @@ class ConnectionMonitor {
2077
2137
  }
2078
2138
  const connectedPeers = this.connectedPeers;
2079
2139
  const compatiblePeers = this.compatiblePeers;
2080
- console.log(`🔍 Health check: ${connectedPeers.length} connected, ${compatiblePeers.length} compatible`);
2081
- // If a reconnection is already ongoing, skip this cycle to avoid log spam/loops
2082
- if (this.#reconnecting) {
2083
- console.log('⏭️ Health check: reconnection already in progress, skipping reconnect attempt');
2084
- return;
2085
- }
2086
- // If we have no connections or none are compatible, try to reconnect
2087
- 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) {
2088
2146
  console.warn('⚠️ No peer connections detected — attempting reconnection');
2089
2147
  await this.#attemptReconnection();
2090
2148
  }
@@ -2186,20 +2244,7 @@ class ConnectionMonitor {
2186
2244
  console.warn(' ⚠️ peernet.start() failed:', e?.message || e);
2187
2245
  }
2188
2246
  }
2189
- // Approach 3: Try client.connect if available
2190
- if (globalThis.peernet?.client &&
2191
- 'connect' in globalThis.peernet.client &&
2192
- typeof globalThis.peernet.client.connect === 'function') {
2193
- console.log(' → Trying client.connect()');
2194
- try {
2195
- await globalThis.peernet.client.connect();
2196
- console.log(' ✅ client.connect() succeeded');
2197
- }
2198
- catch (e) {
2199
- console.warn(' ⚠️ client.connect() failed:', e?.message || e);
2200
- }
2201
- }
2202
- // Approach 4: Explicitly dial star servers if available
2247
+ // Approach 3: Explicitly dial star servers if available (only if client.reinit() didn't succeed)
2203
2248
  try {
2204
2249
  const networkName = globalThis.peernet?.network;
2205
2250
  if (networkName && typeof networkName === 'string') {
@@ -2215,11 +2260,6 @@ class ConnectionMonitor {
2215
2260
  await globalThis.peernet.client.dial(star);
2216
2261
  console.log(` ✅ Connected to star server: ${star}`);
2217
2262
  }
2218
- else if (globalThis.peernet?.client && 'connect' in globalThis.peernet.client) {
2219
- // Try connect with the star URL
2220
- await globalThis.peernet.client.connect(star);
2221
- console.log(` ✅ Connected to star server: ${star}`);
2222
- }
2223
2263
  }
2224
2264
  catch (e) {
2225
2265
  console.warn(` ⚠️ Failed to dial ${star}:`, e?.message || e);
@@ -2261,14 +2301,13 @@ class ConnectionMonitor {
2261
2301
  });
2262
2302
  }
2263
2303
  async #attemptReconnection() {
2264
- if (this.#reconnecting) {
2265
- console.log('⏭️ Reconnection already in progress');
2266
- return;
2267
- }
2268
2304
  try {
2269
2305
  await this.#restoreNetwork();
2270
- // Check if reconnection was successful
2271
- 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;
2272
2311
  if (hasConnections) {
2273
2312
  console.log('✅ Reconnection successful, resetting backoff delay');
2274
2313
  this.#reconnectDelay = 5000;
@@ -2318,35 +2357,252 @@ class Chain extends VersionControl {
2318
2357
  #state;
2319
2358
  #slotTime;
2320
2359
  #blockTime; // 6 second target block time
2360
+ #epochLength; // Blocks per epoch (enables block-based epoch boundaries)
2321
2361
  /** {Address[]} */
2322
2362
  #validators;
2323
2363
  /** {Boolean} */
2324
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;
2325
2369
  #participants;
2326
2370
  #participating;
2327
2371
  #jail;
2372
+ #jailReleaseTimers;
2328
2373
  #peerConnectionRetries;
2329
2374
  #maxPeerRetries;
2330
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
+ // ────────────────────────────────────────────────────────────────────────────
2331
2394
  #connectionMonitor;
2332
2395
  constructor(config) {
2333
2396
  super(config);
2334
2397
  this.#slotTime = 10000;
2335
2398
  this.#blockTime = 6000; // 6 second target block time
2399
+ this.#epochLength = 10; // Blocks per epoch (enables block-based epoch boundaries)
2336
2400
  this.utils = {};
2337
2401
  /** {Address[]} */
2338
2402
  this.#validators = [];
2339
2403
  /** {Boolean} */
2340
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 = {};
2341
2409
  this.#participants = [];
2342
2410
  this.#participating = false;
2343
- this.#jail = [];
2411
+ this.#jail = new Set();
2412
+ this.#jailReleaseTimers = new Map();
2344
2413
  this.#peerConnectionRetries = new Map();
2345
2414
  this.#maxPeerRetries = 5;
2346
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();
2347
2433
  this.ready = new Promise((resolve) => {
2348
2434
  this.readyResolve = resolve;
2349
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
+ // ─────────────────────────────────────────────────────────────────────────
2350
2606
  this.#addTransaction = async (message) => {
2351
2607
  const transaction = new TransactionMessage(message);
2352
2608
  const hash = await transaction.hash();
@@ -2370,17 +2626,149 @@ class Chain extends VersionControl {
2370
2626
  #sleep(ms) {
2371
2627
  return new Promise((resolve) => setTimeout(resolve, ms));
2372
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
+ }
2373
2731
  async #runEpoch() {
2374
2732
  if (this.#runningEpoch)
2375
2733
  return;
2376
2734
  this.#runningEpoch = true;
2377
2735
  console.log('epoch');
2378
- const validators = await this.staticCall(addresses.validators, 'validators');
2736
+ const validators = await this.#getConsensusValidators();
2379
2737
  console.log({ validators });
2738
+ if (this.#isJailed(peernet.selectedAccount)) {
2739
+ this.#runningEpoch = false;
2740
+ return;
2741
+ }
2380
2742
  if (!validators.includes(peernet.selectedAccount)) {
2381
2743
  this.#runningEpoch = false;
2382
2744
  return;
2383
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
+ }
2384
2772
  const start = Date.now();
2385
2773
  try {
2386
2774
  await this.#createBlock();
@@ -2465,6 +2853,10 @@ class Chain extends VersionControl {
2465
2853
  globalThis.peernet.subscribe('send-transaction', this.#sendTransaction.bind(this));
2466
2854
  globalThis.peernet.subscribe('add-transaction', this.#addTransaction.bind(this));
2467
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));
2468
2860
  globalThis.pubsub.subscribe('peer:connected', this.#peerConnected.bind(this));
2469
2861
  globalThis.pubsub.publish('chain:ready', true);
2470
2862
  console.log('[chain] init:done');
@@ -2480,11 +2872,44 @@ class Chain extends VersionControl {
2480
2872
  await globalThis.transactionPoolStore.delete(hash);
2481
2873
  }
2482
2874
  async #validatorTimeout(validatorInfo) {
2483
- setTimeout(() => {
2484
- this.#jail.splice(this.#jail.indexOf(validatorInfo.address), 1);
2485
- }, validatorInfo.timeout);
2486
- this.#jail.push(validatorInfo.address);
2487
- }
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
+ // ─────────────────────────────────────────────────────────────────────────
2488
2913
  #addTransaction;
2489
2914
  async #prepareRequest(request) {
2490
2915
  let node = await new globalThis.peernet.protos['peernet-request']({ request });
@@ -2540,17 +2965,7 @@ class Chain extends VersionControl {
2540
2965
  debug(`peer connected: ${peerId}`);
2541
2966
  const peer = peernet.getConnection(peerId);
2542
2967
  debug(`peer connected with version ${peer.version}`);
2543
- // todo handle version changes
2544
- // for now just do nothing if version doesn't match
2545
- debug(`peer connected with version ${peer.version}`);
2546
- const compatibleVersion = () => {
2547
- if (!peer.version || !this.version)
2548
- return false;
2549
- const [peerMajor, peerMinor] = peer.version.split('.');
2550
- const [localMajor, localMinor] = this.version.split('.');
2551
- return peerMajor === localMajor && peerMinor === localMinor;
2552
- };
2553
- if (!compatibleVersion()) {
2968
+ if (!this.isVersionCompatible(peer.version)) {
2554
2969
  debug(`versions don't match`);
2555
2970
  return;
2556
2971
  }
@@ -2564,9 +2979,19 @@ class Chain extends VersionControl {
2564
2979
  catch (error) {
2565
2980
  const peerName = peer?.peerId || peer?.id || peer?.address || peerId || 'unknown';
2566
2981
  debug(`lastBlock request failed: ${peerName}:`, error?.message ?? error);
2982
+ await this.#recordPeerFailure(peerId, `lastBlock request failed: ${error?.message ?? error}`);
2567
2983
  return;
2568
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
2569
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
+ }
2570
2995
  if (!lastBlock || !lastBlock.hash || lastBlock.hash === '0x0') {
2571
2996
  debug(`peer has no lastBlock: ${peerId}`);
2572
2997
  return;
@@ -2584,14 +3009,20 @@ class Chain extends VersionControl {
2584
3009
  console.log(e);
2585
3010
  }
2586
3011
  }
2587
- if (knownBlocksResponse.blocks)
2588
- for (const hash of knownBlocksResponse.blocks) {
2589
- 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
+ }
2590
3019
  }
3020
+ }
2591
3021
  }
2592
3022
  catch (error) {
2593
3023
  const peerName = peer?.peerId || peer?.id || peer?.address || peerId || 'unknown';
2594
3024
  debug(`knownBlocks request failed: ${peerName}:`, error?.message ?? error);
3025
+ await this.#recordPeerFailure(peerId, `knownBlocks request failed: ${error?.message ?? error}`);
2595
3026
  return;
2596
3027
  }
2597
3028
  }
@@ -2634,6 +3065,7 @@ class Chain extends VersionControl {
2634
3065
  catch (error) {
2635
3066
  const peerName = peer?.peerId || peer?.id || peer?.address || peerId || 'unknown';
2636
3067
  debug(`stateInfo/syncChain failed: ${peerName}:`, error?.message ?? error);
3068
+ await this.#recordPeerFailure(peerId, `stateInfo/syncChain failed: ${error?.message ?? error}`);
2637
3069
  return;
2638
3070
  }
2639
3071
  }
@@ -2670,26 +3102,55 @@ class Chain extends VersionControl {
2670
3102
  const receivedEncoded = block instanceof BlockMessage ? block.encoded : block;
2671
3103
  const blockMessage = await new BlockMessage(block);
2672
3104
  const hash = await blockMessage.hash();
2673
- // Verify data integrity: re-encode should produce the same bytes
2674
- const canonicalEncoded = blockMessage.encoded;
2675
- if (receivedEncoded.length === canonicalEncoded.length) {
2676
- let mismatch = false;
2677
- for (let i = 0; i < receivedEncoded.length; i++) {
2678
- if (receivedEncoded[i] !== canonicalEncoded[i]) {
2679
- mismatch = true;
2680
- break;
2681
- }
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}`);
2682
3125
  }
2683
- if (mismatch) {
2684
- 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}`);
2685
3129
  }
2686
- else {
2687
- 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;
2688
3146
  }
2689
3147
  }
2690
- else {
2691
- 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}`);
2692
3150
  }
3151
+ console.log(`[chain] ✅ Block data integrity verified: ${hash}`);
3152
+ this.#validateBlockValidators(blockMessage);
3153
+ // NOW SAFE TO PROCEED with transaction processing
2693
3154
  const transactions = await Promise.all(blockMessage.decoded.transactions
2694
3155
  // @ts-ignore
2695
3156
  .map(async (hash) => {
@@ -2698,24 +3159,34 @@ class Chain extends VersionControl {
2698
3159
  return new TransactionMessage(data);
2699
3160
  }));
2700
3161
  await globalThis.blockStore.put(hash, blockMessage.encoded);
3162
+ // Cache block for conflict detection
3163
+ this.#blocks[blockIndex] = {
3164
+ hash,
3165
+ ...blockMessage.decoded
3166
+ };
2701
3167
  debug(`added block: ${hash}`);
2702
3168
  let promises = [];
2703
3169
  let contracts = [];
2704
- const normalTransactions = [];
2705
- const priorityransactions = [];
2706
- 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) {
2707
3185
  if (!contracts.includes(transaction.decoded.to)) {
2708
3186
  contracts.push(transaction.decoded.to);
2709
3187
  }
2710
- if (transaction.decoded.priority)
2711
- priorityransactions.push(transaction);
2712
- else
2713
- normalTransactions.push(transaction);
2714
- }
2715
- for (const transaction of priorityransactions.sort((a, b) => a.decoded.nonce - b.decoded.nonce)) {
2716
3188
  await this.#handleTransaction(transaction, []);
2717
3189
  }
2718
- await Promise.all(normalTransactions.map((transaction) => this.#handleTransaction(transaction, [])));
2719
3190
  // for (let transaction of transactionsMessages) {
2720
3191
  // // await transactionStore.put(transaction.hash, transaction.encoded)
2721
3192
  // if (!contracts.includes(transaction.to)) {
@@ -2740,6 +3211,15 @@ class Chain extends VersionControl {
2740
3211
  await Promise.all(Object.entries(noncesByAddress).map(([from, nonce]) => globalThis.accountsStore.put(from, String(nonce))));
2741
3212
  if ((await this.lastBlock).index < Number(blockMessage.decoded.index)) {
2742
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));
2743
3223
  await this.updateState(blockMessage);
2744
3224
  }
2745
3225
  globalThis.pubsub.publish('block-processed', blockMessage.decoded);
@@ -2831,23 +3311,31 @@ class Chain extends VersionControl {
2831
3311
  timestamp,
2832
3312
  previousHash: '',
2833
3313
  reward: BigInt(150),
2834
- index: 0
3314
+ index: 0,
3315
+ producer: '',
3316
+ producerProof: '',
3317
+ protocolVersion: this.version
2835
3318
  };
2836
3319
  const latestTransactions = await this.machine.latestTransactions();
2837
3320
  // exclude failing tx
2838
3321
  transactions = await this.promiseTransactions(transactions);
2839
- const normalTransactions = [];
2840
- const priorityransactions = [];
2841
- for (const transaction of transactions) {
2842
- if (transaction.decoded.priority)
2843
- priorityransactions.push(transaction);
2844
- else
2845
- normalTransactions.push(transaction);
2846
- }
2847
- 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) {
2848
3337
  await this.#handleTransaction(transaction, latestTransactions, block);
2849
3338
  }
2850
- await Promise.all(normalTransactions.map((transaction) => this.#handleTransaction(transaction, latestTransactions, block)));
2851
3339
  // don't add empty block
2852
3340
  if (block.transactions.length === 0)
2853
3341
  return;
@@ -2865,7 +3353,7 @@ class Chain extends VersionControl {
2865
3353
  }
2866
3354
  for (const validator of validators) {
2867
3355
  const peer = peers[validator];
2868
- if (peer && peer.connected && peer.version === this.version) {
3356
+ if (peer && peer.connected && this.isVersionCompatible(peer.version)) {
2869
3357
  let data = await new BWRequestMessage();
2870
3358
  const node = await globalThis.peernet.prepareMessage(data.encoded);
2871
3359
  try {
@@ -2905,26 +3393,54 @@ class Chain extends VersionControl {
2905
3393
  // block.timestamp = Date.now()
2906
3394
  // block.reward = block.reward.toString()
2907
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
+ }));
2908
3405
  try {
2909
3406
  await Promise.all(block.transactions.map(async (transaction) => {
2910
3407
  await globalThis.transactionStore.put(transaction, await transactionPoolStore.get(transaction));
2911
3408
  await globalThis.transactionPoolStore.delete(transaction);
2912
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
+ }
2913
3422
  let blockMessage = await new BlockMessage(block);
2914
3423
  const hash = await blockMessage.hash();
2915
3424
  await globalThis.peernet.put(hash, blockMessage.encoded, 'block');
2916
3425
  await this.machine.addLoadedBlock({ ...blockMessage.decoded, loaded: true, hash: await blockMessage.hash() });
2917
3426
  await this.updateState(blockMessage);
2918
3427
  debug(`created block: ${hash} @${block.index}`);
2919
- // Publish canonical encoded form via codec interface
2920
- 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
+ }));
2921
3436
  try {
2922
- globalThis.peernet.publish('add-block', blockMessage.encoded);
3437
+ globalThis.peernet.publish('consensus:propose', proposalPayload);
2923
3438
  }
2924
3439
  catch (publishError) {
2925
- debug('peernet publish failed: add-block', publishError?.message ?? publishError);
3440
+ debug('peernet publish failed: consensus:propose', publishError?.message ?? publishError);
2926
3441
  }
2927
- globalThis.pubsub.publish('add-block', blockMessage.encoded);
3442
+ // Proposer casts their own prevote immediately
3443
+ await this.#castVote('prevote', hash, block.index, this.#consensusRound);
2928
3444
  }
2929
3445
  catch (error) {
2930
3446
  console.log(error);