trac-msb 0.2.24 → 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 (137) 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 +46 -7
  23. package/src/config/env.js +33 -6
  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/ConfirmationWatch.js +82 -0
  102. package/src/core/state/State.js +1794 -173
  103. package/src/core/state/StateEventQueue.js +69 -0
  104. package/src/core/state/utils/address.js +10 -6
  105. package/src/core/state/utils/epochProof.js +109 -0
  106. package/src/core/state/utils/escrowEntry.js +178 -0
  107. package/src/core/state/validators/StateValidationSchema.js +757 -0
  108. package/src/core/state/validators/stateValidationRules.js +312 -0
  109. package/src/index.js +255 -65
  110. package/src/messages/consensus/v1/ConsensusMessageBuilder.js +368 -0
  111. package/src/messages/consensus/v1/ConsensusMessageDirector.js +142 -0
  112. package/src/messages/consensus/v1/consensusMessageFactory.js +12 -0
  113. package/src/messages/consensus/v1/epochAppendMessage.js +58 -0
  114. package/src/messages/network/v1/NetworkMessageBuilder.js +15 -7
  115. package/src/messages/state/ApplyStateMessageBuilder.js +499 -13
  116. package/src/messages/state/ApplyStateMessageDirector.js +261 -2
  117. package/src/utils/StateMachine.js +125 -0
  118. package/src/utils/applyOperations.js +45 -2
  119. package/src/utils/buffer.js +239 -59
  120. package/src/utils/consensusConfig.js +76 -0
  121. package/src/utils/constants.js +174 -73
  122. package/src/utils/helpers.js +0 -25
  123. package/src/utils/htlcLock.js +122 -0
  124. package/src/utils/logger.js +10 -4
  125. package/src/utils/migrationUtils.js +2 -1
  126. package/src/utils/normalizers.js +113 -0
  127. package/src/utils/scheduler/SchedulableService.js +107 -0
  128. package/src/utils/{Scheduler.js → scheduler/Scheduler.js} +56 -17
  129. package/src/core/network/services/ConnectionManager.js +0 -406
  130. package/src/utils/check.js +0 -525
  131. package/src/utils/protobuf/applyOperations.cjs +0 -1373
  132. package/src/utils/protobuf/operationHelpers.js +0 -81
  133. /package/src/core/network/protocols/{legacy → validators/legacy}/handlers/LegacyResponseHandler.js +0 -0
  134. /package/src/core/network/protocols/{legacy → validators/legacy}/validators/ValidatorResponse.js +0 -0
  135. /package/src/core/network/protocols/{legacy → validators/legacy}/validators/base/BaseResponse.js +0 -0
  136. /package/src/core/network/protocols/{v1 → validators/v1}/validators/V1LivenessRequest.js +0 -0
  137. /package/src/core/network/protocols/{v1 → validators/v1}/validators/V1LivenessResponse.js +0 -0
@@ -0,0 +1,839 @@
1
+ import { EPOCH_EVENTS, EPOCH_STATES, EpochStateMachine } from './EpochStateMachine.js';
2
+ import {
3
+ createMessage,
4
+ toHex,
5
+ uint16ToBuffer,
6
+ uint32ToBuffer,
7
+ uint64ToBuffer,
8
+ } from '../../../utils/buffer.js';
9
+ import { addressToBuffer } from '../../state/utils/address.js';
10
+ import { EpochRoundListeners } from './EpochRoundListeners.js';
11
+ import { calculateProofSignatureQuorum } from '../quorum.js';
12
+ import {
13
+ sameSelectionContext,
14
+ EpochAppenderSelectionPhase,
15
+ EpochAppendOutcome,
16
+ } from '../epochAppenderSelection/epochAppenderSelectionState.js';
17
+ import {
18
+ EpochAppenderSelectionError,
19
+ EpochAppenderSelectionErrorCode,
20
+ } from '../epochAppenderSelection/EpochAppenderSelectionError.js';
21
+
22
+ /**
23
+ * @typedef {import('./EpochStateMachine.js').EpochRoundContext} EpochRoundContext
24
+ * @typedef {import('./EpochCoordinatorOperations.js').EpochApprovalCollection} EpochApprovalCollection
25
+ * @typedef {import('./EpochCoordinatorOperations.js').EpochProofApproval} EpochProofApproval
26
+ */
27
+
28
+ const INACTIVE_ROUND_ERROR = new Error('Epoch coordination round is no longer active');
29
+
30
+ /**
31
+ * Runs one epoch round through the FSM. Calculates the proof, collects approvals, selects an appender
32
+ * and checks the signed append result. A retry can reuse the proof, but starts a new approval
33
+ * collection and election. Cancelling the round stops its timers and callbacks. It cannot undo an
34
+ * append that has already started.
35
+ */
36
+ export class EpochCoordinationRound {
37
+ #state;
38
+ #wallet;
39
+ #config;
40
+ #indexerConnectionManager;
41
+ #logger;
42
+ #epochOperations;
43
+ #roundIntervalMs;
44
+ #epochStateMachine;
45
+ #roundListeners;
46
+ #cancelled = false;
47
+ #finished = false;
48
+ #remoteProposalWait = null;
49
+ #backoffWait = null;
50
+ #appenderSelector;
51
+ #selectionRequest = null;
52
+ #appendOutcomeTimer = null;
53
+ #checkingAppendOutcome = false;
54
+
55
+ /**
56
+ * Sets up a round with the shared node services. This round manages its own FSM, approval
57
+ * collection, retry delays and append outcome timer.
58
+ *
59
+ * @param {object} dependencies Services used by this round.
60
+ * @param {import('../../state/State.js').default} dependencies.state Access to signed ledger data and epoch events.
61
+ * @param {object} dependencies.wallet Local proposer identity.
62
+ * @param {object} dependencies.config Network and timeout configuration.
63
+ * @param {import('../../network/services/IndexerConnectionManager.js').default} dependencies.manager Consensus peer sessions.
64
+ * @param {object} dependencies.logger Round diagnostic logger.
65
+ * @param {import('./EpochCoordinatorOperations.js').EpochCoordinatorOperations} dependencies.operations Methods for proofs, signatures and appends.
66
+ * @param {number} dependencies.intervalMs Delay before the next round.
67
+ * @param {import('../epochAppenderSelection/EpochAppenderSelector.js').EpochAppenderSelector|null} [dependencies.appenderSelector=null] Shared selector that saves votes locally.
68
+ */
69
+ constructor({ state, wallet, config, manager: indexerConnectionManager, logger, operations: epochOperations, intervalMs: roundIntervalMs, appenderSelector = null }) {
70
+ this.#state = state;
71
+ this.#wallet = wallet;
72
+ this.#config = config;
73
+ this.#indexerConnectionManager = indexerConnectionManager;
74
+ this.#logger = logger;
75
+ this.#epochOperations = epochOperations;
76
+ this.#roundIntervalMs = roundIntervalMs;
77
+ this.#appenderSelector = appenderSelector;
78
+ this.#epochStateMachine = new EpochStateMachine(this.#buildHandlers());
79
+ this.#epochStateMachine.on(EPOCH_EVENTS.APPROVAL_COLLECTION_FAILED, ({ context: roundContext }) => {
80
+ this.#closeApprovalCollection();
81
+ this.#logApprovalBackoff(roundContext);
82
+ });
83
+ this.#roundListeners = new EpochRoundListeners({
84
+ state,
85
+ config,
86
+ machine: this.#epochStateMachine,
87
+ isRoundActive: () => this.#isRoundActive(),
88
+ onEpochProgress: () => this.#checkSignedEpochProgress(),
89
+ });
90
+ }
91
+
92
+ /**
93
+ * Starts this round. The method may return while the FSM is still waiting for an approval, timeout
94
+ * or ledger event.
95
+ *
96
+ * @param {(delay: number) => void} scheduleNextRound Schedules the next round after the given delay in milliseconds.
97
+ * @returns {Promise<void>}
98
+ */
99
+ async run(scheduleNextRound) {
100
+ if (!this.#isRoundActive()) return;
101
+
102
+ const currentEpoch = await this.#state.getCurrentEpoch();
103
+ if (!this.#isRoundActive()) return;
104
+ if (currentEpoch === null || currentEpoch < 0n) {
105
+ await this.#finishRound(scheduleNextRound, this.#roundIntervalMs);
106
+ return;
107
+ }
108
+
109
+ if (!this.#appenderSelector) {
110
+ throw new EpochAppenderSelectionError(
111
+ EpochAppenderSelectionErrorCode.SELECTOR_UNAVAILABLE,
112
+ 'Epoch appender selection is required but its selector is unavailable'
113
+ );
114
+ }
115
+ await this.#appenderSelector.refreshAppendOutcome();
116
+ if (!this.#isRoundActive()) return;
117
+ if (this.#appenderSelector.activeReservation) {
118
+ await this.#finishRound(scheduleNextRound, this.#roundIntervalMs);
119
+ return;
120
+ }
121
+ this.#roundListeners.start();
122
+ await this.#epochStateMachine.enter({
123
+ finishAndScheduleNextRound: (delayMs) => this.#finishRound(scheduleNextRound, delayMs),
124
+ });
125
+ }
126
+
127
+ /**
128
+ * Cancels the round and closes its FSM. Operations that are already running check whether the
129
+ * round is still active before using their results.
130
+ *
131
+ * @returns {Promise<void>}
132
+ */
133
+ async cancel() {
134
+ if (this.#cancelled) return this.#epochStateMachine.close();
135
+
136
+ this.#cancelled = true;
137
+ this.#closeApprovalCollection();
138
+ this.#cancelRemoteProposalWait();
139
+ this.#cancelBackoffWait();
140
+ clearTimeout(this.#appendOutcomeTimer);
141
+ await this.#epochStateMachine.close();
142
+ await this.#cancelSelectionRequest();
143
+ }
144
+
145
+ /**
146
+ * Closes this round and schedules the next one. A round finishes only once. A cancelled round does
147
+ * not schedule another run.
148
+ *
149
+ * @param {(delay: number) => void} scheduleNextRound Schedules the next round after this one closes.
150
+ * @param {number} delayMs Delay before the next round, in milliseconds.
151
+ * @returns {Promise<boolean>}
152
+ */
153
+ async #finishRound(scheduleNextRound, delayMs) {
154
+ if (this.#cancelled || this.#finished) return false;
155
+
156
+ this.#finished = true;
157
+ this.#closeApprovalCollection();
158
+ this.#cancelRemoteProposalWait();
159
+ this.#cancelBackoffWait();
160
+ clearTimeout(this.#appendOutcomeTimer);
161
+ await this.#epochStateMachine.close();
162
+ await this.#cancelSelectionRequest();
163
+
164
+ if (this.#cancelled) return false;
165
+ scheduleNextRound(delayMs);
166
+ return true;
167
+ }
168
+
169
+ /**
170
+ * Checks whether the round can still handle events and operation results.
171
+ *
172
+ * @returns {boolean}
173
+ */
174
+ #isRoundActive() {
175
+ return !this.#cancelled && !this.#finished && this.#epochStateMachine.shouldRun();
176
+ }
177
+
178
+ /**
179
+ * Stops a handler if the round ended during an async operation. The handler wrapper catches this
180
+ * error and ignores the late result.
181
+ */
182
+ #assertRoundActive() {
183
+ if (!this.#isRoundActive()) throw INACTIVE_ROUND_ERROR;
184
+ }
185
+
186
+ /**
187
+ * Logs how many external approvals were received when collection fails.
188
+ *
189
+ * @param {EpochRoundContext} roundContext Failed collection and required signature count.
190
+ */
191
+ #logApprovalBackoff(roundContext) {
192
+ const receivedApprovals = roundContext.approvalCollection?.approvals.length ?? 0;
193
+ const requiredApprovals = Math.max(roundContext.requiredProofSignatures - 1, 0);
194
+ const targetEpoch = roundContext.currentEpoch + 1n;
195
+
196
+ this.#logger.warn(
197
+ `[EpochCoordinationRound] approval collection failed for epoch ${targetEpoch}; ` +
198
+ `entering backoff after receiving ${receivedApprovals} of ${requiredApprovals} required external approvals.`,
199
+ );
200
+ }
201
+
202
+ /** Closes the current approval collection so late replies are ignored. */
203
+ #closeApprovalCollection() {
204
+ if (this.#epochStateMachine.context.approvalCollection) {
205
+ this.#epochStateMachine.context.approvalCollection.closed = true;
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Checks that the round and this approval collection are still active.
211
+ *
212
+ * @param {EpochApprovalCollection} approvalCollection Collection that received a reply.
213
+ * @returns {boolean} True if this collection still belongs to the active attempt.
214
+ */
215
+ #isApprovalCollectionActive(approvalCollection) {
216
+ return this.#isRoundActive() && !approvalCollection.closed && approvalCollection === this.#epochStateMachine.context.approvalCollection;
217
+ }
218
+
219
+
220
+ /**
221
+ * Waits for a matching remote proposal to reach the ledger. Cancelling the round ends this wait
222
+ * early.
223
+ *
224
+ * @returns {Promise<void>}
225
+ */
226
+ #waitForRemoteProposal() {
227
+ return new Promise((resolve) => {
228
+ const timer = setTimeout(() => {
229
+ if (this.#remoteProposalWait?.timer === timer) this.#remoteProposalWait = null;
230
+ resolve();
231
+ }, this.#config.epochRemoteProposalTimeout);
232
+ this.#remoteProposalWait = { timer, resolve };
233
+ });
234
+ }
235
+
236
+ /** Clears the remote proposal timer and finishes the wait. */
237
+ #cancelRemoteProposalWait() {
238
+ const remoteProposalWait = this.#remoteProposalWait;
239
+ if (!remoteProposalWait) return;
240
+
241
+ this.#remoteProposalWait = null;
242
+ clearTimeout(remoteProposalWait.timer);
243
+ remoteProposalWait.resolve();
244
+ }
245
+
246
+ /**
247
+ * Waits before starting another attempt after a failure.
248
+ *
249
+ * @param {number} [delayMs] Delay in milliseconds. Defaults to epochBackoffDelay.
250
+ * @returns {Promise<void>} Resolves when the timer ends or the wait is cancelled.
251
+ */
252
+ #waitForBackoff(delayMs = this.#config.epochBackoffDelay) {
253
+ return new Promise((resolve) => {
254
+ const complete = () => {
255
+ this.#backoffWait = null;
256
+ resolve();
257
+ };
258
+
259
+ this.#backoffWait = {
260
+ timer: setTimeout(complete, delayMs),
261
+ complete,
262
+ };
263
+ });
264
+ }
265
+
266
+ /** Clears the retry timer and finishes the wait. */
267
+ #cancelBackoffWait() {
268
+ const backoffWait = this.#backoffWait;
269
+ if (!backoffWait) return;
270
+
271
+ clearTimeout(backoffWait.timer);
272
+ backoffWait.complete();
273
+ }
274
+
275
+ /**
276
+ * Loads the signed parent epoch and VDF config for this round.
277
+ *
278
+ * @param {EpochRoundContext} _context Unused because this handler loads the round context.
279
+ * @param {EpochStateMachine} epochStateMachine FSM that receives the next event.
280
+ * @returns {Promise<void>}
281
+ */
282
+ async #handleLoadEpochContext(_context, epochStateMachine) {
283
+ const currentEpoch = await this.#state.requireCurrentEpoch();
284
+ const currentEpochHash = await this.#state.requireEpoch(currentEpoch);
285
+ const {
286
+ configData: {
287
+ difficulty: vdfDifficulty,
288
+ discriminantBitSize: vdfDiscriminantSize,
289
+ },
290
+ } = await this.#state.requireSignedConsensusConfig();
291
+ this.#assertRoundActive();
292
+ epochStateMachine.appendContext({ currentEpoch, currentEpochHash, vdfDifficulty, vdfDiscriminantSize });
293
+ await epochStateMachine.send(EPOCH_EVENTS.START);
294
+ }
295
+
296
+ /**
297
+ * Calculates the local VDF proof and sends the result to the FSM.
298
+ *
299
+ * @param {EpochRoundContext} roundContext Data for this round and attempt.
300
+ * @param {EpochStateMachine} epochStateMachine FSM that receives the next event.
301
+ * @returns {Promise<void>}
302
+ */
303
+ async #handleInitializeVdf(roundContext, epochStateMachine) {
304
+ const { currentEpochHash, vdfDifficulty, vdfDiscriminantSize, currentEpoch } = roundContext;
305
+
306
+ const challenge = createMessage(
307
+ uint16ToBuffer(this.#config.networkId),
308
+ uint64ToBuffer(currentEpoch + 1n),
309
+ currentEpochHash,
310
+ addressToBuffer(this.#wallet.address, this.#config.addressPrefix),
311
+ uint32ToBuffer(vdfDifficulty),
312
+ uint16ToBuffer(vdfDiscriminantSize),
313
+ );
314
+
315
+ try {
316
+ const vdf = await this.#epochOperations.calculateVDF(challenge, vdfDifficulty, vdfDiscriminantSize);
317
+ this.#assertRoundActive();
318
+ epochStateMachine.appendContext({ vdf });
319
+ await epochStateMachine.send(EPOCH_EVENTS.LOCAL_PROOF_READY);
320
+ } catch (error) {
321
+ this.#assertRoundActive();
322
+ this.#logger.error(error);
323
+ await epochStateMachine.send(EPOCH_EVENTS.SOLVER_FAILURE);
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Builds and signs the local proposal after the VDF proof is ready.
329
+ *
330
+ * @param {EpochRoundContext} roundContext Data for this round and attempt.
331
+ * @param {EpochStateMachine} epochStateMachine FSM that receives the next event.
332
+ * @returns {Promise<void>}
333
+ */
334
+ async #handleBuildAndSignProofProposal(roundContext, epochStateMachine) {
335
+ const { currentEpoch, currentEpochHash, vdf } = roundContext;
336
+ const proofProposalMessage = await this.#epochOperations.createProofProposal(currentEpoch, currentEpochHash, vdf);
337
+ this.#assertRoundActive();
338
+ epochStateMachine.appendContext({ proofProposalMessage, proofProposal: proofProposalMessage.proof_proposal });
339
+ await epochStateMachine.send(EPOCH_EVENTS.PROPOSAL_BUILT);
340
+ }
341
+
342
+ /**
343
+ * Reads the current indexers and decides whether the proof needs external approvals.
344
+ *
345
+ * @param {EpochRoundContext} roundContext Data for this round and attempt.
346
+ * @param {EpochStateMachine} epochStateMachine FSM that receives the next event.
347
+ * @returns {Promise<void>}
348
+ */
349
+ async #handleQuorumDecision(roundContext, epochStateMachine) {
350
+ if (roundContext.remoteProposalReceived) {
351
+ await this.#waitForRemoteProposal();
352
+ this.#assertRoundActive();
353
+ const latestEpoch = await this.#state.getCurrentEpoch();
354
+ this.#assertRoundActive();
355
+ if (latestEpoch > roundContext.currentEpoch) {
356
+ await epochStateMachine.send(EPOCH_EVENTS.TARGET_EPOCH_ALREADY_SIGNED);
357
+ return;
358
+ }
359
+ }
360
+
361
+ // Each attempt uses one authoritative membership snapshot, regardless of peer connectivity.
362
+ const indexers = await this.#state.getIndexersEntry();
363
+ this.#assertRoundActive();
364
+ const indexerKeys = new Set(indexers.map(({ key }) => toHex(key)));
365
+ if (!indexerKeys.has(toHex(this.#state.writingKey))) {
366
+ await roundContext.finishAndScheduleNextRound(this.#roundIntervalMs);
367
+ return;
368
+ }
369
+
370
+ const requiredProofSignatures = calculateProofSignatureQuorum(indexers.length);
371
+ const approvers = this.#epochOperations.getProofApprovers(indexers);
372
+ epochStateMachine.appendContext({ indexerKeys, requiredProofSignatures, approvers, approvalCollection: null });
373
+
374
+ await epochStateMachine.send(
375
+ requiredProofSignatures <= 1
376
+ ? EPOCH_EVENTS.EXTERNAL_APPROVALS_NOT_REQUIRED
377
+ : EPOCH_EVENTS.EXTERNAL_APPROVALS_REQUIRED,
378
+ );
379
+ }
380
+
381
+ /**
382
+ * Connects to the other indexers and starts one proof approval request per signer.
383
+ *
384
+ * @param {EpochRoundContext} roundContext Data for this round and attempt.
385
+ * @param {EpochStateMachine} epochStateMachine FSM that receives the next event.
386
+ * @returns {Promise<void>}
387
+ */
388
+ async #handleSendProposalToIndexers(roundContext, epochStateMachine) {
389
+ const { approvers } = roundContext;
390
+ await this.#indexerConnectionManager.connect();
391
+ this.#assertRoundActive();
392
+
393
+ const approvalCollection = {
394
+ approvers,
395
+ approvals: [],
396
+ rejections: [],
397
+ closed: false,
398
+ };
399
+
400
+ epochStateMachine.appendContext({ approvalCollection });
401
+ this.#dispatchApprovalRequests(approvers, epochStateMachine, approvalCollection);
402
+ await epochStateMachine.send(EPOCH_EVENTS.PROPOSAL_BROADCAST);
403
+ }
404
+
405
+ /**
406
+ * Sends proof approval requests. Each reply is checked against the active round and approval
407
+ * collection.
408
+ *
409
+ * @param {Array<{key: Buffer}>} approvers Indexers that can approve this proof.
410
+ * @param {EpochStateMachine} epochStateMachine FSM for this round.
411
+ * @param {EpochApprovalCollection} approvalCollection Collection that owns these requests.
412
+ */
413
+ #dispatchApprovalRequests(approvers, epochStateMachine, approvalCollection) {
414
+ for (const member of approvers) {
415
+ const { currentEpoch, currentEpochHash, vdf } = epochStateMachine.context;
416
+ this.#epochOperations.requestProofApproval(
417
+ member,
418
+ { currentEpoch, currentEpochHash, vdf },
419
+ this.#indexerConnectionManager,
420
+ approvalCollection,
421
+ )
422
+ .then((proofApproval) => this.#handleApproval(proofApproval, approvalCollection, epochStateMachine))
423
+ .catch((error) => this.#handleApprovalFailure(error, member, approvalCollection, epochStateMachine));
424
+ }
425
+ }
426
+
427
+ /**
428
+ * Adds a valid approval and advances the FSM when enough proof signatures are collected.
429
+ *
430
+ * @param {EpochProofApproval} proofApproval Verified signature and signer address.
431
+ * @param {EpochApprovalCollection} approvalCollection Collection that sent the request.
432
+ * @param {EpochStateMachine} epochStateMachine FSM that receives QUORUM_REACHED.
433
+ * @returns {Promise<void>}
434
+ */
435
+ async #handleApproval(proofApproval, approvalCollection, epochStateMachine) {
436
+ if (!this.#isApprovalCollectionActive(approvalCollection)) return;
437
+
438
+ approvalCollection.approvals.push(proofApproval);
439
+ if (approvalCollection.approvals.length + 1 >= epochStateMachine.context.requiredProofSignatures) {
440
+ approvalCollection.closed = true;
441
+ await epochStateMachine.send(EPOCH_EVENTS.QUORUM_REACHED);
442
+ }
443
+ }
444
+
445
+ /**
446
+ * Records a failed approval request. Stops collection when the remaining signers cannot provide
447
+ * enough signatures.
448
+ *
449
+ * @param {Error} error Reason the approval request failed.
450
+ * @param {{key: Buffer}} member Indexer that was asked to approve the proof.
451
+ * @param {EpochApprovalCollection} approvalCollection Collection that sent the request.
452
+ * @param {EpochStateMachine} epochStateMachine FSM that receives APPROVAL_COLLECTION_FAILED.
453
+ * @returns {Promise<void>}
454
+ */
455
+ async #handleApprovalFailure(error, member, approvalCollection, epochStateMachine) {
456
+ if (!this.#isApprovalCollectionActive(approvalCollection)) return;
457
+
458
+ approvalCollection.rejections.push({ member, error });
459
+ if (approvalCollection.rejections.length > approvalCollection.approvers.length - epochStateMachine.context.requiredProofSignatures + 1) {
460
+ approvalCollection.closed = true;
461
+ await epochStateMachine.send(EPOCH_EVENTS.APPROVAL_COLLECTION_FAILED);
462
+ }
463
+ }
464
+
465
+ /**
466
+ * Builds the SET_EPOCH payload from the local proposal and collected approvals.
467
+ *
468
+ * @param {EpochRoundContext} roundContext Data for this round and attempt.
469
+ * @param {EpochStateMachine} epochStateMachine FSM that receives the next event.
470
+ * @returns {Promise<void>}
471
+ */
472
+ async #handleBuildSetEpoch(roundContext, epochStateMachine) {
473
+ const setEpochPayload = await this.#epochOperations.buildSetEpochPayload(
474
+ roundContext.proofProposal,
475
+ roundContext.approvalCollection?.approvals ?? [],
476
+ );
477
+ this.#assertRoundActive();
478
+ epochStateMachine.appendContext({ setEpochPayload });
479
+ await epochStateMachine.send(EPOCH_EVENTS.SET_EPOCH_BUILT);
480
+ }
481
+
482
+ /**
483
+ * Checks the signed epoch, consensus config and indexers before appending this payload.
484
+ *
485
+ * @param {EpochRoundContext} roundContext Data for this round and attempt.
486
+ * @param {EpochStateMachine} epochStateMachine FSM that receives the next event.
487
+ * @returns {Promise<void>}
488
+ */
489
+ async #handleRefreshSignedStateBeforeAppend(roundContext, epochStateMachine) {
490
+ const latestEpochBeforeAppend = await this.#state.getCurrentEpoch();
491
+ this.#assertRoundActive();
492
+ if (latestEpochBeforeAppend !== roundContext.currentEpoch) {
493
+ await this.#cancelSelectionRequest();
494
+ this.#assertRoundActive();
495
+ await epochStateMachine.send(EPOCH_EVENTS.TARGET_EPOCH_ALREADY_SIGNED);
496
+ return;
497
+ }
498
+
499
+ const signedSelectionContext = await this.#state.getEpochAppenderSelectionContext();
500
+ this.#assertRoundActive();
501
+
502
+ const { configData } = await this.#state.requireSignedConsensusConfig();
503
+ this.#assertRoundActive();
504
+
505
+ if (
506
+ !sameSelectionContext(signedSelectionContext, this.#selectionRequest) ||
507
+ configData.difficulty !== roundContext.vdfDifficulty ||
508
+ configData.discriminantBitSize !== roundContext.vdfDiscriminantSize
509
+ ) {
510
+ await this.#cancelSelectionRequest();
511
+ this.#assertRoundActive();
512
+ await epochStateMachine.send(EPOCH_EVENTS.TARGET_EPOCH_ALREADY_SIGNED);
513
+ return;
514
+ }
515
+
516
+ const currentWriterKeys = signedSelectionContext.currentWriterKeys;
517
+ const membershipChanged =
518
+ currentWriterKeys.length !== roundContext.indexerKeys.size ||
519
+ currentWriterKeys.some(key => !roundContext.indexerKeys.has(key));
520
+
521
+ if (membershipChanged) {
522
+ await this.#cancelSelectionRequest();
523
+ this.#assertRoundActive();
524
+ }
525
+
526
+ await epochStateMachine.send(
527
+ membershipChanged
528
+ ? EPOCH_EVENTS.INDEXERS_CHANGED
529
+ : EPOCH_EVENTS.TARGET_EPOCH_ABSENT
530
+ );
531
+ }
532
+
533
+ /**
534
+ * Refreshes signed state and checks whether the local proof can be reused.
535
+ *
536
+ * @param {EpochRoundContext} roundContext Data for this round and attempt.
537
+ * @param {EpochStateMachine} epochStateMachine FSM that receives the next event.
538
+ * @returns {Promise<void>}
539
+ */
540
+ async #handleRefreshSignedState(roundContext, epochStateMachine) {
541
+ await this.#state.refresh();
542
+ this.#assertRoundActive();
543
+ const latestEpoch = await this.#state.getCurrentEpoch();
544
+ this.#assertRoundActive();
545
+
546
+ if (latestEpoch > roundContext.currentEpoch) {
547
+ await epochStateMachine.send(EPOCH_EVENTS.NEW_EPOCH_DISCOVERED);
548
+ } else if (roundContext.vdf) {
549
+ await epochStateMachine.send(EPOCH_EVENTS.EPOCH_UNCHANGED_WITH_LOCAL_PROOF);
550
+ } else {
551
+ await epochStateMachine.send(EPOCH_EVENTS.EPOCH_UNCHANGED_WITHOUT_LOCAL_PROOF);
552
+ }
553
+ }
554
+
555
+ /**
556
+ * Waits before retrying. Election failures use a shorter delay.
557
+ *
558
+ * @param {EpochRoundContext} roundContext Data for this round and attempt.
559
+ * @param {EpochStateMachine} epochStateMachine FSM that receives the next event.
560
+ * @returns {Promise<void>}
561
+ */
562
+ async #handleBackoff(roundContext, epochStateMachine) {
563
+ const delayMs = roundContext.appendElectionRetry
564
+ ? Math.max(100, Math.min(this.#config.epochBackoffDelay, 1000))
565
+ : this.#config.epochBackoffDelay;
566
+ epochStateMachine.appendContext({ appendElectionRetry: false });
567
+ await this.#waitForBackoff(delayMs);
568
+ this.#assertRoundActive();
569
+ await epochStateMachine.send(EPOCH_EVENTS.BACKOFF_ELAPSED);
570
+ }
571
+
572
+ /**
573
+ * Ends this round and schedules a new one that loads fresh signed context.
574
+ *
575
+ * @param {EpochRoundContext} roundContext Provides the callback that closes this round and schedules the next.
576
+ * @returns {Promise<void>}
577
+ */
578
+ async #handleRoundCompletion(roundContext) {
579
+ await roundContext.finishAndScheduleNextRound(this.#roundIntervalMs);
580
+ }
581
+
582
+ /**
583
+ * Checks that the round is active before running a handler. A handler error ends the round and
584
+ * schedules another attempt. Late results from an inactive round are ignored.
585
+ *
586
+ * @param {Function} handler Handler for one FSM state.
587
+ * @returns {Function} Handler that checks whether the round is active.
588
+ */
589
+ #wrapHandler(handler) {
590
+ return async (roundContext, epochStateMachine) => {
591
+ try {
592
+ this.#assertRoundActive();
593
+ await handler(roundContext, epochStateMachine);
594
+ } catch (error) {
595
+ if (error === INACTIVE_ROUND_ERROR || !this.#isRoundActive()) return;
596
+
597
+ this.#logger.error(`[EpochCoordinationRound] handler failed: ${error.message}`);
598
+ await roundContext.finishAndScheduleNextRound(this.#roundIntervalMs);
599
+ }
600
+ };
601
+ }
602
+
603
+ /** @returns {object} FSM handlers that check whether the round is active. */
604
+ #buildHandlers() {
605
+ const handlers = {
606
+ [EPOCH_STATES.LOAD_EPOCH_CONTEXT]: this.#handleLoadEpochContext.bind(this),
607
+ [EPOCH_STATES.INITIALIZE_VDF]: this.#handleInitializeVdf.bind(this),
608
+ [EPOCH_STATES.BUILD_AND_SIGN_PROOF_PROPOSAL]: this.#handleBuildAndSignProofProposal.bind(this),
609
+ [EPOCH_STATES.QUORUM_DECISION]: this.#handleQuorumDecision.bind(this),
610
+ [EPOCH_STATES.SEND_PROPOSAL_TO_INDEXERS]: this.#handleSendProposalToIndexers.bind(this),
611
+ [EPOCH_STATES.BUILD_SET_EPOCH]: this.#handleBuildSetEpoch.bind(this),
612
+ [EPOCH_STATES.WAIT_FOR_APPEND_REPLIES]: this.#handleWaitForAppendReplies.bind(this),
613
+ [EPOCH_STATES.REFRESH_SIGNED_STATE_BEFORE_APPEND]: this.#handleRefreshSignedStateBeforeAppend.bind(this),
614
+ [EPOCH_STATES.APPEND_SET_EPOCH]: this.#handleAppendSetEpoch.bind(this),
615
+ [EPOCH_STATES.WAIT_FOR_APPEND_OUTCOME]: this.#handleWaitForAppendOutcome.bind(this),
616
+ [EPOCH_STATES.REFRESH_SIGNED_STATE]: this.#handleRefreshSignedState.bind(this),
617
+ [EPOCH_STATES.BACKOFF]: this.#handleBackoff.bind(this),
618
+ [EPOCH_STATES.RELOAD_SIGNED_CONTEXT]: this.#handleRoundCompletion.bind(this),
619
+ };
620
+
621
+ return Object.fromEntries(
622
+ Object.entries(handlers).map(([state, handler]) => [state, this.#wrapHandler(handler)]),
623
+ );
624
+ }
625
+
626
+ /**
627
+ * Clears this round's selection request. Cancels the reservation if no append has started. A
628
+ * PUBLISHING reservation stays with the selector until its result is known or it expires.
629
+ *
630
+ * @returns {Promise<void>}
631
+ */
632
+ async #cancelSelectionRequest() {
633
+ const selectionRequest = this.#selectionRequest;
634
+ this.#selectionRequest = null;
635
+ if (selectionRequest) {
636
+ await this.#appenderSelector.cancel(selectionRequest.requestId);
637
+ }
638
+ }
639
+
640
+ /**
641
+ * Reserves this attempt's payload and waits for appender votes. A changed signed context ends the
642
+ * round. A failed election starts another attempt after a delay.
643
+ *
644
+ * @param {EpochRoundContext} roundContext Current proof and SET_EPOCH bytes.
645
+ * @param {EpochStateMachine} epochStateMachine FSM receiving the selection outcome.
646
+ * @returns {Promise<void>}
647
+ */
648
+ async #handleWaitForAppendReplies(roundContext, epochStateMachine) {
649
+ try {
650
+ const signedSelectionContext = await this.#state.getEpochAppenderSelectionContext();
651
+ this.#assertRoundActive();
652
+ if (
653
+ BigInt(signedSelectionContext.targetEpoch) !== roundContext.currentEpoch + 1n ||
654
+ signedSelectionContext.parentHash !== toHex(roundContext.currentEpochHash)
655
+ ) {
656
+ await epochStateMachine.send(EPOCH_EVENTS.TARGET_EPOCH_ALREADY_SIGNED);
657
+ return;
658
+ }
659
+ const selectionRequest = await this.#appenderSelector.createSelectionRequest(signedSelectionContext, roundContext.setEpochPayload);
660
+ if (!this.#isRoundActive()) {
661
+ await this.#appenderSelector.cancel(selectionRequest.requestId);
662
+ return;
663
+ }
664
+ this.#selectionRequest = selectionRequest;
665
+ await this.#appenderSelector.waitForAppenderSelection(selectionRequest);
666
+ this.#assertRoundActive();
667
+ if (this.#selectionRequest !== selectionRequest || epochStateMachine.state !== EPOCH_STATES.WAIT_FOR_APPEND_REPLIES) {
668
+ return;
669
+ }
670
+ await epochStateMachine.send(EPOCH_EVENTS.APPEND_REPLIES_COLLECTED);
671
+ } catch (error) {
672
+ this.#assertRoundActive();
673
+ await this.#cancelSelectionRequest();
674
+ const latestEpoch = await this.#state.getCurrentEpoch();
675
+ this.#assertRoundActive();
676
+ if (latestEpoch > roundContext.currentEpoch) {
677
+ await epochStateMachine.send(EPOCH_EVENTS.TARGET_EPOCH_ALREADY_SIGNED);
678
+ return;
679
+ }
680
+ const message = `[EpochCoordinationRound] append replies unavailable: ${error.message}`;
681
+ if (error.code === EpochAppenderSelectionErrorCode.ELECTION_BUSY ||
682
+ error.code === EpochAppenderSelectionErrorCode.REPLIES_INCOMPLETE ||
683
+ error.code === EpochAppenderSelectionErrorCode.ELECTION_SUPERSEDED) {
684
+ this.#logger.debug?.(message);
685
+ } else {
686
+ this.#logger.warn(message);
687
+ }
688
+ epochStateMachine.appendContext({ appendElectionRetry: true });
689
+ await epochStateMachine.send(EPOCH_EVENTS.APPEND_REPLY_COLLECTION_FAILED);
690
+ }
691
+ }
692
+
693
+ /**
694
+ * Starts the append and moves the FSM to waiting for its result. The write runs in the background
695
+ * because a timeout or a new election cannot cancel it.
696
+ *
697
+ * @param {EpochRoundContext} _context Unused here because the selector stores the selected payload.
698
+ * @param {EpochStateMachine} epochStateMachine FSM receiving APPEND_STARTED.
699
+ * @returns {Promise<void>}
700
+ */
701
+ async #handleAppendSetEpoch(_context, epochStateMachine) {
702
+ if (epochStateMachine.state !== EPOCH_STATES.APPEND_SET_EPOCH || !this.#selectionRequest) {
703
+ return;
704
+ }
705
+ const selectionRequest = this.#selectionRequest;
706
+ // Observe without awaiting the local write. Signed completion or election expiry
707
+ // ends this attempt; neither a timeout nor a newer term cancels the pending append.
708
+ this.#appendSelectedEpochPayload(selectionRequest)
709
+ .catch(error => this.#handleAppendFailure(selectionRequest, epochStateMachine, error))
710
+ .catch(error => this.#logger.error(error));
711
+ await epochStateMachine.send(EPOCH_EVENTS.APPEND_STARTED);
712
+ }
713
+
714
+ /**
715
+ * Reads the selected payload and queues its append. The selector checks authorization inside the
716
+ * write queue before the payload is written.
717
+ *
718
+ * @param {import('../epochAppenderSelection/epochAppenderSelectionState.js').EpochAppendRequest} selectionRequest Selection request for this attempt.
719
+ * @returns {Promise<void>} Completes after the local append. The signed result still needs checking.
720
+ */
721
+ async #appendSelectedEpochPayload(selectionRequest) {
722
+ const epochPayload = await this.#appenderSelector.getSelectedEpochPayload(selectionRequest.requestId);
723
+ this.#assertRoundActive();
724
+ if (this.#selectionRequest !== selectionRequest) {
725
+ return;
726
+ }
727
+ return this.#epochOperations.appendSetEpoch(epochPayload, appendLocation => {
728
+ return this.#appenderSelector.authorizeEpochAppend(selectionRequest.requestId, appendLocation);
729
+ });
730
+ }
731
+
732
+ /**
733
+ * Handles an append error only if it belongs to this attempt. Releases the selection when the
734
+ * append never started. Otherwise, the selector keeps checking its signed result until the result
735
+ * is known or the selection expires.
736
+ *
737
+ * @param {import('../epochAppenderSelection/epochAppenderSelectionState.js').EpochAppendRequest} selectionRequest Request saved before starting the append.
738
+ * @param {EpochStateMachine} epochStateMachine Current round FSM.
739
+ * @param {Error} error Error from the write queue, authorization or append.
740
+ * @returns {Promise<void>}
741
+ */
742
+ async #handleAppendFailure(selectionRequest, epochStateMachine, error) {
743
+ if (!this.#isRoundActive() || this.#selectionRequest !== selectionRequest) {
744
+ return;
745
+ }
746
+ this.#logger.error(`[EpochCoordinationRound] append outcome unresolved: ${error.message}`);
747
+ const activeReservation = this.#appenderSelector.activeReservation;
748
+ if (activeReservation?.phase === EpochAppenderSelectionPhase.HELD) {
749
+ // Without the durable marker no append could have started, so cancellation is safe.
750
+ const released = await this.#appenderSelector.cancel(selectionRequest.requestId);
751
+ if (released && this.#isRoundActive() && this.#selectionRequest === selectionRequest) {
752
+ this.#selectionRequest = null;
753
+ await epochStateMachine.send(EPOCH_EVENTS.APPEND_REJECTED);
754
+ }
755
+ return;
756
+ }
757
+ if (!activeReservation && !this.#appenderSelector.getRecordedAppendOutcome(selectionRequest.requestId)) {
758
+ this.#selectionRequest = null;
759
+ await epochStateMachine.send(EPOCH_EVENTS.APPEND_REJECTED);
760
+ }
761
+ }
762
+
763
+ /**
764
+ * Checks signed progress now, then schedules further checks while this attempt is active.
765
+ *
766
+ * @returns {Promise<void>}
767
+ */
768
+ async #handleWaitForAppendOutcome() {
769
+ await this.#checkSignedEpochProgress();
770
+ this.#scheduleAppendOutcomeCheck();
771
+ }
772
+
773
+ /**
774
+ * Schedules another result check while the FSM waits for the append. The selector also checks
775
+ * appends that continue after the round has ended.
776
+ *
777
+ * @returns {void}
778
+ */
779
+ #scheduleAppendOutcomeCheck() {
780
+ clearTimeout(this.#appendOutcomeTimer);
781
+ if (!this.#isRoundActive() || this.#epochStateMachine.state !== EPOCH_STATES.WAIT_FOR_APPEND_OUTCOME) return;
782
+ this.#appendOutcomeTimer = setTimeout(async () => {
783
+ await this.#checkSignedEpochProgress();
784
+ this.#scheduleAppendOutcomeCheck();
785
+ }, Math.max(100, Math.min(this.#config.epochAppendTimeout, 1000)));
786
+ }
787
+
788
+ /**
789
+ * Sends an FSM event for the signed append result or selection expiry. EPOCH_CREATED can arrive
790
+ * before the epoch is signed, so the event starts a check. A result is used only if it still
791
+ * belongs to this round's current request.
792
+ *
793
+ * @returns {Promise<void>}
794
+ */
795
+ async #checkSignedEpochProgress() {
796
+ if (!this.#isRoundActive() || this.#checkingAppendOutcome) return;
797
+ this.#checkingAppendOutcome = true;
798
+ const selectionRequest = this.#selectionRequest;
799
+ try {
800
+ await this.#appenderSelector.refreshAppendOutcome();
801
+ if (!this.#isRoundActive() || this.#selectionRequest !== selectionRequest) return;
802
+ const waitingForAppendOutcome = this.#epochStateMachine.state === EPOCH_STATES.WAIT_FOR_APPEND_OUTCOME;
803
+ const outcome = selectionRequest && this.#appenderSelector.getRecordedAppendOutcome(selectionRequest.requestId);
804
+ if (waitingForAppendOutcome && outcome) {
805
+ let event;
806
+ switch (outcome.status) {
807
+ case EpochAppendOutcome.REJECTED:
808
+ event = EPOCH_EVENTS.APPEND_REJECTED;
809
+ break;
810
+ case EpochAppendOutcome.EPOCH_ADVANCED:
811
+ event = EPOCH_EVENTS.TARGET_EPOCH_CONFIRMED;
812
+ break;
813
+ case EpochAppendOutcome.SELECTION_EXPIRED:
814
+ this.#epochStateMachine.appendContext({ appendElectionRetry: true });
815
+ event = EPOCH_EVENTS.APPENDER_SELECTION_EXPIRED;
816
+ break;
817
+ default:
818
+ return;
819
+ }
820
+ clearTimeout(this.#appendOutcomeTimer);
821
+ this.#selectionRequest = null;
822
+ await this.#epochStateMachine.send(event);
823
+ return;
824
+ }
825
+ // The event itself may come from speculative apply. Read signed state.
826
+ if (!waitingForAppendOutcome && this.#epochStateMachine.state !== EPOCH_STATES.APPEND_SET_EPOCH) {
827
+ const epoch = await this.#state.getCurrentEpoch();
828
+ if (!this.#isRoundActive() || this.#selectionRequest !== selectionRequest) return;
829
+ if (epoch > this.#epochStateMachine.context.currentEpoch) {
830
+ await this.#epochStateMachine.context.finishAndScheduleNextRound(this.#roundIntervalMs);
831
+ }
832
+ }
833
+ } catch (error) {
834
+ this.#logger.error(`[EpochCoordinationRound] cannot confirm append outcome: ${error.message}`);
835
+ } finally {
836
+ this.#checkingAppendOutcome = false;
837
+ }
838
+ }
839
+ }