trac-msb 0.2.26 → 0.3.0-beta.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.
Files changed (136) hide show
  1. package/SECURITY.md +2 -2
  2. package/cli/commandHandler.js +317 -20
  3. package/cli/handlers.js +9 -1
  4. package/cli/interactive.js +4 -3
  5. package/cli/tokenSupplyAudit.js +90 -0
  6. package/msb.mjs +1 -0
  7. package/package.json +15 -6
  8. package/rpc/create_server.js +12 -3
  9. package/rpc/handlers.js +103 -0
  10. package/rpc/routes/v1.js +8 -0
  11. package/src/codecs/apply/applyOperationCodec.js +334 -0
  12. package/src/codecs/apply/applyOperations.generated.cjs +6380 -0
  13. package/src/codecs/consensus/v1/consensusV1.generated.cjs +3508 -0
  14. package/src/codecs/consensus/v1/consensusV1OperationCodec.js +161 -0
  15. package/src/codecs/consensus/v1/vdfConfigCodec.js +83 -0
  16. package/src/{utils/protobuf → codecs/network/v1}/networkV1.generated.cjs +664 -393
  17. package/src/codecs/network/v1/networkV1OperationCodec.js +25 -0
  18. package/src/codecs/protobufRuntime.cjs +95 -0
  19. package/src/codecs/state/escrowEntryCodec.js +45 -0
  20. package/src/codecs/state/state.generated.cjs +659 -0
  21. package/src/config/args.js +0 -1
  22. package/src/config/config.js +41 -7
  23. package/src/config/env.js +33 -9
  24. package/src/core/consensus/epochAppenderSelection/EpochAppenderSelectionError.js +55 -0
  25. package/src/core/consensus/epochAppenderSelection/EpochAppenderSelectionOperations.js +118 -0
  26. package/src/core/consensus/epochAppenderSelection/EpochAppenderSelector.js +1042 -0
  27. package/src/core/consensus/epochAppenderSelection/createEpochAppenderSelector.js +23 -0
  28. package/src/core/consensus/epochAppenderSelection/epochAppenderSelectionLedgerReader.js +274 -0
  29. package/src/core/consensus/epochAppenderSelection/epochAppenderSelectionState.js +488 -0
  30. package/src/core/consensus/epochAppenderSelection/epochAppenderSelectionStorage.js +93 -0
  31. package/src/core/consensus/quorum.js +11 -0
  32. package/src/core/consensus/services/EpochCoordinationRound.js +839 -0
  33. package/src/core/consensus/services/EpochCoordinatorOperations.js +211 -0
  34. package/src/core/consensus/services/EpochCoordinatorService.js +335 -0
  35. package/src/core/consensus/services/EpochRoundListeners.js +116 -0
  36. package/src/core/consensus/services/EpochStateMachine.js +141 -0
  37. package/src/core/consensus/services/VDFBare.js +291 -0
  38. package/src/core/consensus/services/VDFNode.js +111 -0
  39. package/src/core/consensus/services/VDFProgressWatchdog.js +79 -0
  40. package/src/core/consensus/services/VDFService.js +87 -0
  41. package/src/core/consensus/services/VDFServiceManager.js +147 -0
  42. package/src/core/consensus/services/createVDFService.js +16 -0
  43. package/src/core/consensus/services/vdf-guardian.js +32 -0
  44. package/src/core/consensus/services/vdf-worker.js +90 -0
  45. package/src/core/network/Network.js +327 -238
  46. package/src/core/network/protocols/consensus/IndexerMessages.js +72 -0
  47. package/src/core/network/protocols/consensus/v1/ConsensusV1ConnectionPolicy.js +57 -0
  48. package/src/core/network/protocols/consensus/v1/ConsensusV1MessageRouter.js +147 -0
  49. package/src/core/network/protocols/consensus/v1/ConsensusV1Protocol.js +72 -0
  50. package/src/core/network/protocols/consensus/v1/IndexerPendingRequestService.js +108 -0
  51. package/src/core/network/protocols/consensus/v1/V1ConsensusProtocolError.js +29 -0
  52. package/src/core/network/protocols/consensus/v1/handlers/V1EpochAppendOperationHandler.js +68 -0
  53. package/src/core/network/protocols/consensus/v1/handlers/V1EpochProofProposalOperationHandler.js +226 -0
  54. package/src/core/network/protocols/consensus/v1/validators/ConsensusValidationSchema.js +192 -0
  55. package/src/core/network/protocols/consensus/v1/validators/V1BaseConsensusOperation.js +428 -0
  56. package/src/core/network/protocols/consensus/v1/validators/V1EpochAppendReply.js +47 -0
  57. package/src/core/network/protocols/consensus/v1/validators/V1EpochAppendRequest.js +17 -0
  58. package/src/core/network/protocols/consensus/v1/validators/V1EpochProofProposalApproval.js +116 -0
  59. package/src/core/network/protocols/consensus/v1/validators/V1EpochProofProposalRequest.js +152 -0
  60. package/src/core/network/protocols/consensus/v1/validators/consensusValidationSchemaHelpers.js +145 -0
  61. package/src/core/network/protocols/shared/ConnectionOperationHandler.js +27 -0
  62. package/src/core/network/protocols/{NetworkMessages.js → validators/ValidatorMessages.js} +24 -14
  63. package/src/core/network/protocols/{ProtocolSession.js → validators/ValidatorProtocolSession.js} +22 -25
  64. package/src/core/network/protocols/{legacy/NetworkMessageRouter.js → validators/legacy/ValidatorLegacyMessageRouter.js} +6 -6
  65. package/src/core/network/protocols/{LegacyProtocol.js → validators/legacy/ValidatorLegacyProtocol.js} +9 -7
  66. package/src/core/network/protocols/{legacy → validators/legacy}/handlers/BaseStateOperationHandler.js +1 -1
  67. package/src/core/network/protocols/{legacy → validators/legacy}/handlers/LegacyGetRequestHandler.js +5 -5
  68. package/src/core/network/protocols/{legacy → validators/legacy}/handlers/LegacyRoleOperationHandler.js +5 -5
  69. package/src/core/network/protocols/{legacy → validators/legacy}/handlers/LegacySubnetworkOperationHandler.js +5 -5
  70. package/src/core/network/protocols/{legacy → validators/legacy}/handlers/LegacyTransferOperationHandler.js +5 -5
  71. package/src/core/network/protocols/{ProtocolInterface.js → validators/shared/ValidatorProtocolInterface.js} +5 -5
  72. package/src/core/network/protocols/{shared → validators/shared}/validators/PartialBootstrapDeploymentValidator.js +1 -1
  73. package/src/core/network/protocols/validators/shared/validators/PartialHtlcValidator.js +325 -0
  74. package/src/core/network/protocols/{shared → validators/shared}/validators/PartialOperationValidator.js +44 -21
  75. package/src/core/network/protocols/{shared → validators/shared}/validators/PartialRoleAccessValidator.js +3 -3
  76. package/src/core/network/protocols/{shared → validators/shared}/validators/PartialTransactionValidator.js +3 -3
  77. package/src/core/network/protocols/{shared → validators/shared}/validators/PartialTransferValidator.js +4 -4
  78. package/src/core/network/protocols/{v1 → validators/v1}/V1ProtocolError.js +1 -1
  79. package/src/core/network/{services/PendingRequestService.js → protocols/validators/v1/ValidatorPendingRequestService.js} +19 -82
  80. package/src/core/network/protocols/{v1/NetworkMessageRouter.js → validators/v1/ValidatorV1MessageRouter.js} +9 -9
  81. package/src/core/network/protocols/{V1Protocol.js → validators/v1/ValidatorV1Protocol.js} +10 -8
  82. package/src/core/network/protocols/{connectionPolicies.js → validators/v1/connectionPolicies.js} +1 -1
  83. package/src/core/network/protocols/{v1 → validators/v1}/handlers/V1BaseOperationHandler.js +6 -23
  84. package/src/core/network/protocols/{v1 → validators/v1}/handlers/V1BroadcastTransactionOperationHandler.js +14 -9
  85. package/src/core/network/protocols/{v1 → validators/v1}/handlers/V1LivenessOperationHandler.js +5 -4
  86. package/src/core/network/protocols/{v1 → validators/v1}/handlers/broadcastTransaction/BroadcastTransactionErrorMapper.js +3 -3
  87. package/src/core/network/protocols/{v1 → validators/v1}/handlers/broadcastTransaction/BroadcastTransactionOperationStrategies.js +44 -1
  88. package/src/core/network/protocols/{v1 → validators/v1}/handlers/broadcastTransaction/BroadcastTransactionPayloadUtils.js +1 -1
  89. package/src/core/network/protocols/{v1 → validators/v1}/validators/V1BaseOperation.js +19 -15
  90. package/src/core/network/protocols/{v1 → validators/v1}/validators/V1BroadcastTransactionRequest.js +2 -2
  91. package/src/core/network/protocols/{v1 → validators/v1}/validators/V1BroadcastTransactionResponse.js +34 -18
  92. package/src/core/network/protocols/{v1 → validators/v1}/validators/V1ValidationSchema.js +3 -3
  93. package/src/core/network/services/IndexerConnectionManager.js +88 -0
  94. package/src/core/network/services/TransactionPoolService.js +11 -18
  95. package/src/core/network/services/TransactionRateLimiterService.js +1 -1
  96. package/src/core/network/services/ValidatorConnectionManager.js +163 -0
  97. package/src/core/network/services/{MessageOrchestrator.js → ValidatorMessageOrchestrator.js} +34 -42
  98. package/src/core/network/services/ValidatorObserverService.js +21 -36
  99. package/src/core/network/shared/BasePendingRequestService.js +67 -0
  100. package/src/core/network/shared/PeerConnectionManager.js +185 -0
  101. package/src/core/state/State.js +1789 -173
  102. package/src/core/state/StateEventQueue.js +69 -0
  103. package/src/core/state/utils/address.js +10 -6
  104. package/src/core/state/utils/epochProof.js +109 -0
  105. package/src/core/state/utils/escrowEntry.js +178 -0
  106. package/src/core/state/validators/StateValidationSchema.js +757 -0
  107. package/src/core/state/validators/stateValidationRules.js +312 -0
  108. package/src/index.js +255 -65
  109. package/src/messages/consensus/v1/ConsensusMessageBuilder.js +368 -0
  110. package/src/messages/consensus/v1/ConsensusMessageDirector.js +142 -0
  111. package/src/messages/consensus/v1/consensusMessageFactory.js +12 -0
  112. package/src/messages/consensus/v1/epochAppendMessage.js +58 -0
  113. package/src/messages/network/v1/NetworkMessageBuilder.js +15 -7
  114. package/src/messages/state/ApplyStateMessageBuilder.js +499 -13
  115. package/src/messages/state/ApplyStateMessageDirector.js +261 -2
  116. package/src/utils/StateMachine.js +125 -0
  117. package/src/utils/applyOperations.js +45 -2
  118. package/src/utils/buffer.js +239 -59
  119. package/src/utils/consensusConfig.js +76 -0
  120. package/src/utils/constants.js +174 -73
  121. package/src/utils/helpers.js +0 -25
  122. package/src/utils/htlcLock.js +122 -0
  123. package/src/utils/logger.js +10 -4
  124. package/src/utils/migrationUtils.js +2 -1
  125. package/src/utils/normalizers.js +113 -0
  126. package/src/utils/scheduler/SchedulableService.js +107 -0
  127. package/src/utils/{Scheduler.js → scheduler/Scheduler.js} +56 -17
  128. package/src/core/network/services/ConnectionManager.js +0 -406
  129. package/src/utils/check.js +0 -525
  130. package/src/utils/protobuf/applyOperations.cjs +0 -1373
  131. package/src/utils/protobuf/operationHelpers.js +0 -81
  132. /package/src/core/network/protocols/{legacy → validators/legacy}/handlers/LegacyResponseHandler.js +0 -0
  133. /package/src/core/network/protocols/{legacy → validators/legacy}/validators/ValidatorResponse.js +0 -0
  134. /package/src/core/network/protocols/{legacy → validators/legacy}/validators/base/BaseResponse.js +0 -0
  135. /package/src/core/network/protocols/{v1 → validators/v1}/validators/V1LivenessRequest.js +0 -0
  136. /package/src/core/network/protocols/{v1 → validators/v1}/validators/V1LivenessResponse.js +0 -0
@@ -0,0 +1,488 @@
1
+ /**
2
+ * Defines the records used to select an appender. Field names are part of the protocol and local
3
+ * storage format. Copy helpers keep changes to one record from affecting another.
4
+ *
5
+ * @typedef {EpochAppendRequest & {
6
+ * voteStatus: number, observedCounter: bigint, vote: EpochAppendRequest
7
+ * }} EpochAppendReply Validated REPLY containing the peer's saved vote.
8
+ *
9
+ * @typedef {object} EpochSelectionRequestLifecycle
10
+ * @property {function(): boolean} isActive Checks whether this election is still waiting for replies.
11
+ * @property {function(function(): void): void} onPending Registers a callback that cancels the pending request.
12
+ */
13
+ import { EpochAppenderSelectionError, EpochAppenderSelectionErrorCode } from './EpochAppenderSelectionError.js';
14
+ import {
15
+ EPOCH_APPENDER_SELECTION_MAX_COUNTER,
16
+ EPOCH_APPENDER_SELECTION_COUNTER_LIMIT,
17
+ CONSENSUS_REQUEST_ID_MAX_LENGTH,
18
+ UINT16_MAX,
19
+ HASH_HEX_LENGTH,
20
+ PEER_PUBLIC_KEY_HEX_LENGTH,
21
+ } from '../../../utils/constants.js';
22
+ import { isHexString } from '../../../utils/helpers.js';
23
+
24
+ const SelectionStateVersion = Object.freeze({
25
+ LEGACY: 1,
26
+ DURABLE_VOTES: 2,
27
+ EPOCH_RECOVERY: 3,
28
+ });
29
+ const SELECTION_STATE_VERSION = SelectionStateVersion.EPOCH_RECOVERY;
30
+ export const EPOCH_APPENDER_SELECTION_VERSION = 2;
31
+ export const TWO_INDEXER_COHORT_SIZE = 2;
32
+
33
+ /** Reply values for a granted vote, a busy voter or an old election term. */
34
+ export const EpochAppenderVoteStatus = Object.freeze({
35
+ GRANTED: 1,
36
+ BUSY: 2,
37
+ STALE: 3,
38
+ });
39
+
40
+ /**
41
+ * Returns the number of votes needed to select an appender, including the local vote. Two indexers
42
+ * first require both votes. The selector handles the fallback when a peer is silent.
43
+ *
44
+ * @param {number} memberCount Number of indexers in the signed election context.
45
+ * @returns {number} Number of votes before applying the fallback for two indexers.
46
+ */
47
+ export function calculateAppenderVoteQuorum(memberCount) {
48
+ return memberCount <= TWO_INDEXER_COHORT_SIZE ? memberCount : Math.floor(memberCount / 2) + 1;
49
+ }
50
+
51
+ /** Selection phases stored locally. These names also support reading older records. */
52
+ export const EpochAppenderSelectionPhase = Object.freeze({
53
+ REQUESTING: 'REQUESTING',
54
+ HELD: 'HELD',
55
+ PUBLISHING: 'PUBLISHING',
56
+ });
57
+
58
+ /** Append results and selection expiry. UNKNOWN and SELECTION_EXPIRED do not prove cancellation. */
59
+ export const EpochAppendOutcome = Object.freeze({
60
+ UNKNOWN: 'UNKNOWN',
61
+ EPOCH_ADVANCED: 'EPOCH_ADVANCED',
62
+ REJECTED: 'REJECTED',
63
+ SELECTION_EXPIRED: 'SELECTION_EXPIRED',
64
+ });
65
+
66
+ /**
67
+ * @typedef {object} EpochAppenderSelectionMember
68
+ * @property {string} writerKey Writer key as lowercase hex.
69
+ * @property {string} publicKey Authenticated wallet public key as lowercase hex.
70
+ *
71
+ * @typedef {object} EpochAppenderSelectionContext
72
+ * @property {number} networkId Network that this election belongs to.
73
+ * @property {string} bootstrap Autobase bootstrap key, encoded as lowercase hex.
74
+ * @property {string} targetEpoch Epoch number stored as a decimal string and converted to BigInt for calculations.
75
+ * @property {string} parentHash Hash of the signed parent epoch record.
76
+ * @property {string} membershipContext Hash of the signed voters for this election.
77
+ * @property {string} configHash Hash of the signed consensus configuration bytes.
78
+ * @property {EpochAppenderSelectionMember[]} members Signed indexer list checked by this node.
79
+ * @property {string[]} [currentWriterKeys] Current signed writer keys. Incoming requests do not include this field.
80
+ *
81
+ * @typedef {object} EpochSelectionIdentity
82
+ * @property {string} requestId ID shared by all retries of this election request.
83
+ * @property {string} payloadHash Hash of the exact SET_EPOCH bytes reserved by the candidate.
84
+ * @property {bigint} logicalCounter Election term. New terms follow the saved voting history.
85
+ * @property {string} requester Candidate's authenticated wallet public key, encoded as hex.
86
+ * @property {number} selectionVersion Voting protocol version.
87
+ *
88
+ * @typedef {EpochAppenderSelectionContext & EpochSelectionIdentity} EpochAppendRequest
89
+ *
90
+ * @typedef {object} EpochAppendPublication
91
+ * @property {string} writerKey Local writer key as lowercase hex.
92
+ * @property {number} from First possible block index for this append in the local writer log. Starts at zero.
93
+ * @property {string} payloadHash Hash identifying the exact operation in that writer's log.
94
+ * @property {string} targetEpoch Target epoch stored as a decimal string.
95
+ *
96
+ * @typedef {object} EpochAppenderSelectionReservation
97
+ * @property {EpochAppendRequest} request Selection request that reserves this payload.
98
+ * @property {string} payload Exact operation bytes encoded as hex.
99
+ * @property {string} phase One of EpochAppenderSelectionPhase.
100
+ * @property {EpochAppendPublication|null} publication Append details saved when the phase becomes PUBLISHING.
101
+ * @property {number} expiresAt Local deadline for starting another election. Expiry does not cancel the append.
102
+ * @property {boolean} solo True if this node continues alone after the only peer fails to reply.
103
+ *
104
+ * @typedef {object} EpochAppendOutcomeResult
105
+ * @property {string} status One of EpochAppendOutcome. UNKNOWN and SELECTION_EXPIRED do not prove rejection.
106
+ */
107
+
108
+ /**
109
+ * Checks whether both contexts refer to the same election. The resolved member lists are not compared
110
+ * here.
111
+ *
112
+ * @param {EpochAppenderSelectionContext|null} leftContext First signed election context.
113
+ * @param {EpochAppenderSelectionContext|null} rightContext Second signed election context.
114
+ * @returns {boolean} False if either context is missing.
115
+ */
116
+ export function sameSelectionContext(leftContext, rightContext) {
117
+ if (!leftContext || !rightContext) {
118
+ return false;
119
+ }
120
+ return leftContext.networkId === rightContext.networkId &&
121
+ leftContext.bootstrap === rightContext.bootstrap &&
122
+ leftContext.targetEpoch === rightContext.targetEpoch &&
123
+ leftContext.parentHash === rightContext.parentHash &&
124
+ leftContext.membershipContext === rightContext.membershipContext &&
125
+ leftContext.configHash === rightContext.configHash;
126
+ }
127
+
128
+ /**
129
+ * Checks whether both requests have the same context, candidate, term and payload.
130
+ *
131
+ * @param {EpochAppendRequest|null} leftRequest First election request.
132
+ * @param {EpochAppendRequest|null} rightRequest Second election request.
133
+ * @returns {boolean}
134
+ */
135
+ export function sameSelectionRequest(leftRequest, rightRequest) {
136
+ return sameSelectionContext(leftRequest, rightRequest) &&
137
+ leftRequest.requestId === rightRequest.requestId &&
138
+ leftRequest.payloadHash === rightRequest.payloadHash &&
139
+ leftRequest.logicalCounter === rightRequest.logicalCounter &&
140
+ leftRequest.requester === rightRequest.requester &&
141
+ leftRequest.selectionVersion === rightRequest.selectionVersion;
142
+ }
143
+
144
+ /**
145
+ * Checks whether both lists contain the same writer keys in the same order. Two missing lists are
146
+ * treated as equal.
147
+ *
148
+ * @param {string[]|undefined} leftKeys First sorted writer list.
149
+ * @param {string[]|undefined} rightKeys Second sorted writer list.
150
+ * @returns {boolean}
151
+ */
152
+ export function sameWriterKeys(leftKeys, rightKeys) {
153
+ if (!leftKeys || !rightKeys) {
154
+ return leftKeys === rightKeys;
155
+ }
156
+ return leftKeys.length === rightKeys.length &&
157
+ leftKeys.every((writerKey, index) => writerKey === rightKeys[index]);
158
+ }
159
+
160
+ /**
161
+ * Copies the election context, including its member objects and writer keys.
162
+ *
163
+ * @param {EpochAppenderSelectionContext} context Source context.
164
+ * @returns {EpochAppenderSelectionContext}
165
+ */
166
+ export function copySelectionContext(context) {
167
+ const snapshot = {
168
+ networkId: context.networkId,
169
+ bootstrap: context.bootstrap,
170
+ targetEpoch: context.targetEpoch,
171
+ parentHash: context.parentHash,
172
+ membershipContext: context.membershipContext,
173
+ configHash: context.configHash,
174
+ members: context.members.map(member => ({
175
+ writerKey: member.writerKey,
176
+ publicKey: member.publicKey,
177
+ })),
178
+ };
179
+ if (context.currentWriterKeys) {
180
+ snapshot.currentWriterKeys = [...context.currentWriterKeys];
181
+ }
182
+ return snapshot;
183
+ }
184
+
185
+ /**
186
+ * Copies the request fields and the locally resolved member list.
187
+ *
188
+ * @param {EpochAppendRequest} request Source request.
189
+ * @returns {EpochAppendRequest}
190
+ */
191
+ export function copySelectionRequest(request) {
192
+ return {
193
+ ...copySelectionContext(request),
194
+ requestId: request.requestId,
195
+ payloadHash: request.payloadHash,
196
+ logicalCounter: request.logicalCounter,
197
+ requester: request.requester,
198
+ selectionVersion: request.selectionVersion,
199
+ };
200
+ }
201
+
202
+ /**
203
+ * Copies the details used to find this append in the local writer log.
204
+ *
205
+ * @param {EpochAppendPublication} publication Append details to copy.
206
+ * @returns {EpochAppendPublication}
207
+ */
208
+ export function copyAppendPublication(publication) {
209
+ return {
210
+ writerKey: publication.writerKey,
211
+ from: publication.from,
212
+ payloadHash: publication.payloadHash,
213
+ targetEpoch: publication.targetEpoch,
214
+ };
215
+ }
216
+
217
+ /**
218
+ * Copies the local reservation and its append details. Nested objects are copied too.
219
+ *
220
+ * @param {EpochAppenderSelectionReservation|null} reservation Source reservation.
221
+ * @returns {EpochAppenderSelectionReservation|null}
222
+ */
223
+ export function copySelectionReservation(reservation) {
224
+ if (!reservation) {
225
+ return null;
226
+ }
227
+ return {
228
+ request: copySelectionRequest(reservation.request),
229
+ payload: reservation.payload,
230
+ phase: reservation.phase,
231
+ publication: reservation.publication ? copyAppendPublication(reservation.publication) : null,
232
+ expiresAt: reservation.expiresAt,
233
+ solo: reservation.solo ?? false,
234
+ };
235
+ }
236
+
237
+ /**
238
+ * @typedef {object} EpochAppenderSelectionState
239
+ * @property {number} version Local storage format version.
240
+ * @property {bigint} clock Local election counter. Peer hints advance it in bounded steps.
241
+ * @property {string} minimumElectionEpoch Earliest epoch allowed after recovering an exhausted clock.
242
+ * @property {EpochAppenderSelectionReservation|null} active Local selection request or append still waiting for a result.
243
+ * @property {{request: EpochAppendRequest, expiresAt: number}|null} vote Last saved vote. After it expires, another candidate still needs a higher term.
244
+ */
245
+
246
+ /**
247
+ * Creates the initial state before this node starts an election or votes for a peer.
248
+ *
249
+ * @returns {EpochAppenderSelectionState}
250
+ */
251
+ export function createSelectionState() {
252
+ return { version: SELECTION_STATE_VERSION, clock: 0n, minimumElectionEpoch: '0', active: null, vote: null };
253
+ }
254
+
255
+ /**
256
+ * Copies the saved votes and active reservation for storage or validation.
257
+ *
258
+ * @param {EpochAppenderSelectionState} selectionState Local state managed by the selector.
259
+ * @returns {EpochAppenderSelectionState}
260
+ */
261
+ export function copySelectionState(selectionState) {
262
+ return {
263
+ version: selectionState.version,
264
+ clock: selectionState.clock,
265
+ minimumElectionEpoch: selectionState.minimumElectionEpoch ?? '0',
266
+ active: copySelectionReservation(selectionState.active),
267
+ vote: selectionState.vote ? {
268
+ request: copySelectionRequest(selectionState.vote.request),
269
+ expiresAt: selectionState.vote.expiresAt,
270
+ } : null,
271
+ };
272
+ }
273
+
274
+ /**
275
+ * Updates an older record while keeping any pending append. Sets a timeout so that append cannot block
276
+ * new elections forever.
277
+ *
278
+ * @param {EpochAppenderSelectionState} selectionState Validated record in the older or current format.
279
+ * @param {number} nowMs Current local time in milliseconds.
280
+ * @param {number} selectionTimeoutMs Time allowed for a pending append after restart, in milliseconds.
281
+ * @returns {EpochAppenderSelectionState} Copy of the state in the current format.
282
+ */
283
+ export function upgradeSelectionState(selectionState, nowMs, selectionTimeoutMs) {
284
+ const upgradedState = copySelectionState(selectionState);
285
+ if (upgradedState.version === SELECTION_STATE_VERSION) {
286
+ return upgradedState;
287
+ }
288
+ upgradedState.version = SELECTION_STATE_VERSION;
289
+ if (selectionState.version === SelectionStateVersion.LEGACY && upgradedState.active) {
290
+ upgradedState.active.request.selectionVersion = EPOCH_APPENDER_SELECTION_VERSION;
291
+ upgradedState.active.expiresAt = nowMs + selectionTimeoutMs;
292
+ upgradedState.vote = {
293
+ request: copySelectionRequest(upgradedState.active.request),
294
+ expiresAt: upgradedState.active.expiresAt,
295
+ };
296
+ }
297
+ return upgradedState;
298
+ }
299
+
300
+ /**
301
+ * Checks the saved votes and reservations before sending requests or starting an append. Supports both
302
+ * the older and current storage formats.
303
+ *
304
+ * @param {EpochAppenderSelectionState} selectionState Decoded local record.
305
+ * @param {string} localPublicKey Local wallet public key, encoded as hex.
306
+ * @throws {EpochAppenderSelectionError} If the saved state contains invalid or conflicting data.
307
+ */
308
+ export function validateSelectionState(selectionState, localPublicKey) {
309
+ if (
310
+ !selectionState ||
311
+ !Object.values(SelectionStateVersion).includes(selectionState.version) ||
312
+ typeof selectionState.clock !== 'bigint' ||
313
+ selectionState.clock < 0n
314
+ ) {
315
+ throw new EpochAppenderSelectionError(EpochAppenderSelectionErrorCode.SELECTION_STATE_INVALID, 'Invalid epoch appender selection state');
316
+ }
317
+ if (selectionState.clock > EPOCH_APPENDER_SELECTION_MAX_COUNTER) {
318
+ throw new EpochAppenderSelectionError(EpochAppenderSelectionErrorCode.SELECTION_STATE_INVALID, 'Epoch append coordination counter exceeds uint64');
319
+ }
320
+ if (selectionState.version === SELECTION_STATE_VERSION &&
321
+ typeof selectionState.minimumElectionEpoch !== 'string') {
322
+ throw new EpochAppenderSelectionError(EpochAppenderSelectionErrorCode.SELECTION_STATE_INVALID, 'Invalid minimum election epoch');
323
+ }
324
+ if (selectionState.version >= SelectionStateVersion.DURABLE_VOTES && !Object.hasOwn(selectionState, 'vote')) {
325
+ throw new EpochAppenderSelectionError(EpochAppenderSelectionErrorCode.SELECTION_STATE_INVALID, 'Durable appender vote field is missing');
326
+ }
327
+ if (selectionState.version >= SelectionStateVersion.DURABLE_VOTES && selectionState.vote) {
328
+ validateStoredVote(selectionState, localPublicKey);
329
+ }
330
+ if (!selectionState.active) {
331
+ return;
332
+ }
333
+ validateReservation(selectionState.active, localPublicKey, selectionState.clock);
334
+ if (selectionState.version >= SelectionStateVersion.DURABLE_VOTES) {
335
+ validateReservationVote(selectionState);
336
+ }
337
+ if (selectionState.active.phase === EpochAppenderSelectionPhase.PUBLISHING) {
338
+ validatePublication(selectionState.active);
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Checks a decoded request and its member identities. Storage decoding has already checked epoch values.
344
+ *
345
+ * @param {EpochAppendRequest} request Candidate request read from local storage.
346
+ * @returns {boolean}
347
+ */
348
+ function isValidElectionRequest(request) {
349
+ if (!request || request.selectionVersion !== EPOCH_APPENDER_SELECTION_VERSION) {
350
+ return false;
351
+ }
352
+ if (!Number.isInteger(request.networkId) || request.networkId < 1 || request.networkId > UINT16_MAX) {
353
+ return false;
354
+ }
355
+ if (typeof request.requestId !== 'string' || !request.requestId.length ||
356
+ request.requestId.length > CONSENSUS_REQUEST_ID_MAX_LENGTH) {
357
+ return false;
358
+ }
359
+ if (!(request.logicalCounter > 0n && request.logicalCounter <= EPOCH_APPENDER_SELECTION_MAX_COUNTER) ||
360
+ typeof request.targetEpoch !== 'string') {
361
+ return false;
362
+ }
363
+ const hasValidIdentityFields = ['bootstrap', 'parentHash', 'membershipContext', 'configHash', 'payloadHash']
364
+ .every(field => isValidSelectionHex(request[field], HASH_HEX_LENGTH)) &&
365
+ isValidSelectionHex(request.requester, PEER_PUBLIC_KEY_HEX_LENGTH);
366
+ const members = request.members;
367
+ if (!hasValidIdentityFields || !Array.isArray(members) || members.length === 0) {
368
+ return false;
369
+ }
370
+ const hasValidMembers = members.every(member => member &&
371
+ isValidSelectionHex(member.publicKey, PEER_PUBLIC_KEY_HEX_LENGTH) &&
372
+ isValidSelectionHex(member.writerKey, PEER_PUBLIC_KEY_HEX_LENGTH));
373
+ return hasValidMembers && new Set(members.map(member => member.publicKey)).size === members.length;
374
+ }
375
+
376
+ /**
377
+ * Checks that the reservation belongs to this node and has a valid phase, term and payload details.
378
+ *
379
+ * @param {EpochAppenderSelectionReservation} reservation Local request or pending append.
380
+ * @param {string} localPublicKey Expected owner identity.
381
+ * @param {bigint} highestKnownCounter Highest election term saved by this node.
382
+ * @throws {EpochAppenderSelectionError}
383
+ */
384
+ function validateReservation(reservation, localPublicKey, highestKnownCounter) {
385
+ const request = reservation.request;
386
+ const hasValidPhase = Object.values(EpochAppenderSelectionPhase).includes(reservation.phase);
387
+ const hasLocalOwner = request?.requester === localPublicKey;
388
+ const hasValidCounter = request?.logicalCounter > 0n && request.logicalCounter <= EPOCH_APPENDER_SELECTION_MAX_COUNTER;
389
+ if (!hasValidPhase || !hasLocalOwner || !hasValidCounter) {
390
+ throw new EpochAppenderSelectionError(EpochAppenderSelectionErrorCode.RESERVATION_INVALID, 'Invalid epoch append reservation');
391
+ }
392
+ if (request.logicalCounter > highestKnownCounter) {
393
+ throw new EpochAppenderSelectionError(EpochAppenderSelectionErrorCode.RESERVATION_INVALID, 'Reservation counter exceeds coordination clock');
394
+ }
395
+ const hasValidMembers = Array.isArray(request.members) && request.members.every(member =>
396
+ member && typeof member.writerKey === 'string' && typeof member.publicKey === 'string'
397
+ );
398
+ const hasValidWriterKeys = request.currentWriterKeys === undefined ||
399
+ (Array.isArray(request.currentWriterKeys) && request.currentWriterKeys.every(key => typeof key === 'string'));
400
+ if (!hasValidMembers || !hasValidWriterKeys || typeof reservation.payload !== 'string') {
401
+ throw new EpochAppenderSelectionError(EpochAppenderSelectionErrorCode.RESERVATION_INVALID, 'Invalid epoch append reservation data');
402
+ }
403
+ }
404
+
405
+ /**
406
+ * Checks the writer position and verifies that the append details match the selected request.
407
+ *
408
+ * @param {EpochAppenderSelectionReservation} reservation PUBLISHING reservation.
409
+ * @throws {EpochAppenderSelectionError}
410
+ */
411
+ function validatePublication(reservation) {
412
+ const { publication, request } = reservation;
413
+ if (!publication || !isValidSelectionHex(publication.writerKey, PEER_PUBLIC_KEY_HEX_LENGTH)) {
414
+ throw new EpochAppenderSelectionError(EpochAppenderSelectionErrorCode.PUBLICATION_MARKER_INVALID, 'Invalid durable epoch publication writer');
415
+ }
416
+ if (!Number.isSafeInteger(publication.from) || publication.from < 0) {
417
+ throw new EpochAppenderSelectionError(EpochAppenderSelectionErrorCode.PUBLICATION_MARKER_INVALID, 'Invalid durable epoch publication position');
418
+ }
419
+ if (publication.payloadHash !== request.payloadHash || publication.targetEpoch !== request.targetEpoch) {
420
+ throw new EpochAppenderSelectionError(EpochAppenderSelectionErrorCode.PUBLICATION_MARKER_INVALID, 'Durable publication does not match its request');
421
+ }
422
+ }
423
+
424
+ /**
425
+ * Checks the election range of a decoded peer counter.
426
+ *
427
+ * @param {bigint} counter Election term or observed clock.
428
+ * @returns {boolean}
429
+ */
430
+ export function isValidPeerSelectionCounter(counter) {
431
+ return counter > 0n && counter <= EPOCH_APPENDER_SELECTION_COUNTER_LIMIT;
432
+ }
433
+
434
+ /**
435
+ * Checks that a stored vote belongs to this cohort and follows the saved clock and epoch floor.
436
+ *
437
+ * @param {EpochAppenderSelectionState} selectionState Decoded local state.
438
+ * @param {string} localPublicKey Local wallet public key.
439
+ * @throws {EpochAppenderSelectionError}
440
+ */
441
+ function validateStoredVote(selectionState, localPublicKey) {
442
+ const { request, expiresAt } = selectionState.vote;
443
+ if (!isValidElectionRequest(request) || !Number.isSafeInteger(expiresAt) || expiresAt < 0) {
444
+ throw new EpochAppenderSelectionError(EpochAppenderSelectionErrorCode.SELECTION_STATE_INVALID, 'Invalid durable appender vote');
445
+ }
446
+ const includesCandidate = request.members.some(member => member.publicKey === request.requester);
447
+ const includesLocalIndexer = request.members.some(member => member.publicKey === localPublicKey);
448
+ if (!includesCandidate || !includesLocalIndexer) {
449
+ throw new EpochAppenderSelectionError(EpochAppenderSelectionErrorCode.SELECTION_STATE_INVALID, 'Durable vote is missing a participant');
450
+ }
451
+ if (request.logicalCounter > selectionState.clock ||
452
+ BigInt(request.targetEpoch) < BigInt(selectionState.minimumElectionEpoch ?? '0')) {
453
+ throw new EpochAppenderSelectionError(EpochAppenderSelectionErrorCode.SELECTION_STATE_INVALID, 'Durable vote conflicts with the saved clock or epoch');
454
+ }
455
+ }
456
+
457
+ /**
458
+ * Checks that an active reservation has a saved vote and a valid election deadline.
459
+ *
460
+ * @param {EpochAppenderSelectionState} selectionState Decoded local state with an active reservation.
461
+ * @throws {EpochAppenderSelectionError}
462
+ */
463
+ function validateReservationVote(selectionState) {
464
+ const { active, vote } = selectionState;
465
+ if (!vote || active.request.logicalCounter > vote.request.logicalCounter) {
466
+ throw new EpochAppenderSelectionError(EpochAppenderSelectionErrorCode.RESERVATION_INVALID, 'Reservation is missing its durable vote');
467
+ }
468
+ if (!Number.isSafeInteger(active.expiresAt) || active.expiresAt < 0 || !isValidElectionRequest(active.request)) {
469
+ throw new EpochAppenderSelectionError(EpochAppenderSelectionErrorCode.RESERVATION_INVALID, 'Invalid appender election deadline or request');
470
+ }
471
+ if (typeof active.solo !== 'boolean' || (active.solo && active.request.members.length !== TWO_INDEXER_COHORT_SIZE)) {
472
+ throw new EpochAppenderSelectionError(EpochAppenderSelectionErrorCode.RESERVATION_INVALID, 'Solo selection requires a two-indexer cohort');
473
+ }
474
+ if (BigInt(active.request.targetEpoch) < BigInt(selectionState.minimumElectionEpoch ?? '0')) {
475
+ throw new EpochAppenderSelectionError(EpochAppenderSelectionErrorCode.RESERVATION_INVALID, 'Reservation precedes the minimum election epoch');
476
+ }
477
+ }
478
+
479
+ /**
480
+ * Checks the expected length and lowercase form of a stored key or hash.
481
+ *
482
+ * @param {string} value Stored hexadecimal value.
483
+ * @param {number} expectedLength Expected number of hexadecimal characters.
484
+ * @returns {boolean}
485
+ */
486
+ function isValidSelectionHex(value, expectedLength) {
487
+ return isHexString(value) && value.length === expectedLength && value === value.toLowerCase();
488
+ }
@@ -0,0 +1,93 @@
1
+ import b4a from 'b4a';
2
+ import { EpochAppenderSelectionError, EpochAppenderSelectionErrorCode } from './EpochAppenderSelectionError.js';
3
+ import { EPOCH_APPENDER_SELECTION_MAX_COUNTER } from '../../../utils/constants.js';
4
+
5
+ // Keep the existing storage key. The selector upgrades the record when it opens.
6
+ export const EPOCH_APPENDER_SELECTION_STATE_KEY = 'msb/epoch-append/v1';
7
+
8
+ /**
9
+ * Loads the selection state from local Hypercore userData. This record is not part of the replicated
10
+ * application state.
11
+ *
12
+ * @param {object} localMetadataStore Autobase instance with getUserData.
13
+ * @returns {Promise<import('./epochAppenderSelectionState.js').EpochAppenderSelectionState|null>} Decoded record. The selector checks its contents.
14
+ */
15
+ export async function loadSelectionState(localMetadataStore) {
16
+ const encodedSelectionState = await localMetadataStore.getUserData(EPOCH_APPENDER_SELECTION_STATE_KEY);
17
+ if (!encodedSelectionState) {
18
+ return null;
19
+ }
20
+ return decodeSelectionState(encodedSelectionState);
21
+ }
22
+
23
+ /**
24
+ * Saves the selection state locally before confirming a vote or authorizing an append.
25
+ *
26
+ * @param {object} localMetadataStore Autobase instance with setUserData.
27
+ * @param {import('./epochAppenderSelectionState.js').EpochAppenderSelectionState} selectionState Copy of the selection state to save.
28
+ * @returns {Promise<void>}
29
+ */
30
+ export function saveSelectionState(localMetadataStore, selectionState) {
31
+ const encodedSelectionState = encodeSelectionState(selectionState);
32
+ return localMetadataStore.setUserData(EPOCH_APPENDER_SELECTION_STATE_KEY, encodedSelectionState);
33
+ }
34
+
35
+ /**
36
+ * Decodes the saved record. The journal field keeps its old name for compatibility and contains the
37
+ * local selection state. Decimal counters are restored as BigInt at this boundary.
38
+ *
39
+ * @param {Buffer} encodedSelectionState Encoded record read from userData.
40
+ * @returns {import('./epochAppenderSelectionState.js').EpochAppenderSelectionState} Decoded state that still needs validation.
41
+ * @throws {EpochAppenderSelectionError} If the JSON or record format is invalid.
42
+ */
43
+ export function decodeSelectionState(encodedSelectionState) {
44
+ let storageEnvelope;
45
+ try {
46
+ storageEnvelope = JSON.parse(b4a.toString(encodedSelectionState), decodeSelectionNumber);
47
+ } catch (cause) {
48
+ throw new EpochAppenderSelectionError(EpochAppenderSelectionErrorCode.SELECTION_STATE_INVALID, 'Cannot decode epoch appender selection state', { cause });
49
+ }
50
+ // The v1 storage envelope retains its original field name for compatibility.
51
+ if (!storageEnvelope || !storageEnvelope.journal || typeof storageEnvelope.journal !== 'object') {
52
+ throw new EpochAppenderSelectionError(EpochAppenderSelectionErrorCode.SELECTION_STATE_INVALID, 'Epoch appender selection state record is missing');
53
+ }
54
+ return storageEnvelope.journal;
55
+ }
56
+
57
+ /**
58
+ * Encodes the selection state using the existing storage format. BigInt counters are saved as decimal strings.
59
+ *
60
+ * @param {import('./epochAppenderSelectionState.js').EpochAppenderSelectionState} selectionState Local record.
61
+ * @returns {Buffer}
62
+ */
63
+ export function encodeSelectionState(selectionState) {
64
+ // Retain the legacy envelope field for byte compatibility; it no longer controls activation.
65
+ return b4a.from(JSON.stringify({ activationEpoch: '1', journal: selectionState },
66
+ (_field, value) => typeof value === 'bigint' ? value.toString() : value));
67
+ }
68
+
69
+ /**
70
+ * Checks decimal uint64 fields from JSON and restores election counters as BigInt.
71
+ * Epoch fields keep their string format used by the signed context.
72
+ *
73
+ * @param {string} field JSON property name.
74
+ * @param {*} value Stored property value.
75
+ * @returns {*} BigInt for counters, otherwise the original value.
76
+ */
77
+ function decodeSelectionNumber(field, value) {
78
+ const isCounter = field === 'clock' || field === 'logicalCounter';
79
+ const isEpoch = field === 'targetEpoch' || field === 'minimumElectionEpoch';
80
+ if (!isCounter && !isEpoch) {
81
+ return value;
82
+ }
83
+ if (typeof value !== 'string') {
84
+ throw new EpochAppenderSelectionError(EpochAppenderSelectionErrorCode.SELECTION_STATE_INVALID,
85
+ `Stored ${field} must be a decimal uint64 string`);
86
+ }
87
+ const number = BigInt(value);
88
+ if (number < 0n || number > EPOCH_APPENDER_SELECTION_MAX_COUNTER || number.toString() !== value) {
89
+ throw new EpochAppenderSelectionError(EpochAppenderSelectionErrorCode.SELECTION_STATE_INVALID,
90
+ `Stored ${field} must be a decimal uint64 string`);
91
+ }
92
+ return isCounter ? number : value;
93
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Returns the number of proof signatures required for an epoch. The proposer counts as one signer.
3
+ * With one or two indexers, this is enough. Larger groups need a majority. Appender votes are counted
4
+ * separately by calculateAppenderVoteQuorum.
5
+ *
6
+ * @param {number} indexerCount Number of indexers in the membership read for this attempt.
7
+ * @returns {number} Required total signatures, including the local signature.
8
+ */
9
+ export function calculateProofSignatureQuorum(indexerCount) {
10
+ return indexerCount <= 2 ? 1 : Math.floor(indexerCount / 2) + 1;
11
+ }