@leofcoin/chain 1.8.30 → 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/browser/chain.js +726 -188
- package/exports/browser/{constants-BKKQytjd.js → constants-BTdMMS4w.js} +172 -132
- package/exports/browser/node-browser.js +1 -1
- package/exports/browser/workers/block-worker.js +1 -1
- package/exports/browser/workers/machine-worker.js +2 -2
- package/exports/browser/workers/{worker-iOnLaHA--iOnLaHA-.js → worker-BrtyXRJ7-BrtyXRJ7.js} +287 -208
- package/exports/chain.js +608 -109
- package/exports/constants.d.ts +1 -0
- package/exports/version-control.d.ts +1 -0
- package/exports/workers/block-worker.js +1 -1
- package/exports/workers/machine-worker.js +2 -2
- package/exports/workers/{worker-iOnLaHA--iOnLaHA-.js → worker-BrtyXRJ7-BrtyXRJ7.js} +287 -208
- package/package.json +5 -5
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',
|
|
776
|
-
stateStore.put('states',
|
|
777
|
-
stateStore.put('accounts',
|
|
778
|
-
stateStore.put('info',
|
|
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
|
-
|
|
791
|
+
totalBlocks: await blockStore.length,
|
|
784
792
|
totalBurnAmount: await this.totalBurnAmount,
|
|
785
793
|
totalMintAmount: await this.totalMintAmount,
|
|
786
|
-
|
|
787
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
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
|
-
//
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
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 (
|
|
2701
|
-
|
|
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
|
-
|
|
2704
|
-
|
|
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
|
-
|
|
2708
|
-
|
|
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
|
-
|
|
2722
|
-
const
|
|
2723
|
-
|
|
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
|
-
|
|
2857
|
-
const
|
|
2858
|
-
|
|
2859
|
-
if (
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
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
|
|
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
|
-
//
|
|
2937
|
-
console.log(`[
|
|
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('
|
|
3437
|
+
globalThis.peernet.publish('consensus:propose', proposalPayload);
|
|
2940
3438
|
}
|
|
2941
3439
|
catch (publishError) {
|
|
2942
|
-
debug('peernet publish failed:
|
|
3440
|
+
debug('peernet publish failed: consensus:propose', publishError?.message ?? publishError);
|
|
2943
3441
|
}
|
|
2944
|
-
|
|
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);
|