@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/browser/chain.js +780 -225
- 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 +662 -146
- package/exports/constants.d.ts +1 -0
- package/exports/types.d.ts +5 -14
- 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 +6 -6
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
|
];
|
|
@@ -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
|
-
|
|
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
|
-
|
|
1444
|
-
|
|
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.#
|
|
1479
|
-
return
|
|
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
|
-
|
|
1485
|
-
|
|
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.#
|
|
1501
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
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
|
-
//
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
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 (
|
|
2684
|
-
|
|
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
|
-
|
|
2687
|
-
|
|
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
|
-
|
|
2691
|
-
|
|
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
|
-
|
|
2705
|
-
const
|
|
2706
|
-
|
|
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
|
-
|
|
2840
|
-
const
|
|
2841
|
-
|
|
2842
|
-
if (
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
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
|
|
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
|
-
//
|
|
2920
|
-
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
|
+
}));
|
|
2921
3436
|
try {
|
|
2922
|
-
globalThis.peernet.publish('
|
|
3437
|
+
globalThis.peernet.publish('consensus:propose', proposalPayload);
|
|
2923
3438
|
}
|
|
2924
3439
|
catch (publishError) {
|
|
2925
|
-
debug('peernet publish failed:
|
|
3440
|
+
debug('peernet publish failed: consensus:propose', publishError?.message ?? publishError);
|
|
2926
3441
|
}
|
|
2927
|
-
|
|
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);
|