trac-msb 0.2.26 → 0.3.0-beta.2

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 +121 -0
  10. package/rpc/routes/v1.js +12 -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 +1803 -159
  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 +280 -67
  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,1042 @@
1
+ import b4a from 'b4a';
2
+ import tracCryptoApi from 'trac-crypto-api';
3
+ import { generateUUID } from '../../../utils/helpers.js';
4
+ import { toHex } from '../../../utils/buffer.js';
5
+ import { EPOCH_APPENDER_SELECTION_COUNTER_LIMIT, EPOCH_APPENDER_SELECTION_COUNTER_STEP } from '../../../utils/constants.js';
6
+ import { EpochAppenderSelectionError, EpochAppenderSelectionErrorCode } from './EpochAppenderSelectionError.js';
7
+ import {
8
+ EpochAppenderSelectionPhase,
9
+ EpochAppendOutcome,
10
+ EpochAppenderVoteStatus,
11
+ EPOCH_APPENDER_SELECTION_VERSION,
12
+ TWO_INDEXER_COHORT_SIZE,
13
+ copySelectionContext,
14
+ copySelectionRequest,
15
+ copyAppendPublication,
16
+ copySelectionReservation,
17
+ sameSelectionContext,
18
+ sameSelectionRequest,
19
+ sameWriterKeys,
20
+ calculateAppenderVoteQuorum,
21
+ createSelectionState,
22
+ upgradeSelectionState,
23
+ copySelectionState,
24
+ validateSelectionState,
25
+ isValidPeerSelectionCounter,
26
+ } from './epochAppenderSelectionState.js';
27
+
28
+ /**
29
+ * @typedef {import('./epochAppenderSelectionState.js').EpochAppendRequest} EpochAppendRequest
30
+ * @typedef {import('./epochAppenderSelectionState.js').EpochAppenderSelectionContext} EpochAppenderSelectionContext
31
+ * @typedef {import('./epochAppenderSelectionState.js').EpochAppendOutcomeResult} EpochAppendOutcomeResult
32
+ *
33
+ * @typedef {import('./EpochAppenderSelectionOperations.js').EpochAppenderSelectionOperations} EpochAppenderSelectionOperations
34
+ *
35
+ * @typedef {object} EpochSelectionReplyCollection
36
+ * @property {EpochAppendRequest} request Request copied when the wait starts.
37
+ * @property {function(): void} resolve Finishes the wait after this node is selected.
38
+ * @property {function(Error): void} reject Ends the wait with an election error.
39
+ * @property {Set<string>} pendingPeers Peers whose requests are still waiting for a reply.
40
+ * @property {Map<string, function(): void>} cancelPendingRequests Cancellation callback for each pending peer request.
41
+ * @property {Map<string, Error>} peerErrors Last request error for each peer.
42
+ * @property {Error|null} pollingError Last error while reading context or connecting to peers.
43
+ * @property {Set<string>} respondingPeers Peers that replied, including those that rejected the request.
44
+ * @property {boolean} isPolling True while the context and connection check is running.
45
+ * @property {ReturnType<typeof setTimeout>|null} retryTimer Timer for requesting missing votes again.
46
+ * @property {ReturnType<typeof setTimeout>|null} selectionDeadlineTimer Timer that ends this election attempt.
47
+ */
48
+
49
+ /**
50
+ * Selects which indexer may append the epoch. All rounds on this node share one selector. The selector
51
+ * saves votes locally. The round performs the append after selection.
52
+ *
53
+ * Each indexer saves one candidate per election term before sending GRANTED. The candidate counts its
54
+ * own vote and one vote from each peer in the signed membership. A vote can expire, but its candidate
55
+ * and term stay saved after a restart. Another candidate needs a higher term. A random delay helps
56
+ * nodes avoid starting elections at the same time.
57
+ *
58
+ * With two indexers, both votes are requested first. A node may continue alone if the other node does
59
+ * not reply before the timeout. Both nodes can therefore append if they lose contact.
60
+ *
61
+ * A new election can start while an earlier append is still running. The timeout does not cancel that
62
+ * write. The ledger still checks every submitted proof.
63
+ */
64
+ export class EpochAppenderSelector {
65
+ #localPublicKey;
66
+ #retryIntervalMs;
67
+ #selectionTimeoutMs;
68
+ #monitorAppendOutcome;
69
+ #maxElectionDelayMs;
70
+ #selectionOperations;
71
+ #openPromise;
72
+ #selectionStateQueue = Promise.resolve();
73
+ #selectionState = createSelectionState();
74
+ #grantedPeerVotes = new Set();
75
+ #electionDelayTimers = new Map();
76
+ #replyCollection = null;
77
+ #closed = false;
78
+ #persistenceError = null;
79
+ #completedRequest = null;
80
+ #outcomeMonitorTimer = null;
81
+ #outcomeMonitorPromise = null;
82
+ #appendOutcomeCheck = null;
83
+
84
+ /**
85
+ * Creates the selector shared by all rounds on this node. Its state is saved locally and loaded
86
+ * again after restart. The service that creates it is responsible for closing it.
87
+ *
88
+ * @param {string} localPublicKey Authenticated local wallet public key, encoded as hex.
89
+ * @param {EpochAppenderSelectionOperations} selectionOperations Local storage, signed ledger reads and peer requests.
90
+ * @param {number} [retryIntervalMs=1000] Delay between vote retries and background outcome checks.
91
+ * @param {number} [selectionTimeoutMs=20000] Timeout for selection and append progress in milliseconds.
92
+ * @param {boolean} [monitorAppendOutcome=false] Keep checking append results after the round ends.
93
+ * @param {number} [maxElectionDelayMs=0] Maximum random delay before saving a vote for this node.
94
+ */
95
+ constructor(localPublicKey, selectionOperations, retryIntervalMs = 1000, selectionTimeoutMs = 20_000, monitorAppendOutcome = false, maxElectionDelayMs = 0) {
96
+ this.#localPublicKey = localPublicKey;
97
+ this.#retryIntervalMs = retryIntervalMs;
98
+ this.#selectionTimeoutMs = selectionTimeoutMs;
99
+ this.#monitorAppendOutcome = monitorAppendOutcome;
100
+ this.#maxElectionDelayMs = maxElectionDelayMs;
101
+ this.#selectionOperations = selectionOperations;
102
+ }
103
+
104
+ /**
105
+ * Returns a copy of the current request, reserved payload and append details.
106
+ *
107
+ * @returns {import('./epochAppenderSelectionState.js').EpochAppenderSelectionReservation|null} Null when this node has no reservation.
108
+ */
109
+ get activeReservation() {
110
+ return copySelectionReservation(this.#selectionState.active);
111
+ }
112
+
113
+ /**
114
+ * Returns the saved result of the last completed request if its ID matches. This method does not
115
+ * read the ledger.
116
+ *
117
+ * @param {string} requestId ID of the selection request created by a round.
118
+ * @returns {EpochAppendOutcomeResult|null} Saved result if the request ID matches.
119
+ */
120
+ getRecordedAppendOutcome(requestId) {
121
+ if (this.#completedRequest?.requestId !== requestId) {
122
+ return null;
123
+ }
124
+ return { status: this.#completedRequest.outcome.status };
125
+ }
126
+
127
+ /**
128
+ * Loads saved votes and any append waiting for a result. Initialization runs once. If it fails,
129
+ * later calls return the same error and keep the saved state for inspection.
130
+ *
131
+ * @returns {Promise<void>} The same promise is returned on later calls.
132
+ */
133
+ open() {
134
+ if (!this.#openPromise) {
135
+ this.#openPromise = this.#recoverSelectionState();
136
+ }
137
+ return this.#openPromise;
138
+ }
139
+
140
+ /**
141
+ * Copies the epoch payload and creates a request for the next election term. The saved payload
142
+ * stays the same throughout this request.
143
+ *
144
+ * @param {EpochAppenderSelectionContext} signedSelectionContext Signed epoch, config and indexers used for this election.
145
+ * @param {Buffer} payload Complete SET_EPOCH operation to reserve for this request.
146
+ * @returns {Promise<EpochAppendRequest>}
147
+ * @throws {EpochAppenderSelectionError} If an earlier append still needs a result or saving fails.
148
+ */
149
+ async createSelectionRequest(signedSelectionContext, payload) {
150
+ const frozenPayload = b4a.from(payload);
151
+ const payloadHash = toHex(await tracCryptoApi.hash.blake3(frozenPayload));
152
+ if (signedSelectionContext.members.length > 1) {
153
+ await this.#waitBeforeElection();
154
+ }
155
+ const currentSignedContext = await this.#selectionOperations.readContext?.();
156
+ if (this.#selectionOperations.readContext && !sameSelectionContext(signedSelectionContext, currentSignedContext)) {
157
+ throw new EpochAppenderSelectionError(EpochAppenderSelectionErrorCode.CONTEXT_CHANGED, 'Epoch changed before starting the appender election');
158
+ }
159
+ return this.#persistStateChange(() => {
160
+ this.#recoverElectionClock(currentSignedContext);
161
+ this.#requireCurrentElectionEpoch(signedSelectionContext.targetEpoch);
162
+ if (this.#selectionState.active) {
163
+ throw new EpochAppenderSelectionError(EpochAppenderSelectionErrorCode.PUBLICATION_UNRESOLVED, 'A previous epoch publication is unresolved');
164
+ }
165
+ if (!signedSelectionContext.members.some(member => member.publicKey === this.#localPublicKey)) {
166
+ throw new EpochAppenderSelectionError(EpochAppenderSelectionErrorCode.NOT_IN_COHORT, 'Local indexer is not in this epoch coordination cohort');
167
+ }
168
+ const recordedVote = this.#selectionState.vote;
169
+ if (recordedVote && sameSelectionContext(recordedVote.request, signedSelectionContext) && recordedVote.expiresAt > Date.now()) {
170
+ throw new EpochAppenderSelectionError(EpochAppenderSelectionErrorCode.ELECTION_BUSY,
171
+ 'Waiting for the current appender election to finish or expire',
172
+ { details: { requester: recordedVote.request.requester, term: recordedVote.request.logicalCounter } });
173
+ }
174
+ const nextElectionCounter = this.#selectionState.clock + 1n;
175
+ if (nextElectionCounter > EPOCH_APPENDER_SELECTION_COUNTER_LIMIT) {
176
+ throw new EpochAppenderSelectionError(EpochAppenderSelectionErrorCode.LOGICAL_CLOCK_EXHAUSTED,
177
+ 'Epoch append clock exhausted; recovery requires a newer signed epoch');
178
+ }
179
+ this.#selectionState.clock = nextElectionCounter;
180
+ const selectionRequest = {
181
+ ...copySelectionContext(signedSelectionContext),
182
+ requestId: generateUUID(),
183
+ payloadHash,
184
+ logicalCounter: nextElectionCounter,
185
+ requester: this.#localPublicKey,
186
+ selectionVersion: EPOCH_APPENDER_SELECTION_VERSION,
187
+ };
188
+ const expiresAt = Date.now() + this.#selectionTimeoutMs;
189
+ this.#selectionState.vote = { request: copySelectionRequest(selectionRequest), expiresAt };
190
+ this.#grantedPeerVotes.clear();
191
+ this.#selectionState.active = {
192
+ request: selectionRequest,
193
+ payload: toHex(frozenPayload),
194
+ phase: signedSelectionContext.members.length === 1 ? EpochAppenderSelectionPhase.HELD : EpochAppenderSelectionPhase.REQUESTING,
195
+ publication: null,
196
+ expiresAt,
197
+ solo: false,
198
+ };
199
+ return copySelectionRequest(selectionRequest);
200
+ });
201
+ }
202
+
203
+ /**
204
+ * Saves the vote locally before sending a reply. The protocol handler must first check the sender
205
+ * identity and the signed election context.
206
+ *
207
+ * @param {EpochAppendRequest} selectionRequest Candidate, election term and payload hash.
208
+ * @param {function(import('./epochAppenderSelectionState.js').EpochAppendReply): Promise<void>} sendVoteReply Sends the reply after the vote is saved locally.
209
+ * @returns {Promise<boolean>} True when this node granted its vote.
210
+ */
211
+ async handleSelectionRequest(selectionRequest, sendVoteReply) {
212
+ await this.open();
213
+ let currentSignedContext;
214
+ if (this.#selectionState.clock >= EPOCH_APPENDER_SELECTION_COUNTER_LIMIT) {
215
+ currentSignedContext = await this.#selectionOperations.readContext?.();
216
+ if (currentSignedContext && !sameSelectionContext(selectionRequest, currentSignedContext)) {
217
+ throw new EpochAppenderSelectionError(EpochAppenderSelectionErrorCode.CONTEXT_CHANGED, 'Epoch changed before recovering the election clock');
218
+ }
219
+ }
220
+ const response = await this.#persistStateChange(() => {
221
+ this.#recoverElectionClock(currentSignedContext);
222
+ if (!this.#validateRequestAndAdvanceClock(selectionRequest)) {
223
+ return null;
224
+ }
225
+ const voteStatus = this.#recordVote(selectionRequest);
226
+ const activeReservation = this.#selectionState.active;
227
+ if (voteStatus === EpochAppenderVoteStatus.GRANTED && activeReservation &&
228
+ activeReservation.phase !== EpochAppenderSelectionPhase.PUBLISHING) {
229
+ this.#selectionState.active = null;
230
+ this.#grantedPeerVotes.clear();
231
+ }
232
+ return {
233
+ ...copySelectionRequest(selectionRequest),
234
+ voteStatus,
235
+ observedCounter: this.#selectionState.clock,
236
+ vote: copySelectionRequest(this.#selectionState.vote.request),
237
+ };
238
+ });
239
+ // A peer far ahead retries while this node catches up in bounded steps.
240
+ // Never save a vote whose term is beyond the local clock.
241
+ if (!response) {
242
+ return false;
243
+ }
244
+ if (this.#replyCollection && !this.#selectionState.active) {
245
+ this.#finishReplyCollection(this.#createSupersededElectionError());
246
+ }
247
+ this.#assertSelectorAvailable();
248
+ await sendVoteReply(response);
249
+ return response.voteStatus === EpochAppenderVoteStatus.GRANTED;
250
+ }
251
+
252
+ /**
253
+ * Handles a peer vote for the current request. Each peer counts once. A reply with a higher
254
+ * election term ends the current selection attempt.
255
+ *
256
+ * @param {import('./epochAppenderSelectionState.js').EpochAppendReply} selectionReply Validated reply with the peer's saved vote.
257
+ * @param {string} peerPublicKey Verified wallet public key of the peer, encoded as hex.
258
+ * @returns {Promise<boolean>} True when this node has enough votes.
259
+ */
260
+ async handleSelectionReply(selectionReply, peerPublicKey) {
261
+ let failure;
262
+ const selectionGranted = await this.#persistStateChange(() => {
263
+ const activeReservation = this.#selectionState.active;
264
+ if (!activeReservation || activeReservation.phase !== EpochAppenderSelectionPhase.REQUESTING || !sameSelectionRequest(activeReservation.request, selectionReply)) {
265
+ return false;
266
+ }
267
+ const isMember = activeReservation.request.members.some(member => member.publicKey === peerPublicKey);
268
+ if (peerPublicKey === this.#localPublicKey || !isMember) {
269
+ throw new EpochAppenderSelectionError(EpochAppenderSelectionErrorCode.UNEXPECTED_PEER, 'Reply from an unexpected epoch member');
270
+ }
271
+ if (!isValidPeerSelectionCounter(selectionReply.observedCounter) ||
272
+ !isValidPeerSelectionCounter(selectionReply.vote?.logicalCounter) ||
273
+ !selectionReply.vote || selectionReply.vote.networkId !== activeReservation.request.networkId ||
274
+ selectionReply.vote.bootstrap !== activeReservation.request.bootstrap ||
275
+ selectionReply.observedCounter < selectionReply.logicalCounter ||
276
+ selectionReply.observedCounter < selectionReply.vote.logicalCounter) {
277
+ throw new EpochAppenderSelectionError(EpochAppenderSelectionErrorCode.REQUEST_STALE, 'Invalid appender vote response');
278
+ }
279
+ this.#replyCollection?.respondingPeers.add(peerPublicKey);
280
+ this.#observePeerCounter(selectionReply.observedCounter);
281
+ if (selectionReply.vote.logicalCounter > activeReservation.request.logicalCounter) {
282
+ failure = this.#createSupersededElectionError();
283
+ this.#releaseReservation(activeReservation.request);
284
+ return false;
285
+ }
286
+ if (selectionReply.voteStatus !== EpochAppenderVoteStatus.GRANTED ||
287
+ !sameSelectionRequest(selectionReply.vote, activeReservation.request) ||
288
+ !sameSelectionRequest(this.#selectionState.vote?.request, activeReservation.request) ||
289
+ activeReservation.expiresAt <= Date.now()) {
290
+ this.#replyCollection?.peerErrors.set(peerPublicKey, new EpochAppenderSelectionError(
291
+ EpochAppenderSelectionErrorCode.ELECTION_BUSY,
292
+ `Vote unavailable; candidate=${selectionReply.vote.requester}; term=${selectionReply.vote.logicalCounter}`
293
+ ));
294
+ const unansweredPeerCount = activeReservation.request.members.length - 1 - (this.#replyCollection?.respondingPeers.size ?? 0);
295
+ if (this.#replyCollection && 1 + this.#grantedPeerVotes.size + unansweredPeerCount < calculateAppenderVoteQuorum(activeReservation.request.members.length)) {
296
+ failure = new EpochAppenderSelectionError(EpochAppenderSelectionErrorCode.REPLIES_INCOMPLETE,
297
+ 'This appender election cannot reach a majority; retry in a higher term');
298
+ this.#releaseReservation(activeReservation.request);
299
+ }
300
+ return false;
301
+ }
302
+ this.#replyCollection?.peerErrors.delete(peerPublicKey);
303
+ this.#grantedPeerVotes.add(peerPublicKey);
304
+ if (this.#grantedPeerVotes.size + 1 >= calculateAppenderVoteQuorum(activeReservation.request.members.length)) {
305
+ activeReservation.phase = EpochAppenderSelectionPhase.HELD;
306
+ }
307
+ return activeReservation.phase === EpochAppenderSelectionPhase.HELD;
308
+ });
309
+ if (selectionGranted) {
310
+ this.#finishReplyCollection();
311
+ } else if (this.#replyCollection && !this.#selectionState.active) {
312
+ this.#finishReplyCollection(failure ?? this.#createSupersededElectionError());
313
+ }
314
+ return selectionGranted;
315
+ }
316
+
317
+ /**
318
+ * Cancels a local selection request before the append starts. Once PUBLISHING is saved, the
319
+ * request stays active because its payload may already be in the writer log.
320
+ *
321
+ * @param {string} requestId ID of the request to cancel.
322
+ * @param {Error} [cancellationReason] Error delivered to the waiting round.
323
+ * @returns {Promise<boolean>} False if the request is no longer active or its append has started.
324
+ */
325
+ async cancel(requestId, cancellationReason) {
326
+ const reservationReleased = await this.#persistStateChange(() => {
327
+ const activeReservation = this.#selectionState.active;
328
+ if (!activeReservation || activeReservation.request.requestId !== requestId || activeReservation.phase === EpochAppenderSelectionPhase.PUBLISHING) {
329
+ return false;
330
+ }
331
+ this.#releaseReservation(activeReservation.request);
332
+ return true;
333
+ });
334
+ if (reservationReleased) {
335
+ const error = cancellationReason ?? new EpochAppenderSelectionError(
336
+ EpochAppenderSelectionErrorCode.REQUEST_CANCELLED,
337
+ 'Epoch append request cancelled',
338
+ { details: { requestId } }
339
+ );
340
+ this.#finishReplyCollection(error);
341
+ }
342
+ return reservationReleased;
343
+ }
344
+
345
+ /**
346
+ * Returns a copy of the selected SET_EPOCH payload while the selection is still valid. The caller
347
+ * must also authorize the append inside the write queue.
348
+ *
349
+ * @param {string} requestId ID of the selected request.
350
+ * @returns {Promise<Buffer>} Copy of the selected payload.
351
+ * @throws {EpochAppenderSelectionError} If the selection is missing, replaced or expired.
352
+ */
353
+ async getSelectedEpochPayload(requestId) {
354
+ await this.open();
355
+ this.#assertSelectorAvailable();
356
+ const activeReservation = this.#requireSelectedReservation(requestId);
357
+ return b4a.from(activeReservation.payload, 'hex');
358
+ }
359
+
360
+ /**
361
+ * Checks the selection again inside the State write queue and saves the append details. The caller
362
+ * can append after this method finishes. A later election may replace this selection, but it
363
+ * cannot cancel a write that has already started.
364
+ *
365
+ * @param {string} requestId ID of the selected request.
366
+ * @param {{writerKey: string, from: number}} appendLocation Writer key and its length before the append.
367
+ * @returns {Promise<void>}
368
+ */
369
+ async authorizeEpochAppend(requestId, appendLocation) {
370
+ await this.open();
371
+ this.#assertSelectorAvailable();
372
+ const activeReservation = this.#requireSelectedReservation(requestId);
373
+ await this.#validateAppendContext(activeReservation.request, appendLocation.writerKey);
374
+ await this.#persistStateChange(() => {
375
+ if (this.#selectionState.active !== activeReservation || activeReservation.phase !== EpochAppenderSelectionPhase.HELD) {
376
+ throw new EpochAppenderSelectionError(EpochAppenderSelectionErrorCode.REQUEST_STALE, 'Epoch append request expired before publication');
377
+ }
378
+ this.#requireSelectedReservation(requestId);
379
+ activeReservation.phase = EpochAppenderSelectionPhase.PUBLISHING;
380
+ activeReservation.publication = {
381
+ writerKey: appendLocation.writerKey,
382
+ from: appendLocation.from,
383
+ payloadHash: activeReservation.request.payloadHash,
384
+ targetEpoch: activeReservation.request.targetEpoch,
385
+ };
386
+ });
387
+ this.#assertSelectorAvailable();
388
+ if (activeReservation.expiresAt <= Date.now() || !sameSelectionRequest(this.#selectionState.vote?.request, activeReservation.request)) {
389
+ throw this.#createSupersededElectionError();
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Checks the signed append result or expires the selection so a new election can start. An expired
395
+ * selection does not mean the old append was cancelled. Calls for the same append share one
396
+ * pending read.
397
+ *
398
+ * @returns {Promise<EpochAppendOutcomeResult|null>} Null if there is no pending append or another request replaced it.
399
+ */
400
+ async refreshAppendOutcome() {
401
+ await this.open();
402
+ this.#assertSelectorAvailable();
403
+ const activeReservation = this.#selectionState.active;
404
+ if (!activeReservation || activeReservation.phase !== EpochAppenderSelectionPhase.PUBLISHING) {
405
+ return null;
406
+ }
407
+ if (this.#appendOutcomeCheck?.active === activeReservation) {
408
+ return this.#appendOutcomeCheck.promise;
409
+ }
410
+ const outcomeCheck = { active: activeReservation, promise: null };
411
+ outcomeCheck.promise = this.#resolvePublicationOutcome(activeReservation).finally(() => {
412
+ if (this.#appendOutcomeCheck === outcomeCheck) {
413
+ this.#appendOutcomeCheck = null;
414
+ }
415
+ });
416
+ this.#appendOutcomeCheck = outcomeCheck;
417
+ return outcomeCheck.promise;
418
+ }
419
+
420
+ /**
421
+ * Waits until the local vote and peer replies select this candidate. With two indexers, the node
422
+ * may continue alone after a timeout if the peer has not replied. A rejection does not allow this
423
+ * fallback. Cancellation, context changes and missing votes end the wait.
424
+ *
425
+ * @param {EpochAppendRequest} selectionRequest Request created by createSelectionRequest().
426
+ * @returns {Promise<void>} Resolves when this node is selected.
427
+ */
428
+ async waitForAppenderSelection(selectionRequest) {
429
+ await this.open();
430
+ this.#assertSelectorAvailable();
431
+ if (this.#selectionState.active?.request.requestId !== selectionRequest.requestId) {
432
+ throw new EpochAppenderSelectionError(EpochAppenderSelectionErrorCode.REQUEST_STALE, 'Stale epoch append request');
433
+ }
434
+ if (this.#selectionState.active.phase === EpochAppenderSelectionPhase.HELD) {
435
+ return;
436
+ }
437
+ if (this.#replyCollection) {
438
+ throw new EpochAppenderSelectionError(EpochAppenderSelectionErrorCode.ALREADY_WAITING, 'Already waiting for epoch append replies');
439
+ }
440
+ return new Promise((resolve, reject) => {
441
+ const replyCollection = this.#createReplyCollection(selectionRequest, resolve, reject);
442
+ this.#replyCollection = replyCollection;
443
+ replyCollection.selectionDeadlineTimer = setTimeout(() => {
444
+ this.#handleSelectionTimeout(replyCollection)
445
+ .catch(error => {
446
+ if (this.#replyCollection === replyCollection) {
447
+ this.#finishReplyCollection(error);
448
+ }
449
+ });
450
+ }, Math.max(0, this.#selectionState.active.expiresAt - Date.now()));
451
+ this.#requestMissingVotes(replyCollection);
452
+ });
453
+ }
454
+
455
+ /**
456
+ * Stops timers, rejects pending selection requests and waits for storage and outcome reads to
457
+ * finish. Saved votes and append details remain available after restart.
458
+ *
459
+ * @returns {Promise<void>}
460
+ */
461
+ async close() {
462
+ this.#closed = true;
463
+ for (const [timer, reject] of this.#electionDelayTimers) {
464
+ clearTimeout(timer);
465
+ reject(new EpochAppenderSelectionError(EpochAppenderSelectionErrorCode.SELECTOR_CLOSED, 'Epoch appender selector closed'));
466
+ }
467
+ this.#electionDelayTimers.clear();
468
+ clearInterval(this.#outcomeMonitorTimer);
469
+ this.#finishReplyCollection(new EpochAppenderSelectionError(EpochAppenderSelectionErrorCode.SELECTOR_CLOSED, 'Epoch appender selector closed'));
470
+ await Promise.allSettled([
471
+ this.#openPromise, this.#outcomeMonitorPromise, this.#appendOutcomeCheck?.promise, this.#selectionStateQueue,
472
+ ]);
473
+ }
474
+
475
+ /**
476
+ * Loads and checks the saved state, then updates older records to the current format. Clears
477
+ * requests that never started an append. Keeps saved votes and resumes checking any append that
478
+ * still needs a result.
479
+ *
480
+ * @returns {Promise<void>}
481
+ */
482
+ async #recoverSelectionState() {
483
+ const storedSelectionState = await this.#selectionOperations.load();
484
+ let selectionStateChanged = false;
485
+ if (storedSelectionState) {
486
+ validateSelectionState(storedSelectionState, this.#localPublicKey);
487
+ this.#selectionState = upgradeSelectionState(storedSelectionState, Date.now(), this.#selectionTimeoutMs);
488
+ validateSelectionState(this.#selectionState, this.#localPublicKey);
489
+ selectionStateChanged = storedSelectionState.version !== this.#selectionState.version;
490
+ }
491
+ // Without the durable PUBLISHING marker no append could have started.
492
+ // Keep the durable vote across restart even when a local campaign is withdrawn.
493
+ if (this.#selectionState.active && this.#selectionState.active.phase !== EpochAppenderSelectionPhase.PUBLISHING) {
494
+ this.#selectionState.active = null;
495
+ selectionStateChanged = true;
496
+ }
497
+ selectionStateChanged = this.#recoverElectionClock(undefined, true) || selectionStateChanged;
498
+ if (selectionStateChanged) {
499
+ await this.#saveSelectionState();
500
+ }
501
+ this.#startAppendOutcomeMonitor();
502
+ }
503
+
504
+ /**
505
+ * Checks append results even after the round has ended. The monitor and the round share the same
506
+ * pending read for each append.
507
+ *
508
+ * @returns {void}
509
+ */
510
+ #startAppendOutcomeMonitor() {
511
+ if (!this.#monitorAppendOutcome || this.#closed) {
512
+ return;
513
+ }
514
+ this.#outcomeMonitorTimer = setInterval(() => {
515
+ if (this.#outcomeMonitorPromise || this.#closed || this.#persistenceError) {
516
+ return;
517
+ }
518
+ if (this.#selectionState.active?.phase !== EpochAppenderSelectionPhase.PUBLISHING) {
519
+ return;
520
+ }
521
+ this.#outcomeMonitorPromise = this.refreshAppendOutcome()
522
+ .catch(error => this.#selectionOperations.onError?.(error))
523
+ .finally(() => {
524
+ this.#outcomeMonitorPromise = null;
525
+ });
526
+ }, this.#retryIntervalMs);
527
+ }
528
+
529
+ /**
530
+ * Stops work after shutdown or a storage error. A failed save may still have reached the disk, so
531
+ * another vote could conflict with the saved state.
532
+ *
533
+ * @throws {EpochAppenderSelectionError} If the selector is closed or an earlier save failed.
534
+ */
535
+ #assertSelectorAvailable() {
536
+ if (this.#persistenceError) {
537
+ throw this.#persistenceError;
538
+ }
539
+ if (this.#closed) {
540
+ throw new EpochAppenderSelectionError(EpochAppenderSelectionErrorCode.SELECTOR_CLOSED, 'Epoch appender selector is closed');
541
+ }
542
+ }
543
+
544
+ /**
545
+ * Saves a copy of the local selection state. A storage error blocks further work because the write
546
+ * may have reached the disk even though saving failed.
547
+ *
548
+ * @returns {Promise<void>}
549
+ */
550
+ async #saveSelectionState() {
551
+ try {
552
+ await this.#selectionOperations.save(copySelectionState(this.#selectionState));
553
+ } catch (cause) {
554
+ // The write may have succeeded. Never grant a reply or publish after uncertainty.
555
+ this.#persistenceError = new EpochAppenderSelectionError(
556
+ EpochAppenderSelectionErrorCode.PERSISTENCE_FAILED,
557
+ 'Cannot persist epoch appender selection state',
558
+ { cause }
559
+ );
560
+ throw this.#persistenceError;
561
+ }
562
+ }
563
+
564
+ /**
565
+ * Queues one local state change and saves it before returning its result. Network requests, ledger
566
+ * reads and appends run outside this queue.
567
+ *
568
+ * @template T
569
+ * @param {function(): T} applyStateChange Callback that changes the local selection state without awaiting other work.
570
+ * @returns {Promise<T>} Callback result after the state is saved.
571
+ */
572
+ #persistStateChange(applyStateChange) {
573
+ const operation = this.#selectionStateQueue.then(async () => {
574
+ await this.open();
575
+ this.#assertSelectorAvailable();
576
+ const result = applyStateChange();
577
+ await this.#saveSelectionState();
578
+ return result;
579
+ });
580
+ // Keep the queue usable; the caller receives the original rejected operation.
581
+ this.#selectionStateQueue = operation.catch(() => {});
582
+ return operation;
583
+ }
584
+
585
+ /**
586
+ * Waits for a random delay before starting an election. This helps nodes avoid voting for
587
+ * themselves at the same time. Closing the selector cancels the wait.
588
+ *
589
+ * @returns {Promise<void>}
590
+ */
591
+ #waitBeforeElection() {
592
+ const delay = Math.floor(Math.random() * this.#maxElectionDelayMs);
593
+ if (!delay) return Promise.resolve();
594
+ return new Promise((resolve, reject) => {
595
+ const timer = setTimeout(() => {
596
+ this.#electionDelayTimers.delete(timer);
597
+ resolve();
598
+ }, delay);
599
+ this.#electionDelayTimers.set(timer, reject);
600
+ });
601
+ }
602
+
603
+ /**
604
+ * Checks saved votes and returns GRANTED, BUSY or STALE. When a vote expires, another candidate
605
+ * still needs a higher term. The caller saves the decision before replying.
606
+ *
607
+ * @param {EpochAppendRequest} selectionRequest Authenticated candidate request.
608
+ * @returns {number} One of EpochAppenderVoteStatus. Save it before sending the reply.
609
+ */
610
+ #recordVote(selectionRequest) {
611
+ const recordedVote = this.#selectionState.vote;
612
+ if (recordedVote) {
613
+ if (sameSelectionRequest(recordedVote.request, selectionRequest)) {
614
+ return recordedVote.expiresAt > Date.now() ? EpochAppenderVoteStatus.GRANTED : EpochAppenderVoteStatus.STALE;
615
+ }
616
+ if (selectionRequest.logicalCounter <= recordedVote.request.logicalCounter) {
617
+ return EpochAppenderVoteStatus.STALE;
618
+ }
619
+ if (sameSelectionContext(recordedVote.request, selectionRequest) && recordedVote.expiresAt > Date.now()) {
620
+ return EpochAppenderVoteStatus.BUSY;
621
+ }
622
+ }
623
+ this.#selectionState.vote = {
624
+ request: copySelectionRequest(selectionRequest), expiresAt: Date.now() + this.#selectionTimeoutMs,
625
+ };
626
+ return EpochAppenderVoteStatus.GRANTED;
627
+ }
628
+
629
+ /**
630
+ * Creates an error when another election has replaced the local request.
631
+ *
632
+ * @returns {EpochAppenderSelectionError}
633
+ */
634
+ #createSupersededElectionError() {
635
+ return new EpochAppenderSelectionError(EpochAppenderSelectionErrorCode.ELECTION_SUPERSEDED,
636
+ 'A newer appender election superseded this request');
637
+ }
638
+
639
+ /**
640
+ * Checks the request version, members and counter. Moves the local clock towards the peer term
641
+ * by at most one step. A request further ahead must retry before this node can vote for it.
642
+ *
643
+ * @param {EpochAppendRequest} selectionRequest Validated network request.
644
+ * @throws {EpochAppenderSelectionError} Invalid identity, membership or counter.
645
+ * @returns {boolean} True when the requested term is within the updated local clock.
646
+ */
647
+ #validateRequestAndAdvanceClock(selectionRequest) {
648
+ if (selectionRequest.selectionVersion !== EPOCH_APPENDER_SELECTION_VERSION) {
649
+ throw new EpochAppenderSelectionError(EpochAppenderSelectionErrorCode.REQUEST_STALE, 'Unsupported appender election version');
650
+ }
651
+ if (selectionRequest.requester === this.#localPublicKey) {
652
+ throw new EpochAppenderSelectionError(EpochAppenderSelectionErrorCode.UNEXPECTED_PEER, 'Self append request');
653
+ }
654
+ const includesLocal = selectionRequest.members?.some(member => member.publicKey === this.#localPublicKey);
655
+ const includesRequester = selectionRequest.members?.some(member => member.publicKey === selectionRequest.requester);
656
+ if (!includesLocal || !includesRequester) {
657
+ throw new EpochAppenderSelectionError(EpochAppenderSelectionErrorCode.NOT_IN_COHORT, 'Append request does not include both participants');
658
+ }
659
+ this.#requireCurrentElectionEpoch(selectionRequest.targetEpoch);
660
+ if (!isValidPeerSelectionCounter(selectionRequest.logicalCounter)) {
661
+ throw new EpochAppenderSelectionError(EpochAppenderSelectionErrorCode.LOGICAL_COUNTER_INVALID, 'Invalid append counter');
662
+ }
663
+ if (this.#selectionState.clock > EPOCH_APPENDER_SELECTION_COUNTER_LIMIT) {
664
+ throw new EpochAppenderSelectionError(EpochAppenderSelectionErrorCode.LOGICAL_CLOCK_EXHAUSTED,
665
+ 'Epoch append clock exhausted; recovery requires a newer signed epoch');
666
+ }
667
+ return this.#observePeerCounter(selectionRequest.logicalCounter);
668
+ }
669
+
670
+ /**
671
+ * Returns the HELD reservation if the request matches and the local vote is still valid.
672
+ *
673
+ * @param {string} requestId Local selection identity.
674
+ * @returns {import('./epochAppenderSelectionState.js').EpochAppenderSelectionReservation} Internal reservation. Do not return it to external callers.
675
+ * @throws {EpochAppenderSelectionError} If the selection is missing, expired or replaced.
676
+ */
677
+ #requireSelectedReservation(requestId) {
678
+ const activeReservation = this.#selectionState.active;
679
+ if (!activeReservation || activeReservation.phase !== EpochAppenderSelectionPhase.HELD || activeReservation.request.requestId !== requestId) {
680
+ throw new EpochAppenderSelectionError(EpochAppenderSelectionErrorCode.REPLIES_INCOMPLETE, 'Epoch append replies have not been collected');
681
+ }
682
+ if (activeReservation.expiresAt <= Date.now() || !sameSelectionRequest(this.#selectionState.vote?.request, activeReservation.request)) {
683
+ throw this.#createSupersededElectionError();
684
+ }
685
+ return activeReservation;
686
+ }
687
+
688
+ /**
689
+ * Reads the signed context again before authorizing the append in the write queue. The epoch,
690
+ * consensus config and indexers must still match the selected request.
691
+ *
692
+ * @param {EpochAppendRequest} selectionRequest Selection being used by the append.
693
+ * @param {string} writerKey Actual local writer receiving the payload.
694
+ * @returns {Promise<void>}
695
+ */
696
+ async #validateAppendContext(selectionRequest, writerKey) {
697
+ if (!this.#selectionOperations.readContext) {
698
+ return;
699
+ }
700
+ const signedSelectionContext = await this.#selectionOperations.readContext();
701
+ const sameWriters = sameWriterKeys(signedSelectionContext.currentWriterKeys, selectionRequest.currentWriterKeys);
702
+ const includesWriter = signedSelectionContext.currentWriterKeys.includes(writerKey);
703
+ if (!sameSelectionContext(signedSelectionContext, selectionRequest) || !sameWriters || !includesWriter) {
704
+ throw new EpochAppenderSelectionError(EpochAppenderSelectionErrorCode.CONTEXT_CHANGED, 'Signed context changed in the epoch append queue');
705
+ }
706
+ }
707
+
708
+ /**
709
+ * Expires the local vote while keeping its candidate and election term.
710
+ *
711
+ * @param {EpochAppendRequest} selectionRequest Request whose vote should expire.
712
+ */
713
+ #expireLocalVote(selectionRequest) {
714
+ const recordedVote = this.#selectionState.vote;
715
+ if (recordedVote && sameSelectionRequest(recordedVote.request, selectionRequest)) {
716
+ recordedVote.expiresAt = Math.min(recordedVote.expiresAt, Date.now());
717
+ }
718
+ }
719
+
720
+ /**
721
+ * Allows a node in a pair to continue alone only if the other node has not replied. Otherwise,
722
+ * cancels the selection attempt and reports which votes are missing.
723
+ *
724
+ * @param {EpochSelectionReplyCollection} replyCollection Reply collection that reached its timeout.
725
+ * @returns {Promise<void>}
726
+ */
727
+ async #handleSelectionTimeout(replyCollection) {
728
+ if (this.#replyCollection !== replyCollection) return;
729
+ if (replyCollection.request.members.length === TWO_INDEXER_COHORT_SIZE && replyCollection.respondingPeers.size === 0) {
730
+ const signedSelectionContext = await this.#selectionOperations.readContext();
731
+ const selectionGranted = await this.#persistStateChange(() => {
732
+ const activeReservation = this.#selectionState.active;
733
+ if (this.#replyCollection !== replyCollection || !activeReservation || activeReservation.phase !== EpochAppenderSelectionPhase.REQUESTING ||
734
+ !sameSelectionRequest(activeReservation.request, replyCollection.request) || !sameSelectionContext(signedSelectionContext, replyCollection.request) ||
735
+ !sameSelectionRequest(this.#selectionState.vote?.request, replyCollection.request)) return false;
736
+ activeReservation.phase = EpochAppenderSelectionPhase.HELD;
737
+ activeReservation.solo = true;
738
+ activeReservation.expiresAt = Date.now() + this.#selectionTimeoutMs;
739
+ this.#selectionState.vote.expiresAt = activeReservation.expiresAt;
740
+ return true;
741
+ });
742
+ if (selectionGranted) {
743
+ this.#finishReplyCollection();
744
+ return;
745
+ }
746
+ }
747
+ const timeoutError = this.#createSelectionTimeoutError(replyCollection);
748
+ const reservationReleased = await this.#persistStateChange(() => {
749
+ const activeReservation = this.#selectionState.active;
750
+ if (this.#replyCollection !== replyCollection || !activeReservation || activeReservation.phase !== EpochAppenderSelectionPhase.REQUESTING ||
751
+ !sameSelectionRequest(activeReservation.request, replyCollection.request)) return false;
752
+ this.#releaseReservation(activeReservation.request);
753
+ return true;
754
+ });
755
+ if (reservationReleased) this.#finishReplyCollection(timeoutError);
756
+ }
757
+
758
+ /**
759
+ * Creates the reply collection for one election, including its timers and pending requests.
760
+ *
761
+ * @param {EpochAppendRequest} selectionRequest Request used throughout this election.
762
+ * @param {function(): void} resolve Completes the waiting round after selection.
763
+ * @param {function(Error): void} reject Reports election failure to that round.
764
+ * @returns {EpochSelectionReplyCollection}
765
+ */
766
+ #createReplyCollection(selectionRequest, resolve, reject) {
767
+ return {
768
+ request: copySelectionRequest(selectionRequest),
769
+ resolve,
770
+ reject,
771
+ pendingPeers: new Set(),
772
+ cancelPendingRequests: new Map(),
773
+ peerErrors: new Map(),
774
+ respondingPeers: new Set(),
775
+ pollingError: null,
776
+ isPolling: false,
777
+ retryTimer: null,
778
+ selectionDeadlineTimer: null,
779
+ };
780
+ }
781
+
782
+ /**
783
+ * Builds a timeout error with the missing votes and recent connection errors.
784
+ *
785
+ * @param {EpochSelectionReplyCollection} replyCollection Replies and errors collected before the timeout.
786
+ * @returns {EpochAppenderSelectionError}
787
+ */
788
+ #createSelectionTimeoutError(replyCollection) {
789
+ const peers = replyCollection.request.members.filter(member => member.publicKey !== this.#localPublicKey);
790
+ const missing = peers.filter(member => !this.#grantedPeerVotes.has(member.publicKey));
791
+ const missingPeers = missing.map(({ publicKey }) => ({ publicKey, error: replyCollection.peerErrors.get(publicKey) ?? null }));
792
+ const missingDescription = missingPeers.map(({ publicKey, error }) => {
793
+ const peerErrorDescription = error ? String(error.message).slice(0, 200) : 'no reply received';
794
+ return `${publicKey} (${peerErrorDescription})`;
795
+ }).join(', ');
796
+ const pollingDescription = replyCollection.pollingError ? `; polling: ${String(replyCollection.pollingError.message).slice(0, 200)}` : '';
797
+ const received = peers.length - missing.length;
798
+ const required = calculateAppenderVoteQuorum(replyCollection.request.members.length) - 1;
799
+ return new EpochAppenderSelectionError(
800
+ EpochAppenderSelectionErrorCode.REPLY_TIMEOUT,
801
+ `Epoch append replies timed out after ${this.#selectionTimeoutMs} ms; ` +
802
+ `epoch=${replyCollection.request.targetEpoch}; replies=${received}/${required}; ` +
803
+ `missing: ${missingDescription}${pollingDescription}`,
804
+ {
805
+ details: {
806
+ requestId: replyCollection.request.requestId,
807
+ targetEpoch: replyCollection.request.targetEpoch,
808
+ timeoutMs: this.#selectionTimeoutMs,
809
+ received,
810
+ required,
811
+ missingPeers,
812
+ pollError: replyCollection.pollingError,
813
+ },
814
+ }
815
+ );
816
+ }
817
+
818
+ /**
819
+ * Checks the signed context, connects to indexers and requests missing votes. Requests run
820
+ * independently. A reply is ignored if its collection is no longer active.
821
+ *
822
+ * @param {EpochSelectionReplyCollection} replyCollection Reply collection for the current election.
823
+ * @returns {Promise<void>}
824
+ */
825
+ async #requestMissingVotes(replyCollection) {
826
+ if (this.#replyCollection !== replyCollection || replyCollection.isPolling || this.#closed) {
827
+ return;
828
+ }
829
+ replyCollection.isPolling = true;
830
+ try {
831
+ const signedSelectionContext = await this.#selectionOperations.readContext();
832
+ if (this.#replyCollection !== replyCollection) {
833
+ return;
834
+ }
835
+ if (!sameSelectionContext(signedSelectionContext, replyCollection.request)) {
836
+ await this.cancel(replyCollection.request.requestId, new EpochAppenderSelectionError(
837
+ EpochAppenderSelectionErrorCode.CONTEXT_CHANGED,
838
+ 'Signed epoch append context changed while waiting for replies'
839
+ ));
840
+ return;
841
+ }
842
+ await this.#selectionOperations.connect?.();
843
+ if (this.#replyCollection !== replyCollection) {
844
+ return;
845
+ }
846
+ replyCollection.pollingError = null;
847
+ for (const { publicKey } of replyCollection.request.members) {
848
+ if (publicKey === this.#localPublicKey || this.#grantedPeerVotes.has(publicKey) || replyCollection.pendingPeers.has(publicKey)) {
849
+ continue;
850
+ }
851
+ replyCollection.pendingPeers.add(publicKey);
852
+ // Each RPC proceeds independently; deferred replies must not block other peers.
853
+ this.#requestPeerVote(replyCollection, publicKey);
854
+ }
855
+ } catch (error) {
856
+ replyCollection.pollingError = error;
857
+ this.#selectionOperations.onError?.(error);
858
+ } finally {
859
+ replyCollection.isPolling = false;
860
+ if (this.#replyCollection === replyCollection) {
861
+ replyCollection.retryTimer = setTimeout(() => this.#requestMissingVotes(replyCollection), this.#retryIntervalMs);
862
+ }
863
+ }
864
+ }
865
+
866
+ /**
867
+ * Requests one peer vote and passes its reply to the selector.
868
+ *
869
+ * @param {EpochSelectionReplyCollection} replyCollection Reply collection that tracks this peer request.
870
+ * @param {string} peerPublicKey Target wallet public key, encoded as hex.
871
+ * @returns {Promise<void>}
872
+ */
873
+ async #requestPeerVote(replyCollection, peerPublicKey) {
874
+ try {
875
+ const response = await this.#selectionOperations.sendRequest(peerPublicKey, replyCollection.request, {
876
+ isActive: () => this.#replyCollection === replyCollection,
877
+ onPending: cancel => replyCollection.cancelPendingRequests.set(peerPublicKey, cancel),
878
+ });
879
+ if (this.#replyCollection === replyCollection && response) {
880
+ await this.handleSelectionReply(response, peerPublicKey);
881
+ }
882
+ } catch (error) {
883
+ if (this.#replyCollection === replyCollection) {
884
+ replyCollection.peerErrors.set(peerPublicKey, error);
885
+ this.#selectionOperations.onError?.(error);
886
+ }
887
+ } finally {
888
+ replyCollection.pendingPeers.delete(peerPublicKey);
889
+ replyCollection.cancelPendingRequests.delete(peerPublicKey);
890
+ }
891
+ }
892
+
893
+ /**
894
+ * Clears the election timers and pending requests, then resolves or rejects the waiting round.
895
+ *
896
+ * @param {Error} [error] Error returned to the round. Omit it after successful selection.
897
+ */
898
+ #finishReplyCollection(error) {
899
+ const replyCollection = this.#replyCollection;
900
+ if (!replyCollection) {
901
+ return;
902
+ }
903
+ this.#replyCollection = null;
904
+ clearTimeout(replyCollection.selectionDeadlineTimer);
905
+ clearTimeout(replyCollection.retryTimer);
906
+ for (const cancel of replyCollection.cancelPendingRequests.values()) {
907
+ cancel();
908
+ }
909
+ replyCollection.cancelPendingRequests.clear();
910
+ if (error) {
911
+ replyCollection.reject(error);
912
+ } else {
913
+ replyCollection.resolve();
914
+ }
915
+ }
916
+
917
+ /**
918
+ * Releases the local payload and expires its vote. Keeps the candidate identity and the highest
919
+ * known election term.
920
+ *
921
+ * @param {EpochAppendRequest} selectionRequest Reservation whose vote expires.
922
+ */
923
+ #releaseReservation(selectionRequest) {
924
+ this.#selectionState.active = null;
925
+ this.#expireLocalVote(selectionRequest);
926
+ this.#grantedPeerVotes.clear();
927
+ }
928
+
929
+ /**
930
+ * Reads the result for this append and releases the matching reservation when it is finished or
931
+ * expired. A signed result takes priority over expiry. Missing data and read errors do not prove
932
+ * rejection. The old append may still finish after the selection expires.
933
+ *
934
+ * @param {import('./epochAppenderSelectionState.js').EpochAppenderSelectionReservation} activeReservation Reservation being checked when the ledger read starts.
935
+ * @returns {Promise<EpochAppendOutcomeResult|null>} Null if another reservation replaced it.
936
+ */
937
+ async #resolvePublicationOutcome(activeReservation) {
938
+ let result = { status: EpochAppendOutcome.UNKNOWN };
939
+ let readError;
940
+ try {
941
+ result = await this.#selectionOperations.readOutcome(copyAppendPublication(activeReservation.publication));
942
+ } catch (error) {
943
+ readError = error;
944
+ }
945
+ if (this.#selectionState.active !== activeReservation) {
946
+ return null;
947
+ }
948
+ const finalOutcome = result.status === EpochAppendOutcome.REJECTED ||
949
+ result.status === EpochAppendOutcome.EPOCH_ADVANCED;
950
+ const selectionExpired = activeReservation.expiresAt <= Date.now() ||
951
+ !sameSelectionRequest(this.#selectionState.vote?.request, activeReservation.request);
952
+ // A failed ledger read cannot extend the election deadline or prove rejection.
953
+ if (readError && !selectionExpired) {
954
+ throw readError;
955
+ }
956
+ if (!finalOutcome && !selectionExpired) {
957
+ return { status: EpochAppendOutcome.UNKNOWN };
958
+ }
959
+ const outcome = { status: finalOutcome ? result.status : EpochAppendOutcome.SELECTION_EXPIRED };
960
+ const reservationReleased = await this.#persistStateChange(() => {
961
+ if (this.#selectionState.active !== activeReservation) {
962
+ return false;
963
+ }
964
+ this.#completedRequest = { requestId: activeReservation.request.requestId, outcome };
965
+ this.#releaseReservation(activeReservation.request);
966
+ return true;
967
+ });
968
+ if (!reservationReleased) {
969
+ return null;
970
+ }
971
+ if (readError) {
972
+ this.#selectionOperations.onError?.(readError);
973
+ }
974
+ return { status: outcome.status };
975
+ }
976
+
977
+ /**
978
+ * Moves towards a peer counter by a bounded amount. Repeated valid requests can help a node
979
+ * catch up, but one message cannot set an arbitrary local clock.
980
+ *
981
+ * @param {bigint} peerCounter Validated counter from a REQUEST or REPLY.
982
+ * @returns {boolean} True if the local clock has reached this counter.
983
+ */
984
+ #observePeerCounter(peerCounter) {
985
+ const localCounter = this.#selectionState.clock;
986
+ if (peerCounter <= localCounter) {
987
+ return true;
988
+ }
989
+ const nextCounter = localCounter + EPOCH_APPENDER_SELECTION_COUNTER_STEP;
990
+ this.#selectionState.clock = peerCounter < nextCounter ? peerCounter : nextCounter;
991
+ return peerCounter <= nextCounter;
992
+ }
993
+
994
+ /**
995
+ * Recovers the clock without forgetting votes in the current epoch. On restart, a large gap
996
+ * between observed counters and saved votes can be removed. An exhausted saved vote needs a
997
+ * newer signed epoch and no pending append before it can be cleared. The epoch floor blocks
998
+ * requests from before that recovery.
999
+ *
1000
+ * @param {EpochAppenderSelectionContext} [signedContext] Current context read from local signed state.
1001
+ * @param {boolean} [recoverPeerHints=false] Check for inflated observations while opening saved state.
1002
+ * @returns {boolean} True when the state needs saving.
1003
+ */
1004
+ #recoverElectionClock(signedContext, recoverPeerHints = false) {
1005
+ const localCounter = this.#selectionState.clock;
1006
+ const savedRequests = [this.#selectionState.vote?.request, this.#selectionState.active?.request].filter(Boolean);
1007
+ const lastVotedCounter = savedRequests.reduce((highest, request) => {
1008
+ const counter = request.logicalCounter;
1009
+ return counter > highest ? counter : highest;
1010
+ }, 0n);
1011
+ if (localCounter < EPOCH_APPENDER_SELECTION_COUNTER_LIMIT &&
1012
+ (!recoverPeerHints || localCounter - lastVotedCounter <= EPOCH_APPENDER_SELECTION_COUNTER_STEP)) {
1013
+ return false;
1014
+ }
1015
+ if (lastVotedCounter < EPOCH_APPENDER_SELECTION_COUNTER_LIMIT) {
1016
+ this.#selectionState.clock = lastVotedCounter;
1017
+ return true;
1018
+ }
1019
+ if (!signedContext || this.#selectionState.active || !savedRequests.every(request =>
1020
+ request.networkId === signedContext.networkId && request.bootstrap === signedContext.bootstrap &&
1021
+ BigInt(request.targetEpoch) < BigInt(signedContext.targetEpoch)
1022
+ )) {
1023
+ return false;
1024
+ }
1025
+ this.#selectionState.minimumElectionEpoch = signedContext.targetEpoch;
1026
+ this.#selectionState.clock = 0n;
1027
+ this.#selectionState.vote = null;
1028
+ return true;
1029
+ }
1030
+
1031
+ /**
1032
+ * Rejects requests from before a clock recovery, including delayed callbacks after restart.
1033
+ *
1034
+ * @param {string} targetEpoch Epoch requested by the caller or peer.
1035
+ */
1036
+ #requireCurrentElectionEpoch(targetEpoch) {
1037
+ if (BigInt(targetEpoch) < BigInt(this.#selectionState.minimumElectionEpoch)) {
1038
+ throw new EpochAppenderSelectionError(EpochAppenderSelectionErrorCode.REQUEST_STALE,
1039
+ 'Election request predates clock recovery');
1040
+ }
1041
+ }
1042
+ }