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