@leofcoin/chain 1.9.24 → 1.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/exports/chain.js CHANGED
@@ -1,14 +1,16 @@
1
1
  import { createDebugger } from '@vandeurenglenn/debug';
2
2
  import { Codec } from '@leofcoin/codec-format-interface';
3
3
  import { jsonStringifyBigInt, jsonParseBigInt, formatBytes, parseUnits, formatUnits } from '@leofcoin/utils';
4
- import { TransactionMessage, BlockMessage, ContractMessage, LastBlockMessage, PrevoteMessage, PrecommitMessage, ProposalMessage, BWMessage, StateMessage, BWRequestMessage } from '@leofcoin/messages';
4
+ import { TransactionMessage, BlockMessage, ContractMessage, LastBlockMessage, PrevoteMessage, PrecommitMessage, ProposalMessage, BWMessage, StateMessage } from '@leofcoin/messages';
5
5
  import addresses, { contractFactory } from '@leofcoin/addresses';
6
- import { calculateFee, createContractMessage, signTransaction, contractFactoryMessage, nativeTokenMessage, validatorsMessage, nameServiceMessage } from '@leofcoin/lib';
6
+ import { createTransactionHash, calculateFee, createContractMessage, signTransaction, contractFactoryMessage, nativeTokenMessage, validatorsMessage, nameServiceMessage } from '@leofcoin/lib';
7
+ import MultiWallet from '@leofcoin/multi-wallet';
8
+ import { fromBase58 } from '@vandeurenglenn/typed-array-utils';
7
9
  import semver from 'semver';
8
10
  import { randombytes } from '@leofcoin/crypto';
9
11
  import EasyWorker from '@vandeurenglenn/easy-worker';
10
12
  import { ContractDeploymentError, ExecutionError, isResolveError, ResolveError, isExecutionError } from '@leofcoin/errors';
11
- import { P as PROTOCOL_VERSION, R as REACHED_ONE_ZERO_ZERO } from './constants-C83ZCYKa.js';
13
+ import { P as PROTOCOL_VERSION, R as REACHED_ONE_ZERO_ZERO } from './constants-D6gWzJZg.js';
12
14
  import '@leofcoin/networks';
13
15
 
14
16
  const limit = 1800;
@@ -51,8 +53,14 @@ class Transaction extends Protocol {
51
53
  }
52
54
  }
53
55
  removePendingNonce(address, nonce) {
54
- if (this.#pendingNonces.has(address)) {
55
- this.#pendingNonces.get(address).delete(nonce);
56
+ const nonces = this.#pendingNonces.get(address);
57
+ if (!nonces) return;
58
+ nonces.delete(nonce);
59
+ if (this.#maxPendingNonce.get(address) === nonce) {
60
+ let max = -1;
61
+ for (const n of nonces) if (n > max) max = n;
62
+ if (max === -1) this.#maxPendingNonce.delete(address);
63
+ else this.#maxPendingNonce.set(address, max);
56
64
  }
57
65
  }
58
66
  getPendingNonces(address) {
@@ -121,19 +129,33 @@ class Transaction extends Protocol {
121
129
  transactions = transactions.filter((tx) => tx.decoded.from === address);
122
130
  transactions = await this.promiseTransactionsContent(transactions);
123
131
  if (this.lastBlock?.hash && transactions.length === 0 && this.lastBlock.hash !== "0x0") {
124
- let block;
132
+ let blockHash = this.lastBlock.hash;
125
133
  try {
126
- block = await globalThis.peernet.get(this.lastBlock.hash, "block");
127
- } catch (error) {
128
- block = void 0;
129
- }
130
- if (block === void 0) return [];
131
- block = await new BlockMessage(block);
132
- transactions = transactions.filter((tx) => tx.from === address);
133
- while (transactions.length === 0 && block.decoded.index !== 0 && block.decoded.previousHash !== "0x0") {
134
- block = await globalThis.blockStore.get(block.decoded.previousHash);
135
- block = await new BlockMessage(block);
136
- transactions = block.decoded.transactions.filter((tx) => tx.from === address);
134
+ while (blockHash && blockHash !== "0x0" && transactions.length === 0) {
135
+ let rawBlock;
136
+ try {
137
+ rawBlock = await globalThis.blockStore.get(blockHash);
138
+ } catch {
139
+ rawBlock = await globalThis.peernet.get(blockHash, "block");
140
+ }
141
+ if (!rawBlock) break;
142
+ const block = await new BlockMessage(rawBlock);
143
+ const blockTransactions = await Promise.all(
144
+ block.decoded.transactions.map(async (hash) => {
145
+ let rawTransaction;
146
+ try {
147
+ rawTransaction = await globalThis.transactionStore.get(hash);
148
+ } catch {
149
+ }
150
+ if (!rawTransaction) rawTransaction = await globalThis.peernet.get(hash, "transaction");
151
+ return rawTransaction ? new TransactionMessage(rawTransaction) : void 0;
152
+ })
153
+ );
154
+ transactions = blockTransactions.filter((transaction) => Boolean(transaction)).filter((transaction) => transaction.decoded.from === address).map((transaction) => transaction.decoded);
155
+ blockHash = block.decoded.previousHash;
156
+ }
157
+ } catch {
158
+ return 0;
137
159
  }
138
160
  }
139
161
  if (transactions.length === 0) return 0;
@@ -189,6 +211,22 @@ class Transaction extends Protocol {
189
211
  async createTransactionMessage(transaction, signature) {
190
212
  return new TransactionMessage({ ...transaction, signature });
191
213
  }
214
+ async validateTransactionSignature(message) {
215
+ if (!this.isTransactionMessage(message)) message = await new TransactionMessage(message);
216
+ const { from, signature } = message.decoded;
217
+ if (!from || typeof from !== "string") throw new Error("transaction sender required");
218
+ if (!signature || typeof signature !== "string") throw new Error("transaction not signed");
219
+ try {
220
+ const network = globalThis.peernet?.network || "leofcoin";
221
+ const verifier = new MultiWallet(network);
222
+ await verifier.fromAddress(from, null, network);
223
+ const valid = await verifier.verify(fromBase58(signature), await createTransactionHash(message));
224
+ if (!valid) throw new Error("signature does not match transaction sender");
225
+ } catch (error) {
226
+ if (error?.message === "signature does not match transaction sender") throw error;
227
+ throw new Error(`invalid transaction signature: ${error?.message ?? error}`);
228
+ }
229
+ }
192
230
  async createTransaction(transaction) {
193
231
  return {
194
232
  ...transaction,
@@ -198,8 +236,8 @@ class Transaction extends Protocol {
198
236
  }
199
237
  async sendTransaction(message) {
200
238
  if (!this.isTransactionMessage(message)) message = await new TransactionMessage(message);
201
- if (!message.decoded.signature) throw new Error(`transaction not signed`);
202
239
  if (message.decoded.nonce === void 0) throw new Error(`nonce required`);
240
+ await this.validateTransactionSignature(message);
203
241
  await this.validateNonce(message.decoded.from, message.decoded.nonce);
204
242
  const hash = await message.hash();
205
243
  try {
@@ -416,6 +454,7 @@ class ContractRegistry {
416
454
  */
417
455
  clear() {
418
456
  this.baseContracts.clear();
457
+ this.contractNames.clear();
419
458
  this.contractDependencies.clear();
420
459
  }
421
460
  /**
@@ -503,7 +542,7 @@ class Contract extends Transaction {
503
542
  */
504
543
  async deployDerivedContract(signer, contract, baseContractIdentifier, constructorParameters = []) {
505
544
  if (baseContractIdentifier) {
506
- const baseContract = contractRegistry.getBaseContract(baseContractIdentifier);
545
+ const baseContract = contractRegistry.getBaseContract(baseContractIdentifier) ?? contractRegistry.getBaseContractByHash(baseContractIdentifier);
507
546
  if (!baseContract) {
508
547
  throw new Error(
509
548
  `Base contract '${baseContractIdentifier}' not found. Register it first using registerBaseContract.`
@@ -639,7 +678,8 @@ class Machine {
639
678
  break;
640
679
  }
641
680
  case "addToWantList": {
642
- this.wantList.push(data.hash);
681
+ if (!this.wantList.includes(data.hash)) this.wantList.push(data.hash);
682
+ break;
643
683
  }
644
684
  case "ask": {
645
685
  if (data.question === "contract" || data.question === "transaction") {
@@ -701,9 +741,11 @@ class Machine {
701
741
  try {
702
742
  const promises = [];
703
743
  for (const address of await accountsStore.keys()) {
704
- promises.push(async () => {
705
- accounts[address] = await accountsStore.get(address);
706
- });
744
+ promises.push(
745
+ (async () => {
746
+ accounts[address] = await accountsStore.get(address);
747
+ })()
748
+ );
707
749
  }
708
750
  await Promise.all(promises);
709
751
  } catch (error) {
@@ -712,9 +754,7 @@ class Machine {
712
754
  const { from, to, amount, nonce } = transaction.decoded.data;
713
755
  if (!accounts[from]) {
714
756
  accounts[from] = nonce;
715
- promises.push(async () => {
716
- await accountsStore.put(from, nonce);
717
- });
757
+ promises.push(accountsStore.put(from, nonce));
718
758
  }
719
759
  }
720
760
  await Promise.all(promises);
@@ -1262,7 +1302,7 @@ class State extends Contract {
1262
1302
  #lastBlockHandler;
1263
1303
  #knownBlocksHandler;
1264
1304
  async init() {
1265
- console.log("State init start");
1305
+ debug$1("State init start");
1266
1306
  this.jobber = new Jobber(this.resolveTimeout);
1267
1307
  await globalThis.peernet.addRequestHandler("lastBlock", this.#lastBlockHandler);
1268
1308
  await globalThis.peernet.addRequestHandler("knownBlocks", this.#knownBlocksHandler);
@@ -1297,7 +1337,7 @@ class State extends Contract {
1297
1337
  console.log("State init middle");
1298
1338
  try {
1299
1339
  console.log("fetching known blocks from blockStore");
1300
- this.knownBlocks = await blockStore.keys();
1340
+ this.knownBlocks = await globalThis.blockStore.keys();
1301
1341
  } catch (error) {
1302
1342
  console.log("no local known blocks found");
1303
1343
  }
@@ -1322,6 +1362,7 @@ class State extends Contract {
1322
1362
  this.updateState(new BlockMessage(lastBlock));
1323
1363
  }
1324
1364
  this.#loaded = true;
1365
+ this.#chainState = "loaded";
1325
1366
  } catch (error) {
1326
1367
  console.log("e");
1327
1368
  if (isResolveError(error)) {
@@ -1469,6 +1510,13 @@ class State extends Contract {
1469
1510
  console.error(error);
1470
1511
  }
1471
1512
  try {
1513
+ if (!await globalThis.chainStore.has("lastBlock")) {
1514
+ if (this.knownBlocks.length === 0) {
1515
+ this.#syncState = "connectionless";
1516
+ return;
1517
+ }
1518
+ return this.restoreChain();
1519
+ }
1472
1520
  const localBlock = await globalThis.chainStore.get("lastBlock");
1473
1521
  const hash = new TextDecoder().decode(localBlock);
1474
1522
  if (hash && hash !== "0x0") {
@@ -1480,10 +1528,15 @@ class State extends Contract {
1480
1528
  this.#chainSyncing = false;
1481
1529
  this.#syncState = "errored";
1482
1530
  this.#resolveErrored = true;
1531
+ if (globalThis.peernet.peers.length === 0) return;
1483
1532
  return this.restoreChain();
1484
1533
  }
1485
1534
  }
1486
1535
  async restoreChain() {
1536
+ if (globalThis.peernet.peers.length === 0 && this.knownBlocks.length === 0) {
1537
+ this.#syncState = "connectionless";
1538
+ return;
1539
+ }
1487
1540
  try {
1488
1541
  const { hash } = await this.#getLatestBlock();
1489
1542
  await globalThis.chainStore.put("lastBlock", hash);
@@ -1495,6 +1548,10 @@ class State extends Contract {
1495
1548
  this.#resolveErrored = true;
1496
1549
  this.#resolveErrorCount += 1;
1497
1550
  this.#resolving = false;
1551
+ if (this.#resolveErrorCount >= 3) {
1552
+ this.#syncState = "errored";
1553
+ throw new ResolveError("unable to restore chain after 3 attempts", { cause: error });
1554
+ }
1498
1555
  return this.restoreChain();
1499
1556
  }
1500
1557
  }
@@ -1549,17 +1606,17 @@ class State extends Contract {
1549
1606
  return;
1550
1607
  }
1551
1608
  if (localIndex > remoteIndex) {
1609
+ const MAX_REORG_DEPTH = 6;
1610
+ const reorgDepth = localIndex - remoteIndex;
1611
+ if (reorgDepth > MAX_REORG_DEPTH) {
1612
+ console.warn(
1613
+ `[consensus-safety] Peer proposing reorg depth of ${reorgDepth} blocks (limit is ${MAX_REORG_DEPTH}). Rejecting to prevent DoS.`
1614
+ );
1615
+ throw new Error(`Excessive reorg depth: ${reorgDepth} blocks (max ${MAX_REORG_DEPTH})`);
1616
+ }
1552
1617
  debug$1(`Local index ${localIndex} is ahead of remote ${remoteIndex}, skipping sync`);
1553
1618
  return;
1554
1619
  }
1555
- const MAX_REORG_DEPTH = 6;
1556
- const reorgDepth = localIndex - remoteIndex;
1557
- if (reorgDepth > 0 && reorgDepth > MAX_REORG_DEPTH) {
1558
- console.warn(
1559
- `[consensus-safety] Peer proposing reorg depth of ${reorgDepth} blocks (limit is ${MAX_REORG_DEPTH}). Rejecting to prevent DoS.`
1560
- );
1561
- throw new Error(`Excessive reorg depth: ${reorgDepth} blocks (max ${MAX_REORG_DEPTH})`);
1562
- }
1563
1620
  if (localStateHash !== remoteBlockHash) {
1564
1621
  if (this.wantList.length > 0) {
1565
1622
  debug$1(`Fetching ${this.wantList.length} blocks before resolving`);
@@ -1657,7 +1714,7 @@ class State extends Contract {
1657
1714
  }))
1658
1715
  );
1659
1716
  let latest = { index: 0, hash: "0x0", previousHash: "0x0" };
1660
- promises = promises.sort((a, b) => b.index - a.index);
1717
+ promises = promises.sort((a, b) => Number(b.value.index) - Number(a.value.index));
1661
1718
  if (promises.length > 0) latest = promises[0].value;
1662
1719
  debug$1(`Latest block from peers: ${latest.hash} @${latest.index}`);
1663
1720
  if (latest.hash && latest.hash !== "0x0") {
@@ -1673,7 +1730,7 @@ class State extends Contract {
1673
1730
  });
1674
1731
  let node2 = await globalThis.peernet.prepareMessage(data2);
1675
1732
  try {
1676
- let message2 = await peer.request(node2.encode());
1733
+ let message2 = await peer.request(node2.encoded);
1677
1734
  message2 = await new globalThis.peernet.protos["peernet-response"](message2);
1678
1735
  const MAX_WANTLIST_SIZE = 1e3;
1679
1736
  const incoming = message2.decoded.response.blocks.filter((block) => !this.knownBlocks.includes(block));
@@ -1841,6 +1898,10 @@ class VersionControl extends State {
1841
1898
  async init() {
1842
1899
  super.init && await super.init();
1843
1900
  try {
1901
+ if (!await globalThis.chainStore.has("version")) {
1902
+ await this.#setCurrentVersion();
1903
+ return;
1904
+ }
1844
1905
  const version = await globalThis.chainStore.get("version");
1845
1906
  const storedVersion = new TextDecoder().decode(version);
1846
1907
  console.log(storedVersion, this.#currentVersion);
@@ -2158,6 +2219,60 @@ class ConnectionMonitor {
2158
2219
  }
2159
2220
  }
2160
2221
 
2222
+ const quorumThreshold = (validatorCount) => {
2223
+ if (!Number.isSafeInteger(validatorCount) || validatorCount < 1) {
2224
+ throw new Error("validator count must be a positive safe integer");
2225
+ }
2226
+ return Math.floor(2 * validatorCount / 3) + 1;
2227
+ };
2228
+
2229
+ const validateChainLink = (localTip, incoming) => {
2230
+ const localHash = localTip?.hash || "0x0";
2231
+ const localIndex = Number(localTip?.index ?? -1);
2232
+ const hasCanonicalTip = localHash !== "0x0";
2233
+ if (hasCanonicalTip && incoming.index < localIndex) return "stale";
2234
+ if (hasCanonicalTip && incoming.index === localIndex) {
2235
+ if (incoming.hash === localHash) return "duplicate";
2236
+ throw new Error(`Block conflict detected at index ${incoming.index}`);
2237
+ }
2238
+ const expectedIndex = hasCanonicalTip ? localIndex + 1 : 0;
2239
+ if (incoming.index !== expectedIndex) {
2240
+ throw new Error(`Unexpected block index ${incoming.index}; expected ${expectedIndex}`);
2241
+ }
2242
+ if (incoming.previousHash !== localHash) {
2243
+ throw new Error(
2244
+ `previousHash mismatch at index ${incoming.index}: expected ${localHash}, got ${incoming.previousHash}`
2245
+ );
2246
+ }
2247
+ return "append";
2248
+ };
2249
+
2250
+ const consensusSignableData = (validatorsAddress, type, message) => ({
2251
+ from: String(message.from),
2252
+ to: validatorsAddress,
2253
+ method: `consensus:${type}`,
2254
+ params: [String(message.blockHash), String(message.index), String(message.round)],
2255
+ timestamp: 0
2256
+ });
2257
+ const signConsensusMessage = async (validatorsAddress, type, message, identity) => {
2258
+ if (!identity) throw new Error(`cannot sign ${type} without a local identity`);
2259
+ const signed = await signTransaction(consensusSignableData(validatorsAddress, type, message), identity);
2260
+ return signed.signature;
2261
+ };
2262
+ const verifyConsensusMessage = async (validatorsAddress, type, message, network) => {
2263
+ if (typeof message.from !== "string" || typeof message.signature !== "string" || !message.signature) return false;
2264
+ try {
2265
+ const verifier = new MultiWallet(network);
2266
+ await verifier.fromAddress(message.from, null, network);
2267
+ return await verifier.verify(
2268
+ fromBase58(message.signature),
2269
+ await createTransactionHash(consensusSignableData(validatorsAddress, type, message))
2270
+ );
2271
+ } catch {
2272
+ return false;
2273
+ }
2274
+ };
2275
+
2161
2276
  const debug = createDebugger("leofcoin/chain");
2162
2277
  class Chain extends VersionControl {
2163
2278
  constructor(config) {
@@ -2189,6 +2304,8 @@ class Chain extends VersionControl {
2189
2304
  // ── Tendermint consensus state ──────────────────────────────────────────────
2190
2305
  /** Current consensus round (increments when proposer is unresponsive) */
2191
2306
  this.#consensusRound = 0;
2307
+ /** Locally proposed block awaiting quorum; prevents duplicate proposals at one height. */
2308
+ this.#proposalInFlight = null;
2192
2309
  /** Timer that advances #consensusRound when the proposer doesn't propose in time */
2193
2310
  this.#roundTimer = null;
2194
2311
  /** prevotes collected per `height:round:blockHash` key */
@@ -2197,6 +2314,8 @@ class Chain extends VersionControl {
2197
2314
  this.#precommits = /* @__PURE__ */ new Map();
2198
2315
  /** Index of the last block that reached 2f+1 precommits */
2199
2316
  this.#committedHeight = -1;
2317
+ /** Heights currently applying to local state; prevents duplicate concurrent commits. */
2318
+ this.#committingHeights = /* @__PURE__ */ new Set();
2200
2319
  /** Prevents casting duplicate prevote/precommit per height:round */
2201
2320
  this.#castedVotes = /* @__PURE__ */ new Set();
2202
2321
  this.ready = new Promise((resolve) => {
@@ -2210,9 +2329,10 @@ class Chain extends VersionControl {
2210
2329
  this.#castVote = async (type, blockHash, index, round) => {
2211
2330
  const voteKey = `${type}:${index}:${round}`;
2212
2331
  if (this.#castedVotes.has(voteKey)) return;
2213
- this.#castedVotes.add(voteKey);
2214
2332
  const from = peernet.selectedAccount;
2215
- const voteData = { blockHash, index: BigInt(index), round: BigInt(round), from };
2333
+ const unsignedVote = { blockHash, index: BigInt(index), round: BigInt(round), from };
2334
+ const voteData = { ...unsignedVote, signature: await this.#signConsensusMessage(type, unsignedVote) };
2335
+ this.#castedVotes.add(voteKey);
2216
2336
  const Message = type === "prevote" ? PrevoteMessage : PrecommitMessage;
2217
2337
  const message = new Message(voteData);
2218
2338
  const payload = message.encoded;
@@ -2221,6 +2341,8 @@ class Chain extends VersionControl {
2221
2341
  } catch (e) {
2222
2342
  debug(`peernet publish failed: consensus:${type}`, e?.message ?? e);
2223
2343
  }
2344
+ if (type === "prevote") await this.#handlePrevote(payload);
2345
+ else await this.#handlePrecommit(payload);
2224
2346
  };
2225
2347
  /**
2226
2348
  * Phase 2 — receive a block proposal from the designated proposer.
@@ -2232,6 +2354,10 @@ class Chain extends VersionControl {
2232
2354
  const message = new ProposalMessage(payload);
2233
2355
  const msg = message.decoded;
2234
2356
  const { blockHash, index, round, from } = msg;
2357
+ if (!await this.#verifyConsensusMessage("proposal", msg)) {
2358
+ debug(`[consensus] Ignoring proposal with invalid signature from ${from}`);
2359
+ return;
2360
+ }
2235
2361
  const validators = await this.#getConsensusValidators(Number(index));
2236
2362
  const expectedProposerIdx = Number((index + round) % BigInt(validators.length));
2237
2363
  if (!validators[expectedProposerIdx] || validators[expectedProposerIdx] !== from) {
@@ -2277,6 +2403,10 @@ class Chain extends VersionControl {
2277
2403
  const message = new PrevoteMessage(payload);
2278
2404
  const msg = message.decoded;
2279
2405
  const { blockHash, index, round, from } = msg;
2406
+ if (!await this.#verifyConsensusMessage("prevote", msg)) {
2407
+ debug(`[consensus] Ignoring prevote with invalid signature from ${from}`);
2408
+ return;
2409
+ }
2280
2410
  const validators = await this.#getConsensusValidators(Number(index));
2281
2411
  if (!validators.includes(from)) return;
2282
2412
  const localBlock = await this.lastBlock;
@@ -2285,7 +2415,7 @@ class Chain extends VersionControl {
2285
2415
  const voteKey = `${index}:${round}:${blockHash}`;
2286
2416
  if (!this.#prevotes.has(voteKey)) this.#prevotes.set(voteKey, /* @__PURE__ */ new Set());
2287
2417
  this.#prevotes.get(voteKey).add(from);
2288
- const threshold = Math.ceil(2 * validators.length / 3);
2418
+ const threshold = quorumThreshold(validators.length);
2289
2419
  const voteCount = this.#prevotes.get(voteKey).size;
2290
2420
  debug(`[consensus] Prevotes ${voteKey}: ${voteCount}/${validators.length} (need ${threshold})`);
2291
2421
  if (voteCount >= threshold && validators.includes(peernet.selectedAccount) && !this.#isJailed(peernet.selectedAccount)) {
@@ -2305,46 +2435,48 @@ class Chain extends VersionControl {
2305
2435
  const message = new PrecommitMessage(payload);
2306
2436
  const msg = message.decoded;
2307
2437
  const { blockHash, index, round, from } = msg;
2438
+ if (!await this.#verifyConsensusMessage("precommit", msg)) {
2439
+ debug(`[consensus] Ignoring precommit with invalid signature from ${from}`);
2440
+ return;
2441
+ }
2308
2442
  const validators = await this.#getConsensusValidators(Number(index));
2309
2443
  if (!validators.includes(from)) return;
2310
2444
  if (index <= BigInt(this.#committedHeight)) return;
2311
2445
  const voteKey = `${index}:${round}:${blockHash}`;
2312
2446
  if (!this.#precommits.has(voteKey)) this.#precommits.set(voteKey, /* @__PURE__ */ new Set());
2313
2447
  this.#precommits.get(voteKey).add(from);
2314
- const threshold = Math.ceil(2 * validators.length / 3);
2448
+ const threshold = quorumThreshold(validators.length);
2315
2449
  const voteCount = this.#precommits.get(voteKey).size;
2316
2450
  debug(`[consensus] Precommits ${voteKey}: ${voteCount}/${validators.length} (need ${threshold})`);
2317
- if (voteCount >= threshold && index > BigInt(this.#committedHeight)) {
2318
- this.#committedHeight = Number(index);
2319
- this.#consensusRound = 0;
2320
- for (const key of [...this.#prevotes.keys()]) {
2321
- if (BigInt(key.split(":")[0]) <= index) this.#prevotes.delete(key);
2322
- }
2323
- for (const key of [...this.#precommits.keys()]) {
2324
- if (BigInt(key.split(":")[0]) <= index) this.#precommits.delete(key);
2325
- }
2326
- for (const key of [...this.#castedVotes]) {
2327
- if (BigInt(key.split(":")[1]) <= index) this.#castedVotes.delete(key);
2328
- }
2329
- const currentBlock = await this.lastBlock;
2330
- const currentIndex = currentBlock?.index !== void 0 ? currentBlock.index : -1n;
2331
- if (index > currentIndex) {
2332
- debug(`[consensus] \u2705 Committing block ${blockHash} at height ${index}`);
2333
- try {
2334
- const blockData = await globalThis.peernet.get(blockHash, "block");
2335
- await this.#addBlock(blockData);
2336
- } catch (e) {
2337
- debug(`[consensus] Failed to commit block ${blockHash}:`, e?.message);
2338
- }
2339
- } else {
2340
- debug(`[consensus] \u2705 Block ${blockHash} at height ${index} already committed (proposer path)`);
2341
- }
2451
+ const numericIndex = Number(index);
2452
+ if (voteCount >= threshold && index > BigInt(this.#committedHeight) && !this.#committingHeights.has(numericIndex)) {
2453
+ this.#committingHeights.add(numericIndex);
2342
2454
  try {
2455
+ debug(`[consensus] \u2705 Committing block ${blockHash} at height ${index}`);
2343
2456
  const blockData = await globalThis.peernet.get(blockHash, "block");
2457
+ await this.#addBlock(blockData);
2458
+ this.#committedHeight = numericIndex;
2459
+ this.#consensusRound = 0;
2460
+ if (this.#proposalInFlight?.hash === blockHash) this.#proposalInFlight = null;
2461
+ if (this.#roundTimer) {
2462
+ clearTimeout(this.#roundTimer);
2463
+ this.#roundTimer = null;
2464
+ }
2465
+ for (const key of [...this.#prevotes.keys()]) {
2466
+ if (BigInt(key.split(":")[0]) <= index) this.#prevotes.delete(key);
2467
+ }
2468
+ for (const key of [...this.#precommits.keys()]) {
2469
+ if (BigInt(key.split(":")[0]) <= index) this.#precommits.delete(key);
2470
+ }
2471
+ for (const key of [...this.#castedVotes]) {
2472
+ if (BigInt(key.split(":")[1]) <= index) this.#castedVotes.delete(key);
2473
+ }
2344
2474
  globalThis.peernet.publish("add-block", blockData);
2345
2475
  globalThis.pubsub.publish("add-block", blockData);
2346
2476
  } catch (e) {
2347
- debug("[consensus] Failed to broadcast committed block:", e?.message);
2477
+ debug(`[consensus] Failed to commit block ${blockHash}:`, e?.message);
2478
+ } finally {
2479
+ this.#committingHeights.delete(numericIndex);
2348
2480
  }
2349
2481
  }
2350
2482
  } catch (e) {
@@ -2379,10 +2511,12 @@ class Chain extends VersionControl {
2379
2511
  #minPeerScore;
2380
2512
  #maxPeerFailures;
2381
2513
  #consensusRound;
2514
+ #proposalInFlight;
2382
2515
  #roundTimer;
2383
2516
  #prevotes;
2384
2517
  #precommits;
2385
2518
  #committedHeight;
2519
+ #committingHeights;
2386
2520
  #castedVotes;
2387
2521
  // ────────────────────────────────────────────────────────────────────────────
2388
2522
  #connectionMonitor;
@@ -2422,6 +2556,18 @@ class Chain extends VersionControl {
2422
2556
  #isJailed(address) {
2423
2557
  return typeof address === "string" && this.#jail.has(address);
2424
2558
  }
2559
+ async #signConsensusMessage(type, message) {
2560
+ const identity = globalThis.peernet?.identity;
2561
+ return signConsensusMessage(addresses.validators, type, message, identity);
2562
+ }
2563
+ async #verifyConsensusMessage(type, message) {
2564
+ return verifyConsensusMessage(
2565
+ addresses.validators,
2566
+ type,
2567
+ message,
2568
+ globalThis.peernet?.network || "leofcoin"
2569
+ );
2570
+ }
2425
2571
  async #getConsensusValidators(nextBlockIndex) {
2426
2572
  const localBlock = await this.lastBlock;
2427
2573
  const localIndex = localBlock?.index !== void 0 ? Number(localBlock.index) : -1;
@@ -2435,7 +2581,7 @@ class Chain extends VersionControl {
2435
2581
  const validators = await this.staticCall(addresses.validators, "validators");
2436
2582
  return [...new Set(validators)].sort();
2437
2583
  }
2438
- #validateBlockValidators(blockMessage) {
2584
+ async #validateBlockValidators(blockMessage) {
2439
2585
  const validators = blockMessage.decoded.validators || [];
2440
2586
  if (!Array.isArray(validators) || validators.length === 0) {
2441
2587
  throw new Error(`Block ${blockMessage.decoded.index} does not include validators`);
@@ -2460,15 +2606,31 @@ class Chain extends VersionControl {
2460
2606
  `Block ${blockMessage.decoded.index} producer ${blockMessage.decoded.producer} is not in validators list`
2461
2607
  );
2462
2608
  }
2463
- const addresses2 = validators.map((validator) => validator.address);
2464
- if (addresses2.some((address) => typeof address !== "string" || address.length === 0)) {
2609
+ const unsignedBlockMessage = new BlockMessage({ ...blockMessage.decoded, producerProof: "" });
2610
+ const unsignedBlockHash = await unsignedBlockMessage.hash();
2611
+ const proofPayload = await createTransactionHash({
2612
+ from: blockMessage.decoded.producer,
2613
+ to: addresses.validators,
2614
+ method: "produceBlock",
2615
+ params: [unsignedBlockHash],
2616
+ timestamp: Number(blockMessage.decoded.timestamp)
2617
+ });
2618
+ const network = globalThis.peernet?.network || "leofcoin";
2619
+ const verifier = new MultiWallet(network);
2620
+ await verifier.fromAddress(blockMessage.decoded.producer, null, network);
2621
+ const validProducerProof = await verifier.verify(fromBase58(blockMessage.decoded.producerProof), proofPayload);
2622
+ if (!validProducerProof) {
2623
+ throw new Error(`Block ${blockMessage.decoded.index} has an invalid producerProof`);
2624
+ }
2625
+ const validatorAddresses = validators.map((validator) => validator.address);
2626
+ if (validatorAddresses.some((address) => typeof address !== "string" || address.length === 0)) {
2465
2627
  throw new Error(`Block ${blockMessage.decoded.index} includes an invalid validator address`);
2466
2628
  }
2467
- const canonicalAddresses = [...addresses2].sort();
2468
- if (canonicalAddresses.some((address, index) => address !== addresses2[index])) {
2629
+ const canonicalAddresses = [...validatorAddresses].sort();
2630
+ if (canonicalAddresses.some((address, index) => address !== validatorAddresses[index])) {
2469
2631
  throw new Error(`Block ${blockMessage.decoded.index} validators are not canonically sorted`);
2470
2632
  }
2471
- if (new Set(addresses2).size !== addresses2.length) {
2633
+ if (new Set(validatorAddresses).size !== validatorAddresses.length) {
2472
2634
  throw new Error(`Block ${blockMessage.decoded.index} validators contain duplicates`);
2473
2635
  }
2474
2636
  const validatorCount = BigInt(validators.length);
@@ -2545,7 +2707,7 @@ class Chain extends VersionControl {
2545
2707
  const hasMore = await this.hasTransactionToHandle();
2546
2708
  if (!hasMore && remaining > 0) await this.#sleep(remaining);
2547
2709
  this.#runningEpoch = false;
2548
- if (hasMore) return this.#runEpoch();
2710
+ if (hasMore && !this.#proposalInFlight) return this.#runEpoch();
2549
2711
  }
2550
2712
  async #setup() {
2551
2713
  const contracts = [
@@ -2981,29 +3143,15 @@ class Chain extends VersionControl {
2981
3143
  const blockMessage = await new BlockMessage(block);
2982
3144
  const hash = await blockMessage.hash();
2983
3145
  const blockIndex = Number(blockMessage.decoded.index);
2984
- const existingBlockAtHeight = this.#blocks[blockIndex];
2985
- if (existingBlockAtHeight) {
2986
- if (existingBlockAtHeight.hash !== hash) {
2987
- console.error(` Local: ${existingBlockAtHeight.hash}`);
2988
- console.error(` Remote: ${hash}`);
2989
- throw new Error(`Block conflict detected at index ${blockIndex}`);
2990
- }
2991
- debug(`Block already in store: ${hash}`);
3146
+ const linkResult = validateChainLink(await this.lastBlock, {
3147
+ index: blockIndex,
3148
+ hash,
3149
+ previousHash: String(blockMessage.decoded.previousHash)
3150
+ });
3151
+ if (linkResult !== "append") {
3152
+ debug(`Ignoring ${linkResult} block ${hash} at height ${blockIndex}`);
2992
3153
  return;
2993
3154
  }
2994
- if (blockIndex > 0) {
2995
- const previousBlockInfo = this.#blocks[blockIndex - 1];
2996
- if (!previousBlockInfo) {
2997
- throw new Error(`Missing parent block at index ${blockIndex - 1}`);
2998
- }
2999
- if (previousBlockInfo.hash !== blockMessage.decoded.previousHash) {
3000
- throw new Error(
3001
- `previousHash mismatch at index ${blockIndex}: expected ${previousBlockInfo.hash}, got ${blockMessage.decoded.previousHash}`
3002
- );
3003
- }
3004
- } else if (blockMessage.decoded.previousHash !== "0x0") {
3005
- throw new Error(`Genesis block (index 0) must have previousHash='0x0'`);
3006
- }
3007
3155
  const canonicalEncoded = blockMessage.encoded;
3008
3156
  const byteLengthMatch = receivedEncoded.length === canonicalEncoded.length;
3009
3157
  if (!byteLengthMatch) {
@@ -3022,14 +3170,19 @@ class Chain extends VersionControl {
3022
3170
  throw new Error(`[FATAL] Block data corrupted in transit for block #${blockIndex} hash ${hash}`);
3023
3171
  }
3024
3172
  console.log(`[chain] \u2705 Block data integrity verified: ${hash}`);
3025
- this.#validateBlockValidators(blockMessage);
3173
+ await this.#validateBlockValidators(blockMessage);
3026
3174
  const transactions = await Promise.all(
3027
3175
  blockMessage.decoded.transactions.map(async (hash2) => {
3028
3176
  const data = await peernet.get(hash2, "transaction");
3029
- await transactionPoolStore.has(hash2) && await transactionPoolStore.delete(hash2);
3030
3177
  return new TransactionMessage(data);
3031
3178
  })
3032
3179
  );
3180
+ await Promise.all(transactions.map((transaction) => this.validateTransactionSignature(transaction)));
3181
+ await Promise.all(
3182
+ blockMessage.decoded.transactions.map(async (transactionHash) => {
3183
+ if (await transactionPoolStore.has(transactionHash)) await transactionPoolStore.delete(transactionHash);
3184
+ })
3185
+ );
3033
3186
  await globalThis.blockStore.put(hash, blockMessage.encoded);
3034
3187
  this.#blocks[blockIndex] = {
3035
3188
  hash,
@@ -3046,26 +3199,11 @@ class Chain extends VersionControl {
3046
3199
  if (nonceDiff !== 0) return nonceDiff;
3047
3200
  return 0;
3048
3201
  });
3049
- const txsByAddress = /* @__PURE__ */ new Map();
3050
3202
  for (const transaction of allTransactions) {
3051
- const sender = transaction.decoded.from;
3052
- if (!txsByAddress.has(sender)) {
3053
- txsByAddress.set(sender, []);
3054
- }
3055
- txsByAddress.get(sender).push(transaction);
3203
+ if (!contracts.includes(transaction.decoded.to)) contracts.push(transaction.decoded.to);
3204
+ this.removePendingNonce(transaction.decoded.from, transaction.decoded.nonce);
3205
+ await this.#handleTransaction(transaction, []);
3056
3206
  }
3057
- const addressGroups = [...txsByAddress.values()];
3058
- await Promise.all(
3059
- addressGroups.map(async (addressTxs) => {
3060
- for (const transaction of addressTxs) {
3061
- if (!contracts.includes(transaction.decoded.to)) {
3062
- contracts.push(transaction.decoded.to);
3063
- }
3064
- this.removePendingNonce(transaction.decoded.from, transaction.decoded.nonce);
3065
- await this.#handleTransaction(transaction, []);
3066
- }
3067
- })
3068
- );
3069
3207
  try {
3070
3208
  promises = await Promise.allSettled(promises);
3071
3209
  const noncesByAddress = {};
@@ -3098,7 +3236,6 @@ class Chain extends VersionControl {
3098
3236
  }
3099
3237
  }
3100
3238
  async participate(address) {
3101
- this.#participating = true;
3102
3239
  try {
3103
3240
  if (!await this.staticCall(addresses.validators, "has", [address])) {
3104
3241
  const rawTransaction = {
@@ -3110,18 +3247,20 @@ class Chain extends VersionControl {
3110
3247
  timestamp: Date.now()
3111
3248
  };
3112
3249
  const transaction = await signTransaction(rawTransaction, globalThis.peernet.identity);
3113
- try {
3114
- await this.sendTransaction(transaction);
3115
- } catch (error) {
3116
- console.error(error);
3117
- }
3250
+ await this.sendTransaction(transaction);
3251
+ this.#participating = false;
3252
+ return false;
3118
3253
  }
3119
3254
  } catch (error) {
3120
- debug("Error in participate:", error.message);
3255
+ this.#participating = false;
3256
+ throw new Error(`validator participation failed: ${error?.message ?? error}`);
3121
3257
  }
3258
+ this.#participating = true;
3122
3259
  if (await this.hasTransactionToHandle() && !this.#runningEpoch && this.#participating) await this.#runEpoch();
3260
+ return true;
3123
3261
  }
3124
3262
  async #handleTransaction(transaction, latestTransactions, block) {
3263
+ await this.validateTransactionSignature(transaction);
3125
3264
  const hash = await transaction.hash();
3126
3265
  const doubleTransactions = [];
3127
3266
  if (latestTransactions.includes(hash) || await transactionStore.has(hash)) {
@@ -3153,12 +3292,13 @@ class Chain extends VersionControl {
3153
3292
  }
3154
3293
  // todo filter tx that need to wait on prev nonce
3155
3294
  async #createBlock(limit = this.transactionLimit) {
3295
+ if (this.#proposalInFlight) return;
3156
3296
  console.log(await globalThis.transactionPoolStore.size());
3157
3297
  if (await globalThis.transactionPoolStore.size() === 0) return;
3158
3298
  let transactions = await globalThis.transactionPoolStore.values(this.transactionLimit);
3159
3299
  if (Object.keys(transactions)?.length === 0) return;
3160
3300
  const timestamp = Date.now();
3161
- let block = {
3301
+ const block = {
3162
3302
  transactions: [],
3163
3303
  validators: [],
3164
3304
  fees: BigInt(0),
@@ -3180,70 +3320,17 @@ class Chain extends VersionControl {
3180
3320
  if (nonceDiff !== 0) return nonceDiff;
3181
3321
  return 0;
3182
3322
  });
3183
- const txsByAddress = /* @__PURE__ */ new Map();
3184
3323
  for (const transaction of allTransactions) {
3185
- const sender = transaction.decoded.from;
3186
- if (!txsByAddress.has(sender)) {
3187
- txsByAddress.set(sender, []);
3324
+ await this.validateTransactionSignature(transaction);
3325
+ const hash = await transaction.hash();
3326
+ if (latestTransactions.includes(hash) || await globalThis.transactionStore.has(hash)) {
3327
+ continue;
3188
3328
  }
3189
- txsByAddress.get(sender).push(transaction);
3329
+ block.transactions.push(hash);
3330
+ block.fees += BigInt(await calculateFee(transaction.decoded));
3331
+ await globalThis.peernet.put(hash, transaction.encoded, "transaction");
3190
3332
  }
3191
- const addressGroups = [...txsByAddress.values()];
3192
- await Promise.all(
3193
- addressGroups.map(async (addressTxs) => {
3194
- for (const transaction of addressTxs) {
3195
- await this.#handleTransaction(transaction, latestTransactions, block);
3196
- }
3197
- })
3198
- );
3199
3333
  if (block.transactions.length === 0) return;
3200
- const validators = await this.staticCall(addresses.validators, "validators");
3201
- const peers = {};
3202
- for (const entry of globalThis.peernet.peers) {
3203
- peers[entry[0]] = entry[1];
3204
- }
3205
- const finalizeBWAndBroadcast = async () => {
3206
- const bwPromises = validators.map(async (validator) => {
3207
- const peer = peers[validator];
3208
- if (peer && peer.connected && this.isVersionCompatible(peer.version)) {
3209
- try {
3210
- let data = await new BWRequestMessage();
3211
- const node = await globalThis.peernet.prepareMessage(data.encoded);
3212
- const bw = await peer.request(node.encoded);
3213
- return {
3214
- address: validator,
3215
- bw: bw.up + bw.down
3216
- };
3217
- } catch (error) {
3218
- const peerId = peer?.peerId || peer?.id || peer?.address || "unknown";
3219
- debug(`bw request failed: ${peerId}:`, error?.message ?? error);
3220
- return null;
3221
- }
3222
- } else if (globalThis.peernet.selectedAccount === validator) {
3223
- return {
3224
- address: globalThis.peernet.selectedAccount,
3225
- bw: globalThis.peernet.bw.up + globalThis.peernet.bw.down
3226
- };
3227
- }
3228
- return null;
3229
- });
3230
- const bwResults = await Promise.allSettled(bwPromises);
3231
- for (const result of bwResults) {
3232
- if (result.status === "fulfilled" && result.value) {
3233
- block.validators.push(result.value);
3234
- }
3235
- }
3236
- };
3237
- finalizeBWAndBroadcast().catch((error) => {
3238
- debug(`background BW finalization failed:`, error?.message ?? error);
3239
- });
3240
- block.validators = block.validators.map((validator) => {
3241
- validator.reward = block.fees;
3242
- validator.reward += block.reward;
3243
- validator.reward /= BigInt(block.validators.length);
3244
- delete validator.bw;
3245
- return validator;
3246
- });
3247
3334
  const localBlock = await this.lastBlock;
3248
3335
  block.index = localBlock.index;
3249
3336
  if (block.index === void 0) block.index = 0;
@@ -3251,17 +3338,12 @@ class Chain extends VersionControl {
3251
3338
  block.previousHash = localBlock.hash || "0x0";
3252
3339
  const canonicalValidators = await this.staticCall(addresses.validators, "validators");
3253
3340
  const sortedValidators = [...canonicalValidators].sort();
3341
+ if (sortedValidators.length === 0) throw new Error("cannot produce a block without validators");
3254
3342
  block.validators = sortedValidators.map((validatorAddress) => ({
3255
3343
  address: validatorAddress,
3256
3344
  reward: block.fees / BigInt(sortedValidators.length) + block.reward / BigInt(sortedValidators.length)
3257
3345
  }));
3258
3346
  try {
3259
- await Promise.all(
3260
- block.transactions.map(async (transaction) => {
3261
- await globalThis.transactionStore.put(transaction, await transactionPoolStore.get(transaction));
3262
- await globalThis.transactionPoolStore.delete(transaction);
3263
- })
3264
- );
3265
3347
  block.producer = globalThis.peernet.selectedAccount || "";
3266
3348
  const producerSigner = globalThis.peernet?.identity;
3267
3349
  if (block.producer && producerSigner) {
@@ -3279,19 +3361,23 @@ class Chain extends VersionControl {
3279
3361
  );
3280
3362
  block.producerProof = signedProof.signature;
3281
3363
  }
3364
+ if (!block.producer || !block.producerProof) throw new Error("block producer identity is not available");
3282
3365
  let blockMessage = await new BlockMessage(block);
3283
3366
  const hash = await blockMessage.hash();
3284
3367
  await globalThis.peernet.put(hash, blockMessage.encoded, "block");
3285
- await this.machine.addLoadedBlock({ ...blockMessage.decoded, loaded: true, hash: await blockMessage.hash() });
3286
- await this.updateState(blockMessage);
3287
- debug(`created block: ${hash} @${block.index}`);
3368
+ this.#proposalInFlight = { hash, index: block.index, round: this.#consensusRound };
3369
+ debug(`proposed block: ${hash} @${block.index}`);
3288
3370
  console.log(`[consensus] \u{1F4E4} Proposing block #${block.index} | hash: ${hash} | round: ${this.#consensusRound}`);
3289
- const proposalData = {
3371
+ const unsignedProposal = {
3290
3372
  blockHash: hash,
3291
3373
  index: BigInt(block.index),
3292
3374
  round: BigInt(this.#consensusRound),
3293
3375
  from: peernet.selectedAccount
3294
3376
  };
3377
+ const proposalData = {
3378
+ ...unsignedProposal,
3379
+ signature: await this.#signConsensusMessage("proposal", unsignedProposal)
3380
+ };
3295
3381
  const proposalMessage = new ProposalMessage(proposalData);
3296
3382
  const proposalPayload = proposalMessage.encoded;
3297
3383
  try {
@@ -3300,6 +3386,16 @@ class Chain extends VersionControl {
3300
3386
  debug("peernet publish failed: consensus:propose", publishError?.message ?? publishError);
3301
3387
  }
3302
3388
  await this.#castVote("prevote", hash, block.index, this.#consensusRound);
3389
+ if (!this.#roundTimer && this.#proposalInFlight?.hash === hash) {
3390
+ this.#roundTimer = setTimeout(async () => {
3391
+ this.#roundTimer = null;
3392
+ if (this.#proposalInFlight?.hash !== hash) return;
3393
+ this.#proposalInFlight = null;
3394
+ this.#consensusRound += 1;
3395
+ this.#runningEpoch = false;
3396
+ if (this.#participating) await this.#runEpoch();
3397
+ }, this.#slotTime);
3398
+ }
3303
3399
  } catch (error) {
3304
3400
  console.log(error);
3305
3401
  throw new Error(`invalid block ${block}`);
@@ -3307,6 +3403,7 @@ class Chain extends VersionControl {
3307
3403
  }
3308
3404
  async #sendTransaction(transaction) {
3309
3405
  transaction = await new TransactionMessage(transaction.encoded || transaction);
3406
+ await this.validateTransactionSignature(transaction);
3310
3407
  const hash = await transaction.hash();
3311
3408
  try {
3312
3409
  const has = await globalThis.transactionPoolStore.has(hash);