trac-msb 0.2.20 → 0.2.23

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/SECURITY.md CHANGED
@@ -6,13 +6,11 @@ The following table shows which versions of this project are currently receiving
6
6
 
7
7
  | Version | Supported | Pear compatibility |
8
8
  | -------- | ------------------ | ------------------ |
9
- | 0.2.19 | :white_check_mark: | Pear v2 and v3 |
10
- | 0.2.18 | :white_check_mark: | Pear v2 only |
11
- | 0.2.17 | :white_check_mark: | Pear v2 only |
9
+ | 0.2.21 | :white_check_mark: | Pear v2 and v3 |
12
10
  | main | :warning: unstable | Pear v2 and v3 |
13
- | < 0.2.17 | :x: | N/A |
11
+ | < 0.2.21 | :x: | N/A |
14
12
 
15
- Versions older than 0.2.17 are deprecated.
13
+ Versions older than 0.2.21 are deprecated.
16
14
  Please upgrade to the latest release to ensure you receive security fixes.
17
15
 
18
16
  ---
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "trac-msb",
3
3
  "main": "msb.mjs",
4
- "version": "0.2.20",
4
+ "version": "0.2.23",
5
5
  "pear": {
6
6
  "name": "trac-msb",
7
7
  "type": "terminal"
@@ -54,7 +54,7 @@
54
54
  "hyperbee": "2.26.5",
55
55
  "hypercore": "11.18.3",
56
56
  "hypercore-crypto": "3.6.1",
57
- "hyperdht": "6.27.0",
57
+ "hyperdht": "6.29.6",
58
58
  "hyperswarm": "4.14.2",
59
59
  "hyperschema": "1.17.1",
60
60
  "lodash": "^4.18.1",
@@ -214,6 +214,11 @@ export class Config {
214
214
  return this.#config.validatorHealthCheckInterval
215
215
  }
216
216
 
217
+ get validatorHealthCheckFailureThreshold() {
218
+ if (this.#isOverriden('validatorHealthCheckFailureThreshold')) return this.#options.validatorHealthCheckFailureThreshold
219
+ return this.#config.validatorHealthCheckFailureThreshold
220
+ }
221
+
217
222
  get maxPendingRequestsInPendingRequestsService() {
218
223
  return this.#config.maxPendingRequestsInPendingRequestsService
219
224
  }
package/src/config/env.js CHANGED
@@ -52,10 +52,11 @@ const configData = {
52
52
  rateLimitConnectionTimeoutMs: 60_000, // Rate limiting constants
53
53
  rateLimitMaxTransactionsPerSecond: 50, // Rate limiting constants
54
54
  maxPendingRequestsInPendingRequestsService: 50_000, // Maximum number of pending requests in PendingRequestService (This value should not exceed 256MB)
55
- pendingRequestTimeout: 3000, // constant after which time the transaction will be considered invalid
56
- txCommitTimeout: 2200,
55
+ pendingRequestTimeout: 10_000, // Deadline for a pending V1 response (ms), including health checks
56
+ txCommitTimeout: 5_000, // Wait for local append and publication proof (ms); shorter than the response deadline
57
57
  txPoolSize: 1000, // size of transaction pool
58
58
  validatorHealthCheckInterval: 5 * 60 * 1000, // How often to check validator health (ms)
59
+ validatorHealthCheckFailureThreshold: 3, // Consecutive failed health checks before a validator is removed
59
60
  storesDirectory: 'stores/',
60
61
  storeName: 'testnet',
61
62
  },
@@ -101,10 +102,11 @@ const configData = {
101
102
  rateLimitConnectionTimeoutMs: 60_000, // Rate limiting constants
102
103
  rateLimitMaxTransactionsPerSecond: 50, // Rate limiting constants
103
104
  maxPendingRequestsInPendingRequestsService: 50_000, // Maximum number of pending requests in PendingRequestService (This value should not exceed 256MB)
104
- pendingRequestTimeout: 3000, // constant after which time the transaction will be considered invalid
105
- txCommitTimeout: 2200,
105
+ pendingRequestTimeout: 10_000, // Deadline for a pending V1 response (ms), including health checks
106
+ txCommitTimeout: 5_000, // Wait for local append and publication proof (ms); shorter than the response deadline
106
107
  txPoolSize: 1000, // size of transaction pool
107
108
  validatorHealthCheckInterval: 5 * 60 * 1000, // How often to check validator health (ms)
109
+ validatorHealthCheckFailureThreshold: 3, // Consecutive failed health checks before a validator is removed
108
110
  storesDirectory: 'stores/',
109
111
  storeName: 'mainnet',
110
112
  },
@@ -150,10 +152,11 @@ const configData = {
150
152
  rateLimitConnectionTimeoutMs: 60_000, // Rate limiting constants
151
153
  rateLimitMaxTransactionsPerSecond: 50, // Rate limiting constants
152
154
  maxPendingRequestsInPendingRequestsService: 50_000, // Maximum number of pending requests in PendingRequestService (This value should not exceed 256MB)
153
- pendingRequestTimeout: 3000, // constant after which time the transaction will be considered invalid
154
- txCommitTimeout: 2200,
155
+ pendingRequestTimeout: 10_000, // Deadline for a pending V1 response (ms), including health checks
156
+ txCommitTimeout: 5_000, // Wait for local append and publication proof (ms); shorter than the response deadline
155
157
  txPoolSize: 1000, // size of transaction pool
156
158
  validatorHealthCheckInterval: 1_000, // How often to check validator health (ms)
159
+ validatorHealthCheckFailureThreshold: 3, // Consecutive failed health checks before a validator is removed
157
160
  storesDirectory: 'stores/',
158
161
  storeName: 'development',
159
162
  }
@@ -161,4 +164,4 @@ const configData = {
161
164
 
162
165
  export const createConfig = (environment, options) => {
163
166
  return new Config(options, configData[environment])
164
- }
167
+ }
@@ -263,7 +263,10 @@ class Network extends ReadyResource {
263
263
  new Error('Connection closed before response')
264
264
  );
265
265
  this.#swarm?.leavePeer(connection.remotePublicKey);
266
- this.#validatorConnectionManager.remove(publicKey);
266
+ // only act on the connection this event belongs to
267
+ if (this.#validatorConnectionManager.isCurrent(publicKey, connection)) {
268
+ this.#validatorConnectionManager.remove(publicKey);
269
+ }
267
270
  if (connection.protocolSession) {
268
271
  try {
269
272
  connection.protocolSession.close();
@@ -350,7 +353,8 @@ class Network extends ReadyResource {
350
353
  this.#pendingConnections.set(publicKey, { type, timeoutId });
351
354
 
352
355
  const target = b4a.from(publicKey, 'hex');
353
- if (!this.#swarm.peers.has(publicKey)) {
356
+ // A retained peer may have lost explicit retries after leavePeer().
357
+ if (!this.#swarm.peers.get(publicKey)?.explicit) {
354
358
  this.#swarm.joinPeer(target);
355
359
  }
356
360
 
@@ -6,6 +6,8 @@ import {Logger} from "../../../utils/logger.js";
6
6
  * @typedef {import('hyperswarm').Connection} Connection
7
7
  */
8
8
 
9
+ const DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD = 3;
10
+
9
11
  export class ConnectionManagerError extends Error {
10
12
  constructor(message) {
11
13
  super(message);
@@ -18,6 +20,8 @@ class ConnectionManager {
18
20
  #maxValidators
19
21
  #config
20
22
  #healthCheckService
23
+ #healthCheckFailures
24
+ #healthCheckFailureThreshold
21
25
  #boundedHealthCheckHandler
22
26
  #logger
23
27
  // Note: #validators is using publicKey (Buffer) as key
@@ -28,8 +32,11 @@ class ConnectionManager {
28
32
  **/
29
33
  constructor(config) {
30
34
  this.#validators = new Map();
35
+ // consecutive failed health checks per validator
36
+ this.#healthCheckFailures = new Map();
31
37
  this.#config = config
32
38
  this.#maxValidators = config.maxValidators
39
+ this.#healthCheckFailureThreshold = this.#resolveHealthCheckFailureThreshold(config);
33
40
  this.#boundedHealthCheckHandler = this.#healthCheckHandler.bind(this);
34
41
  this.#logger = new Logger(config)
35
42
  }
@@ -98,10 +105,15 @@ class ConnectionManager {
98
105
  }
99
106
 
100
107
  if (!success) {
101
- this.#logger.debug(`healthCheck: liveness request failed, removing validator. Address = ${targetAddress}; Request ID = ${requestId}`);
102
- this.remove(publicKey);
103
- this.#stopHealthCheck(publicKey);
108
+ const failures = this.#countHealthCheckFailure(publicKey);
109
+ this.#logger.debug(`healthCheck: liveness request failed. Address = ${targetAddress}; Request ID = ${requestId}; Consecutive failures = ${failures}`);
110
+ if (failures >= this.#healthCheckFailureThreshold) {
111
+ this.#logger.debug(`healthCheck: failure threshold reached, removing validator. Address = ${targetAddress}; Request ID = ${requestId}`);
112
+ this.remove(publicKey);
113
+ this.#stopHealthCheck(publicKey);
114
+ }
104
115
  } else {
116
+ this.#clearHealthCheckFailures(publicKey);
105
117
  this.#logger.debug(`healthCheck: success. Address = ${targetAddress}; Request ID = ${requestId}`);
106
118
  }
107
119
  };
@@ -123,6 +135,23 @@ class ConnectionManager {
123
135
  }
124
136
  }
125
137
 
138
+ #resolveHealthCheckFailureThreshold(config) {
139
+ const threshold = Number(config.validatorHealthCheckFailureThreshold);
140
+ if (!Number.isInteger(threshold) || threshold < 1) return DEFAULT_HEALTH_CHECK_FAILURE_THRESHOLD;
141
+ return threshold;
142
+ }
143
+
144
+ #countHealthCheckFailure(publicKey) {
145
+ const publicKeyHex = this.#toHexString(publicKey);
146
+ const failures = (this.#healthCheckFailures.get(publicKeyHex) || 0) + 1;
147
+ this.#healthCheckFailures.set(publicKeyHex, failures);
148
+ return failures;
149
+ }
150
+
151
+ #clearHealthCheckFailures(publicKey) {
152
+ this.#healthCheckFailures.delete(this.#toHexString(publicKey));
153
+ }
154
+
126
155
  /**
127
156
  * Retrieves the Hyperswarm connection object for a given validator public key.
128
157
  * @param {String | Buffer} publicKey - The public key (Buffer or hex string) of the validator.
@@ -134,6 +163,18 @@ class ConnectionManager {
134
163
  return entry ? entry.connection : undefined;
135
164
  }
136
165
 
166
+ /**
167
+ * Checks whether a connection is the one currently tracked for a validator.
168
+ * @param {String | Buffer} publicKey - The public key (Buffer or hex string) of the validator.
169
+ * @param {Object} connection - The connection object to compare with the tracked one.
170
+ * @returns {Boolean} - Returns true if the tracked connection is the given connection, false otherwise.
171
+ */
172
+ isCurrent(publicKey, connection) {
173
+ const publicKeyHex = this.#toHexString(publicKey);
174
+ const entry = this.#validators.get(publicKeyHex);
175
+ return !!entry && entry.connection === connection;
176
+ }
177
+
137
178
  /**
138
179
  * Sends a message through a specific validator without increasing sent messages count.
139
180
  * @param {Object} message - The message to send to the validator.
@@ -194,6 +235,7 @@ class ConnectionManager {
194
235
  this.#logger.debug(`remove: removing validator ${publicKeyToAddress(publicKey, this.#config)}`);
195
236
  const publicKeyHex = this.#toHexString(publicKey);
196
237
  this.#stopHealthCheck(publicKeyHex);
238
+ this.#clearHealthCheckFailures(publicKeyHex);
197
239
  if (this.exists(publicKeyHex)) {
198
240
  const entry = this.#validators.get(publicKeyHex);
199
241
  if (endConnection && entry && entry.connection && typeof entry.connection.end === 'function') {
@@ -326,8 +368,11 @@ class ConnectionManager {
326
368
  return;
327
369
  }
328
370
  this.#validators.set(publicKeyHex, {connection, sent: 0});
371
+ this.#clearHealthCheckFailures(publicKeyHex);
329
372
  connection.on('close', () => {
330
373
  this.#logger.debug(`#append: connection closing for validator ${publicKeyToAddress(publicKey, this.#config)}`);
374
+ // only act on the connection this event belongs to
375
+ if (!this.isCurrent(publicKeyHex, connection)) return;
331
376
  this.remove(publicKeyHex);
332
377
  this.#logger.debug(`#append: connection closed for validator ${publicKeyToAddress(publicKey, this.#config)}`);
333
378
  });
@@ -350,6 +395,7 @@ class ConnectionManager {
350
395
  } else {
351
396
  this.#validators.set(publicKeyHex, {connection, sent: 0});
352
397
  }
398
+ this.#clearHealthCheckFailures(publicKeyHex);
353
399
  }
354
400
 
355
401
  #toHexString(publicKey) {
@@ -122,7 +122,7 @@ class ValidatorObserverService {
122
122
  *
123
123
  * If the count of fully connected writers exceeds the threshold:
124
124
  * - The admin node is excluded from the validator pool (handled in selection phase).
125
- * - Non-admin nodes actively disconnect from the admin validator (if currently connected).
125
+ * - Non-admin nodes detach the admin from the validator pool, preserving replication.
126
126
  *
127
127
  * NOTE:
128
128
  * The admin is not forcibly disconnected from all roles.
@@ -144,7 +144,7 @@ class ValidatorObserverService {
144
144
  // Non-admin nodes: ensure the admin is not kept as a validator connection
145
145
  const adminWriter = writers.find((w) => w.address === adminEntry?.address);
146
146
  if (adminWriter?.publicKey) {
147
- this.#network.validatorConnectionManager.remove(adminWriter.publicKey);
147
+ this.#network.validatorConnectionManager.remove(adminWriter.publicKey, { endConnection: false });
148
148
  }
149
149
  }
150
150
 
@@ -156,7 +156,8 @@ class ValidatorObserverService {
156
156
  const manager = this.#network.validatorConnectionManager;
157
157
 
158
158
  if (manager.connected(publicKey)) {
159
- manager.remove(publicKey);
159
+ // leave the transport to the swarm
160
+ manager.remove(publicKey, { endConnection: false });
160
161
  this.#logger.debug(`Removed stale validator connection: ${b4a.toString(publicKey, "hex")}`);
161
162
  }
162
163
  }
@@ -373,8 +374,8 @@ class ValidatorObserverService {
373
374
  * Rules:
374
375
  * - Skips invalid/malformed entries.
375
376
  * - Skips non-admin indexers.
376
- * - Deduplicates nodes by mapping their latest state to a unique address.
377
- * - Removes stale connections for nodes flagged with 'isRemoved'.
377
+ * - Deduplicates active writers by wallet address.
378
+ * - Removes stale connections only when no active writer remains for an address.
378
379
  * - Includes only valid and active writer nodes.
379
380
  * @param {Object} adminEntry - The current admin node entry.
380
381
  * @returns {Promise<Array<Object>>} A promise resolving to the deduplicated array of active writers.
@@ -385,13 +386,13 @@ class ValidatorObserverService {
385
386
  this.#keyDecodeCache.clear();
386
387
  }
387
388
 
388
- // Map is used for deduplication, ensuring that each node physical address
389
- // occupies only a single slot despite the append-only nature of Autobase
389
+ // Historical writer keys can share an address with an active replacement.
390
390
  const activeMap = new Map();
391
+ const removedMap = new Map();
391
392
 
392
393
  try {
393
394
  for await (const { key, value } of this.#state.base.system.list()) {
394
- if (!this.#shouldRun()) break;
395
+ if (!this.#shouldRun()) return Array.from(activeMap.values());
395
396
 
396
397
  if (!key || b4a.byteLength(key) !== WRITER_BYTE_LENGTH) continue;
397
398
  if (!value) continue;
@@ -426,16 +427,20 @@ class ValidatorObserverService {
426
427
 
427
428
  if (value.isIndexer && addr !== adminEntry?.address) continue;
428
429
 
429
- // Handles soft-delete: drops stale connections and removes the node from the pool
430
430
  if (value.isRemoved) {
431
- this.#enforceNoStaleConnections(publicKey);
432
- activeMap.delete(addr);
431
+ removedMap.set(addr, publicKey);
433
432
  continue;
434
433
  }
435
434
 
436
- // Registers or overwrites to maintain a single source of truth per machine
437
435
  activeMap.set(addr, { key, address: addr, publicKey, publicKeyHex });
438
436
  }
437
+
438
+ // Writer iteration order does not represent key age. Only a complete scan
439
+ // can establish that a removed writer has no active replacement.
440
+ for (const [addr, publicKey] of removedMap) {
441
+ if (!this.#shouldRun()) break;
442
+ if (!activeMap.has(addr)) this.#enforceNoStaleConnections(publicKey);
443
+ }
439
444
  } catch (err) {
440
445
  this.#logger.error(`Autobase writer scan error: ${err.message}`);
441
446
  }
@@ -462,4 +467,4 @@ class ValidatorObserverService {
462
467
  }
463
468
  }
464
469
 
465
- export default ValidatorObserverService;
470
+ export default ValidatorObserverService;
@@ -15,7 +15,7 @@ import {
15
15
  TRAC_NAMESPACE,
16
16
  CustomEventType
17
17
  } from '../../utils/constants.js';
18
- import { isHexString, sleep, isTransactionRecordPut } from '../../utils/helpers.js';
18
+ import { isHexString, sleep } from '../../utils/helpers.js';
19
19
  import tracCryptoApi from 'trac-crypto-api';
20
20
  import Check from '../../utils/check.js';
21
21
  import { safeDecodeApplyOperation } from '../../utils/protobuf/operationHelpers.js';
@@ -372,18 +372,13 @@ class State extends ReadyResource {
372
372
  throw new Error("Invalid hash format");
373
373
  }
374
374
 
375
- const confirmedLength = this.getSignedLength();
376
- const historyStream = this.#base.view.createHistoryStream({
377
- gte: 0,
378
- lte: confirmedLength
379
- });
380
-
381
- for await (const entry of historyStream) {
382
- if (isTransactionRecordPut(entry) && entry.key === hash) {
383
- return entry.seq;
384
- }
375
+ const snapshot = this.#base.view.checkout(this.getSignedLength());
376
+ try {
377
+ const entry = await snapshot.get(hash);
378
+ return entry ? entry.seq : null;
379
+ } finally {
380
+ await snapshot.close();
385
381
  }
386
- return null;
387
382
  }
388
383
 
389
384
  async confirmedTransactionsBetween(startSignedLength, endSignedLength) {