trac-msb 0.3.0-beta.1 → 0.3.0-beta.3

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "trac-msb",
3
3
  "main": "msb.mjs",
4
- "version": "0.3.0-beta.1",
4
+ "version": "0.3.0-beta.3",
5
5
  "pear": {
6
6
  "name": "trac-msb",
7
7
  "type": "terminal"
@@ -26,7 +26,7 @@
26
26
  "lint:fix": "eslint . --fix",
27
27
  "protobuf": "node scripts/generate-protobufs.js",
28
28
  "docs:check": "node scripts/docs/docs-checker.js",
29
- "test:acceptance": "node --experimental-vm-modules node_modules/jest/bin/jest.js --testTimeout=200000 tests/acceptance/v1/rpc.test.mjs tests/acceptance/v1/htlc/htlc-routing.test.mjs --runInBand",
29
+ "test:acceptance": "node --experimental-vm-modules node_modules/jest/bin/jest.js --testTimeout=200000 tests/acceptance/v1/rpc.test.mjs tests/acceptance/v1/htlc/htlc-routing.test.mjs tests/acceptance/v1/current-epoch/current-epoch-routing.test.mjs tests/acceptance/v1/consensus-config/consensus-config-routing.test.mjs --runInBand",
30
30
  "test:integration": "brittle-node -t 1200000 tests/integration/integration.test.js",
31
31
  "test:unit:node": "brittle-node -t 60000 tests/unit/unit.test.js",
32
32
  "test:unit:bare": "brittle-bare -t 60000 tests/unit/unit.test.js",
package/rpc/handlers.js CHANGED
@@ -456,3 +456,21 @@ export async function handleAccountDetails({ msbInstance, respond, req }) {
456
456
  stakedBalance: bufferToBigInt(nodeEntry.stakedBalance).toString(),
457
457
  });
458
458
  }
459
+
460
+ export async function handleCurrentEpoch({ req, msbInstance, respond }) {
461
+ const confirmed = getConfirmedParameter(buildRequestUrl(req));
462
+ if (confirmed === null) {
463
+ return respond(400, { error: 'Parameter "confirmed" must be exactly "true" or "false"' });
464
+ }
465
+ const current_epoch = await msbInstance.getCurrentEpoch(confirmed);
466
+ respond(200, { current_epoch });
467
+ }
468
+
469
+ export async function handleConsensusConfig({ req, msbInstance, respond }) {
470
+ const confirmed = getConfirmedParameter(buildRequestUrl(req));
471
+ if (confirmed === null) {
472
+ return respond(400, { error: 'Parameter "confirmed" must be exactly "true" or "false"' });
473
+ }
474
+ const consensusConfig = await msbInstance.getConsensusConfig(confirmed);
475
+ respond(200, consensusConfig);
476
+ }
package/rpc/routes/v1.js CHANGED
@@ -15,6 +15,8 @@ import {
15
15
  handleHtlcClaim,
16
16
  handleHtlcRefund,
17
17
  handleHtlc,
18
+ handleCurrentEpoch,
19
+ handleConsensusConfig,
18
20
  } from '../handlers.js';
19
21
 
20
22
  export const v1Routes = [
@@ -34,4 +36,6 @@ export const v1Routes = [
34
36
  { method: 'POST', path: '/tx-payloads-bulk', handler: handleFetchBulkTxPayloads },
35
37
  { method: 'GET', path: '/tx/details', handler: handleTransactionExtendedDetails },
36
38
  { method: 'GET', path: '/account', handler: handleAccountDetails },
39
+ { method: 'GET', path: '/current-epoch', exact: true, handler: handleCurrentEpoch },
40
+ { method: 'GET', path: '/consensus-config', exact: true, handler: handleConsensusConfig },
37
41
  ];
@@ -42,9 +42,9 @@ class ValidatorMessageOrchestrator {
42
42
  *
43
43
  * @param {object} [message] Outgoing operation payload.
44
44
  * @param {string} [message.address] Requester address (bech32m).
45
- * @returns {string|null} Selected validator public key hex, or null when unavailable.
45
+ * @returns {Promise<string|null>} Selected validator public key hex, or null when unavailable.
46
46
  */
47
- #pickValidatorForMessage(message) {
47
+ async #pickValidatorForMessage(message) {
48
48
  const requesterAddress = message?.address;
49
49
 
50
50
  if (!requesterAddress) return null
@@ -58,10 +58,15 @@ class ValidatorMessageOrchestrator {
58
58
  return publicKeyToAddress(publicKey, this.#config) !== requesterAddress;
59
59
  });
60
60
 
61
- const pool = eligible.length > 0 ? eligible : connected;
62
-
63
- const index = Math.floor(Math.random() * pool.length);
64
- return pool[index] ?? null;
61
+ const pool = [...(eligible.length > 0 ? eligible : connected)];
62
+ while (pool.length > 0) {
63
+ const index = Math.floor(Math.random() * pool.length);
64
+ const [publicKey] = pool.splice(index, 1);
65
+ const address = publicKeyToAddress(publicKey, this.#config);
66
+ if (await this.state.isValidatorAddress(address)) return publicKey;
67
+ this.validatorConnectionManager.remove(publicKey);
68
+ }
69
+ return null;
65
70
  }
66
71
 
67
72
  /**
@@ -76,7 +81,7 @@ class ValidatorMessageOrchestrator {
76
81
  return false;
77
82
  }
78
83
 
79
- const validatorPublicKey = this.#pickValidatorForMessage(message);
84
+ const validatorPublicKey = await this.#pickValidatorForMessage(message);
80
85
  if (!validatorPublicKey) return false;
81
86
  console.log("Sending message to validator:", publicKeyToAddress(validatorPublicKey, this.#config));
82
87
 
@@ -91,6 +96,7 @@ class ValidatorMessageOrchestrator {
91
96
 
92
97
  // TODO: After Legacy is deprecated, we don't need to check preferred protocol here.
93
98
  const validatorConnection = this.validatorConnectionManager.getConnection(validatorPublicKey);
99
+ if (!validatorConnection) return this.send(message, retries + 1);
94
100
  const preferredProtocol = validatorConnection.protocolSessions.validator.preferredProtocol;
95
101
  let success = false;
96
102
  if (preferredProtocol === validatorConnection.protocolSessions.validator.supportedProtocols.LEGACY) {
@@ -4,6 +4,7 @@ import b4a from "b4a";
4
4
  import { bufferToAddress } from "../../../core/state/utils/address.js";
5
5
  import tracCryptoApi from "trac-crypto-api";
6
6
  import { WRITER_BYTE_LENGTH, CONNECTION_STATUS } from "../../../utils/constants.js";
7
+ import { publicKeyToAddress } from "../../../utils/helpers.js";
7
8
 
8
9
  // Internal constants
9
10
  const VALIDATOR_CANDIDATES_PER_CYCLE = 10;
@@ -48,27 +49,19 @@ class ValidatorObserverService extends SchedulableService {
48
49
  * - Assumes writers list is already pre-filtered and deduplicated from scan phase.
49
50
  * - Excludes self.
50
51
  * - Excludes already connected or pending nodes.
51
- * - Applies admin connection restriction based on the threshold of ALIVE writers.
52
+ * - Revalidates roles and the admin threshold before connecting.
52
53
  * - Randomizes selection (Fisher-Yates) to avoid bias.
53
54
  * @param {Array<Object>} writers - The deduplicated list of active writers.
54
- * @param {Object} adminEntry - The current admin node entry.
55
55
  * @returns {{candidates: Array<Object>, slots: number}} The selected candidates and the number of available connection slots.
56
56
  */
57
- #selectCandidates(writers, adminEntry) {
57
+ #selectCandidates(writers) {
58
58
  const manager = this.#network.validatorConnectionManager;
59
- const adminThreshold = this.#config.maxWritersForAdminIndexerConnection;
60
-
61
- // Evaluates the admin blockage policy based on fully connected/alive writers
62
- const aliveWritersCount = this.#countAliveWriters(writers, adminEntry);
63
- const adminBlocked = adminEntry && aliveWritersCount >= adminThreshold;
64
-
65
59
  // Target calculation: ensures the observer deducts its own slot from the network size
66
60
  // only if it belongs to the valid writers list.
67
61
  const isSelfInWriters = writers.some(w => w.address === this.#address);
68
62
  const networkSize = isSelfInWriters ? writers.length - 1 : writers.length;
69
63
  const target = Math.min(networkSize, this.#config.maxValidators);
70
64
 
71
- const isSelfAdmin = this.#address === adminEntry?.address;
72
65
  const totalConnected = manager.connectionCount();
73
66
 
74
67
  const available = writers.filter((w) => {
@@ -82,9 +75,6 @@ class ValidatorObserverService extends SchedulableService {
82
75
  // Skips nodes we are already interacting with
83
76
  if (isConnected || isPending) return false;
84
77
 
85
- // The admin is actively filtered out from the pool if the threshold policy is triggered
86
- if (adminBlocked && !isSelfAdmin && w.address === adminEntry?.address) return false;
87
-
88
78
  return true;
89
79
  });
90
80
 
@@ -107,37 +97,6 @@ class ValidatorObserverService extends SchedulableService {
107
97
  }
108
98
  }
109
99
 
110
- /**
111
- * Enforces admin-specific connection rules.
112
- *
113
- * If the count of fully connected writers exceeds the threshold:
114
- * - The admin node is excluded from the validator pool (handled in selection phase).
115
- * - Non-admin nodes detach the admin from the validator pool, preserving replication.
116
- *
117
- * NOTE:
118
- * The admin is not forcibly disconnected from all roles.
119
- * It can still maintain connections for tx submission and observation.
120
- * @param {Array<Object>} writers - The deduplicated list of active writers.
121
- * @param {Object} adminEntry - The current admin node entry.
122
- */
123
- #enforceAdminPolicy(writers, adminEntry) {
124
- // Evaluates against alive network peers rather than dormant database entries
125
- const aliveWritersCount = this.#countAliveWriters(writers, adminEntry);
126
-
127
- if (aliveWritersCount < this.#config.maxWritersForAdminIndexerConnection) return;
128
-
129
- this.#logger.debug("Validator threshold reached → enforcing admin policy");
130
-
131
- const isAdmin = adminEntry && this.#address === adminEntry.address;
132
- if (isAdmin) return;
133
-
134
- // Non-admin nodes: ensure the admin is not kept as a validator connection
135
- const adminWriter = writers.find((w) => w.address === adminEntry?.address);
136
- if (adminWriter?.publicKey) {
137
- this.#network.validatorConnectionManager.remove(adminWriter.publicKey);
138
- }
139
- }
140
-
141
100
  /**
142
101
  * Removes active connections to writers that are no longer valid (e.g., removed/soft-deleted).
143
102
  * @param {Uint8Array} publicKey - The public key of the stale connection to be removed.
@@ -161,40 +120,6 @@ class ValidatorObserverService extends SchedulableService {
161
120
  return !this.isInterrupted;
162
121
  }
163
122
 
164
- /**
165
- * Counts the number of physically active and connected writers in the network.
166
- * This method bridges the gap between the Autobase state (which may include
167
- * offline/dormant writers due to its append-only/soft-delete nature) and the
168
- * actual physical network layer. It ensures the admin threshold policy is
169
- * evaluated against truly active nodes rather than historical state.
170
- *
171
- * @param {Array<Object>} writers - The deduplicated list of writers from the Autobase.
172
- * @param {Object} adminEntry - The current admin node entry.
173
- * @returns {number} The count of alive, fully connected writers (including self).
174
- */
175
- #countAliveWriters(writers, adminEntry) {
176
- let aliveCount = 0;
177
- const manager = this.#network.validatorConnectionManager;
178
-
179
- for (const w of writers) {
180
- // Ignores the admin in the threshold calculation
181
- if (w.address === adminEntry?.address) continue;
182
-
183
- // Always counts the current node as alive if it's in the valid writers list
184
- if (w.address === this.#address) {
185
- aliveCount++;
186
- continue;
187
- }
188
-
189
- // Counts only fully connected nodes (ignores pending or offline peers)
190
- if (manager.connected(w.publicKey)) {
191
- aliveCount++;
192
- }
193
- }
194
-
195
- return aliveCount;
196
- }
197
-
198
123
  /**
199
124
  * Starts the validator observer loop.
200
125
  * Initializes the scheduler and bootstrap state.
@@ -226,8 +151,8 @@ class ValidatorObserverService extends SchedulableService {
226
151
  * Main worker loop (executed periodically).
227
152
  *
228
153
  * Flow:
229
- * 1. Fetch admin + writers (cached).
230
- * 2. Enforce admin connection policy.
154
+ * 1. Revalidate connected peers against current roles and the admin threshold.
155
+ * 2. Fetch admin + writers (cached).
231
156
  * 3. Skip if the connection pool is full.
232
157
  * 4. Select candidates.
233
158
  * 5. Attempt connections (with pacing).
@@ -245,18 +170,23 @@ class ValidatorObserverService extends SchedulableService {
245
170
  }
246
171
 
247
172
  try {
173
+ // Recheck bounded connection pools on every cycle, even when discovery
174
+ // is cached or the pool is full. Removing a role preserves replication.
175
+ const manager = this.#network.validatorConnectionManager;
176
+ for (const publicKey of manager.connectedPeers()) {
177
+ if (!await this.#state.isValidatorAddress(publicKeyToAddress(publicKey, this.#config))) {
178
+ manager.remove(publicKey);
179
+ }
180
+ }
248
181
  const adminEntry = await this.#getAdminEntryCached();
249
182
  const writers = await this.#getWritersCached(adminEntry);
250
183
 
251
184
  if (writers.length === 0) return next(interval);
252
185
 
253
- this.#enforceAdminPolicy(writers, adminEntry);
254
-
255
- const manager = this.#network.validatorConnectionManager;
256
186
  // CEILING: do not exceed the max connections limit
257
187
  if (manager.maxConnectionsReached()) return next(interval);
258
188
 
259
- const { candidates, slots} = this.#selectCandidates(writers, adminEntry);
189
+ const { candidates, slots} = this.#selectCandidates(writers);
260
190
  if (candidates.length > 0) {
261
191
  await this.#processCandidates(candidates, slots);
262
192
  }
@@ -389,7 +319,8 @@ class ValidatorObserverService extends SchedulableService {
389
319
  const addressBuffer = await this.#state.getRegisteredWriterKey(writerKeyHex);
390
320
 
391
321
  if (!addressBuffer || b4a.byteLength(addressBuffer) !== this.#config.addressLength) {
392
- this.#keyDecodeCache.set(writerKeyHex, null);
322
+ // Autobase membership can arrive before the signed address mapping.
323
+ // Retry on the next scan instead of caching a transient absence forever.
393
324
  continue;
394
325
  }
395
326
 
@@ -410,9 +341,7 @@ class ValidatorObserverService extends SchedulableService {
410
341
 
411
342
  const { addr, publicKey, publicKeyHex } = decodedIdentity;
412
343
 
413
- if (value.isIndexer && addr !== adminEntry?.address) continue;
414
-
415
- if (value.isRemoved) {
344
+ if (value.isRemoved || (value.isIndexer && addr !== adminEntry?.address)) {
416
345
  removedMap.set(addr, publicKey);
417
346
  continue;
418
347
  }
@@ -436,7 +365,7 @@ class ValidatorObserverService extends SchedulableService {
436
365
  /**
437
366
  * Attempts to establish a connection with a validator candidate.
438
367
  *
439
- * Assumes the candidate was pre-filtered and validated during scan/selection.
368
+ * Revalidates the role because discovery may predate a promotion or removal.
440
369
  *
441
370
  * Behavior:
442
371
  * - Delegates connection attempt to the network layer.
@@ -445,6 +374,7 @@ class ValidatorObserverService extends SchedulableService {
445
374
  */
446
375
  async #tryConnect(candidate) {
447
376
  try {
377
+ if (!await this.#state.isValidatorAddress(candidate.address)) return CONNECTION_STATUS.IGNORED;
448
378
  return await this.#network.tryConnect(candidate.publicKeyHex, "validator");
449
379
  } catch (err) {
450
380
  this.#logger.error(`Validator connection attempt failed: ${err.message}`);
@@ -418,13 +418,26 @@ class State extends ReadyResource {
418
418
  }
419
419
 
420
420
  async isAdminAllowedToValidate() {
421
- if (!this.writingKey) return false;
422
-
423
- const isAdmin = this.writingKey.toString('hex') === this.#config.bootstrap.toString('hex');
424
- const isIndexer = this.isIndexer();
421
+ if (!this.isWritable() || !this.isIndexer() || !await this.isAdmin()) return false;
425
422
  const activeWriters = await this.getActiveWriterCount(true);
426
- const lengthCondition = activeWriters < this.#config.maxWritersForAdminIndexerConnection;
427
- return !!(isAdmin && isIndexer && lengthCondition);
423
+ return activeWriters < this.#config.maxWritersForAdminIndexerConnection;
424
+ }
425
+
426
+ /**
427
+ * Checks a remote validator against the current local view, including pending
428
+ * role changes. Cached discovery and established transports do not grant a role.
429
+ */
430
+ async isValidatorAddress(address) {
431
+ const entry = await this.getNodeEntryUnsigned(address);
432
+ if (!entry?.isWriter) return false;
433
+ if (!entry.isIndexer) {
434
+ const signedEntry = await this.getNodeEntry(address);
435
+ return !!signedEntry?.isWriter && !signedEntry.isIndexer;
436
+ }
437
+
438
+ const admin = await this.getAdminEntry();
439
+ if (address !== admin?.address || !entry.wk || !admin.wk || !b4a.equals(entry.wk, admin.wk)) return false;
440
+ return await this.getActiveWriterCount(true) < this.#config.maxWritersForAdminIndexerConnection;
428
441
  }
429
442
 
430
443
  async isAddressWhitelisted(address) {
@@ -676,6 +689,12 @@ class State extends ReadyResource {
676
689
  for await (const entry of historyStream) {
677
690
  if (isTransactionRecordPut(entry)) {
678
691
  hashes.push({ hash: entry.key, confirmed_length: entry.seq });
692
+ } else if (entry.type === 'put' && entry.key.startsWith(EntryType.EPOCH_HASH)) {
693
+ // Epoch records share this history, but have their own key namespace.
694
+ const hash = entry.key.slice(EntryType.EPOCH_HASH.length);
695
+ if (hash.length === HASH_BYTE_LENGTH * 2 && isHexString(hash)) {
696
+ hashes.push({ hash, confirmed_length: entry.seq });
697
+ }
679
698
  }
680
699
  }
681
700
 
@@ -5561,6 +5580,28 @@ class State extends ReadyResource {
5561
5580
  return Status.SUCCESS;
5562
5581
  }
5563
5582
 
5583
+ async getUnsignedConsensusConfig() {
5584
+ // Keep the pointer and record in the same view while unsigned state can change.
5585
+ const snapshot = this.#base.view.snapshot();
5586
+ try {
5587
+ const currentConfigPointer = (await snapshot.get(EntryType.CONSENSUS_CONFIG_CURRENT))?.value;
5588
+ if (_.isNil(currentConfigPointer)) return null;
5589
+
5590
+ if (!b4a.isBuffer(currentConfigPointer) || currentConfigPointer.length !== CONSENSUS_CONFIG_INDEX_SIZE) {
5591
+ throw new Error('Invalid current consensus config pointer.');
5592
+ }
5593
+
5594
+ const currentConfigIndex = currentConfigPointer.readUInt32BE(0);
5595
+ const encodedConsensusConfig = (await snapshot.get(EntryType.CONSENSUS_CONFIG_RECORD + currentConfigIndex))?.value;
5596
+ if (_.isNil(encodedConsensusConfig)) {
5597
+ throw new Error(`Consensus config record ${currentConfigIndex} does not exist.`);
5598
+ }
5599
+
5600
+ return decodeVersionedConsensusConfig(encodedConsensusConfig);
5601
+ } finally {
5602
+ await snapshot.close();
5603
+ }
5604
+ }
5564
5605
  }
5565
5606
 
5566
5607
  export default State;
package/src/index.js CHANGED
@@ -37,7 +37,8 @@ import {
37
37
  encodeApplyOperation,
38
38
  encodeConsensusConfig,
39
39
  safeDecodeApplyOperation,
40
- safeEncodeApplyOperation
40
+ safeEncodeApplyOperation,
41
+ decodeEpochRecord
41
42
  } from "./codecs/apply/applyOperationCodec.js";
42
43
  import { encodeVdfConfig } from "./codecs/consensus/v1/vdfConfigCodec.js";
43
44
  import PartialTransactionValidator from "./core/network/protocols/validators/shared/validators/PartialTransactionValidator.js";
@@ -425,7 +426,16 @@ export class MainSettlementBus extends ReadyResource {
425
426
 
426
427
  async fetchBulkTxPayloads(hashes) {
427
428
  const response = { results: [], missing: [] };
428
- const results = await Promise.all(hashes.map((hash) => this.getConfirmedTxInfo(hash)));
429
+ const results = await Promise.all(hashes.map(async (hash) => {
430
+ const transaction = await this.getConfirmedTxInfo(hash);
431
+ if (transaction) return transaction.decoded;
432
+
433
+ const epochRecord = await this.#state.getEpochProof(hash);
434
+ if (epochRecord === null) return null;
435
+
436
+ // This hash identifies the stored epoch record, not an original transaction.
437
+ return { type: OperationType.SET_EPOCH, seo: decodeEpochRecord(epochRecord) };
438
+ }));
429
439
 
430
440
  results.forEach((result, index) => {
431
441
  const hash = hashes[index];
@@ -434,7 +444,7 @@ export class MainSettlementBus extends ReadyResource {
434
444
  return;
435
445
  }
436
446
 
437
- const decodedResult = normalizeDecodedPayloadForJson(result.decoded, this.#config);
447
+ const decodedResult = normalizeDecodedPayloadForJson(result, this.#config);
438
448
  response.results.push({ hash, payload: decodedResult });
439
449
  });
440
450
 
@@ -1290,6 +1300,19 @@ export class MainSettlementBus extends ReadyResource {
1290
1300
  expectedKeys.every(key => Object.hasOwn(value, key))
1291
1301
  );
1292
1302
  }
1303
+
1304
+ async getCurrentEpoch(confirmed = true) {
1305
+ const epoch = confirmed
1306
+ ? await this.#state.getCurrentEpoch()
1307
+ : await this.#state.getCurrentEpochUnsigned();
1308
+ return epoch === null ? null : epoch.toString();
1309
+ }
1310
+
1311
+ async getConsensusConfig(confirmed = true) {
1312
+ return confirmed
1313
+ ? this.#state.getSignedConsensusConfig()
1314
+ : this.#state.getUnsignedConsensusConfig();
1315
+ }
1293
1316
  }
1294
1317
 
1295
1318
  export default MainSettlementBus;