@peerbit/shared-log 13.2.31 → 13.2.33

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 (36) hide show
  1. package/dist/src/exchange-heads.d.ts +13 -2
  2. package/dist/src/exchange-heads.d.ts.map +1 -1
  3. package/dist/src/exchange-heads.js +13 -2
  4. package/dist/src/exchange-heads.js.map +1 -1
  5. package/dist/src/index.d.ts +11 -0
  6. package/dist/src/index.d.ts.map +1 -1
  7. package/dist/src/index.js +727 -85
  8. package/dist/src/index.js.map +1 -1
  9. package/dist/src/replication-announcement.d.ts +3 -1
  10. package/dist/src/replication-announcement.d.ts.map +1 -1
  11. package/dist/src/replication-announcement.js +1 -0
  12. package/dist/src/replication-announcement.js.map +1 -1
  13. package/dist/src/replication-info-v2-binding.d.ts +16 -0
  14. package/dist/src/replication-info-v2-binding.d.ts.map +1 -0
  15. package/dist/src/replication-info-v2-binding.js +30 -0
  16. package/dist/src/replication-info-v2-binding.js.map +1 -0
  17. package/dist/src/replication-info-v2-receive.d.ts +181 -0
  18. package/dist/src/replication-info-v2-receive.d.ts.map +1 -0
  19. package/dist/src/replication-info-v2-receive.js +725 -0
  20. package/dist/src/replication-info-v2-receive.js.map +1 -0
  21. package/dist/src/replication-info-v2-send.d.ts +95 -0
  22. package/dist/src/replication-info-v2-send.d.ts.map +1 -0
  23. package/dist/src/replication-info-v2-send.js +398 -0
  24. package/dist/src/replication-info-v2-send.js.map +1 -0
  25. package/dist/src/replication.d.ts +26 -4
  26. package/dist/src/replication.d.ts.map +1 -1
  27. package/dist/src/replication.js +60 -5
  28. package/dist/src/replication.js.map +1 -1
  29. package/package.json +11 -11
  30. package/src/exchange-heads.ts +15 -2
  31. package/src/index.ts +1040 -201
  32. package/src/replication-announcement.ts +10 -4
  33. package/src/replication-info-v2-binding.ts +44 -0
  34. package/src/replication-info-v2-receive.ts +1045 -0
  35. package/src/replication-info-v2-send.ts +540 -0
  36. package/src/replication.ts +39 -5
@@ -0,0 +1,540 @@
1
+ import { type PublicSignKey, randomBytes } from "@peerbit/crypto";
2
+ import { logger as loggerFn } from "@peerbit/logger";
3
+ import type { RPC } from "@peerbit/rpc";
4
+ import {
5
+ AcknowledgeDelivery,
6
+ CONVERGENCE_MESSAGE_PRIORITY,
7
+ } from "@peerbit/stream-interface";
8
+ import type { TransportMessage } from "./message.js";
9
+ import type { ReplicationRangeIndexable } from "./ranges.js";
10
+ import { deriveReplicationInfoV2ReceiverBinding } from "./replication-info-v2-binding.js";
11
+ import {
12
+ AddedReplicationInfoV2Message,
13
+ AddedReplicationSegmentMessage,
14
+ AllReplicatingSegmentsMessage,
15
+ FullReplicationInfoV2Message,
16
+ RequestReplicationInfoV2Message,
17
+ StoppedReplicating,
18
+ StoppedReplicationInfoV2Message,
19
+ } from "./replication.js";
20
+
21
+ const logger = loggerFn("peerbit:shared-log:replication-info-v2-send");
22
+
23
+ const MAX_U64 = (1n << 64n) - 1n;
24
+
25
+ export { deriveReplicationInfoV2ReceiverBinding } from "./replication-info-v2-binding.js";
26
+
27
+ export type LegacyReplicationInfoMessage =
28
+ | AllReplicatingSegmentsMessage
29
+ | AddedReplicationSegmentMessage
30
+ | StoppedReplicating;
31
+
32
+ type SendRequest =
33
+ | { kind: "snapshot" }
34
+ | { kind: "message"; message: LegacyReplicationInfoMessage };
35
+
36
+ export type ReplicationInfoV2SendState = {
37
+ peerHash: string;
38
+ target: PublicSignKey;
39
+ peerSession: object;
40
+ receiverTransportSession: bigint;
41
+ senderTransportSession: bigint;
42
+ capabilityTimestamp: bigint;
43
+ lastRequestTimestamp: bigint;
44
+ receiverRequestChallenge: Uint8Array;
45
+ receiverChallenge: Uint8Array;
46
+ senderEpoch: Uint8Array;
47
+ ownershipLifecycleController: AbortController;
48
+ nextSequence: bigint;
49
+ established: boolean;
50
+ suspended: boolean;
51
+ inFlightSequence?: bigint;
52
+ controller: AbortController;
53
+ pending?: SendRequest;
54
+ worker?: Promise<void>;
55
+ };
56
+
57
+ export type ReplicationInfoV2SendDeps<R extends "u32" | "u64"> = {
58
+ getRpc: () => RPC<TransportMessage, TransportMessage>;
59
+ getSelfKey: () => PublicSignKey;
60
+ getSenderTransportSession: () => bigint;
61
+ getMyReplicationSegments: () => Promise<ReplicationRangeIndexable<R>[]>;
62
+ validatePersistedReplicationRangeSnapshot: (
63
+ ranges: readonly { mode: unknown }[],
64
+ ) => void;
65
+ isClosed: () => boolean;
66
+ isPeerSessionOpen: (peerHash: string, peerSession: object) => boolean;
67
+ captureReplicationOwnershipLifecycle: () => AbortController;
68
+ isReplicationOwnershipLifecycleActive: (
69
+ controller: AbortController,
70
+ ) => boolean;
71
+ };
72
+
73
+ const bytesEqual = (left: Uint8Array, right: Uint8Array): boolean => {
74
+ if (left.byteLength !== right.byteLength) {
75
+ return false;
76
+ }
77
+ for (let index = 0; index < left.byteLength; index++) {
78
+ if (left[index] !== right[index]) {
79
+ return false;
80
+ }
81
+ }
82
+ return true;
83
+ };
84
+
85
+ /**
86
+ * Per-destination V2 sender streams. Each stream retains at most one in-flight
87
+ * operation and one pending operation. A second pending mutation coalesces to
88
+ * a freshly collected authoritative snapshot, bounding memory at O(peers).
89
+ */
90
+ export class ReplicationInfoV2SendCoordinator<R extends "u32" | "u64"> {
91
+ _senderEpoch!: Uint8Array;
92
+ _sendStates!: Map<string, ReplicationInfoV2SendState>;
93
+ _spentPeerSessions!: WeakSet<object>;
94
+ _retiringWorkersByPeer!: Map<string, Promise<void>>;
95
+
96
+ constructor(private readonly deps: ReplicationInfoV2SendDeps<R>) {
97
+ this._senderEpoch = randomBytes(32);
98
+ this._sendStates = new Map();
99
+ this._spentPeerSessions = new WeakSet();
100
+ this._retiringWorkersByPeer = new Map();
101
+ }
102
+
103
+ resetForOpen(): void {
104
+ this.clearForClose();
105
+ this._senderEpoch = randomBytes(32);
106
+ this._sendStates = new Map();
107
+ this._spentPeerSessions = new WeakSet();
108
+ }
109
+
110
+ clearForClose(): void {
111
+ for (const state of [...(this._sendStates?.values() ?? [])]) {
112
+ this.clearState(state);
113
+ }
114
+ this._sendStates?.clear();
115
+ }
116
+
117
+ clearPeer(peerHash: string, expectedSession?: object): void {
118
+ const state = this._sendStates.get(peerHash);
119
+ if (!state || (expectedSession && state.peerSession !== expectedSession)) {
120
+ return;
121
+ }
122
+ this.clearState(state);
123
+ }
124
+
125
+ advancePeerCapability(peerHash: string): void {
126
+ const state = this._sendStates.get(peerHash);
127
+ if (state) {
128
+ this.clearState(state);
129
+ }
130
+ }
131
+
132
+ private clearState(state: ReplicationInfoV2SendState): void {
133
+ this.trackRetiringWorker(state);
134
+ state.controller.abort();
135
+ if (this._sendStates.get(state.peerHash) === state) {
136
+ this._sendStates.delete(state.peerHash);
137
+ }
138
+ }
139
+
140
+ private trackRetiringWorker(state: ReplicationInfoV2SendState): void {
141
+ const worker = state.worker;
142
+ if (!worker) {
143
+ return;
144
+ }
145
+ const previous = this._retiringWorkersByPeer.get(state.peerHash);
146
+ if (previous === worker) {
147
+ return;
148
+ }
149
+ const retirement = previous
150
+ ? Promise.allSettled([previous, worker]).then(() => undefined)
151
+ : worker;
152
+ this._retiringWorkersByPeer.set(state.peerHash, retirement);
153
+ const forget = () => {
154
+ if (this._retiringWorkersByPeer.get(state.peerHash) === retirement) {
155
+ this._retiringWorkersByPeer.delete(state.peerHash);
156
+ }
157
+ };
158
+ void retirement.then(forget, forget);
159
+ }
160
+
161
+ private isDestinationCurrent(state: ReplicationInfoV2SendState): boolean {
162
+ return (
163
+ !this.deps.isClosed() &&
164
+ !state.controller.signal.aborted &&
165
+ this._sendStates.get(state.peerHash) === state &&
166
+ this.deps.isPeerSessionOpen(state.peerHash, state.peerSession) &&
167
+ this.deps.getSenderTransportSession() === state.senderTransportSession
168
+ );
169
+ }
170
+
171
+ private isCurrent(state: ReplicationInfoV2SendState): boolean {
172
+ return (
173
+ this.isDestinationCurrent(state) &&
174
+ this.deps.isReplicationOwnershipLifecycleActive(
175
+ state.ownershipLifecycleController,
176
+ )
177
+ );
178
+ }
179
+
180
+ /**
181
+ * Accept a signed receiver request. An exact newer retry asks for another
182
+ * full snapshot without resetting the epoch/sequence. A different challenge
183
+ * can replace the binding only after a strictly newer signed capability;
184
+ * retiring work is drained before the replacement can start.
185
+ */
186
+ acceptRequest(
187
+ request: RequestReplicationInfoV2Message,
188
+ properties: {
189
+ from: PublicSignKey;
190
+ peerSession: object;
191
+ receiverTransportSession: bigint;
192
+ capabilityTimestamp: bigint;
193
+ requestTimestamp: bigint;
194
+ },
195
+ ): boolean {
196
+ const self = this.deps.getSelfKey();
197
+ const senderTransportSession = this.deps.getSenderTransportSession();
198
+ if (
199
+ properties.from.equals(self) ||
200
+ !request.intendedSender.equals(self) ||
201
+ request.senderSession !== senderTransportSession
202
+ ) {
203
+ return false;
204
+ }
205
+
206
+ const peerHash = properties.from.hashcode();
207
+ if (
208
+ this._retiringWorkersByPeer.has(peerHash) ||
209
+ this._spentPeerSessions.has(properties.peerSession) ||
210
+ !this.deps.isPeerSessionOpen(peerHash, properties.peerSession)
211
+ ) {
212
+ return false;
213
+ }
214
+
215
+ let previous = this._sendStates.get(peerHash);
216
+ if (
217
+ previous &&
218
+ (previous.peerSession !== properties.peerSession ||
219
+ previous.senderTransportSession !== senderTransportSession)
220
+ ) {
221
+ this.clearState(previous);
222
+ previous = undefined;
223
+ if (this._retiringWorkersByPeer.has(peerHash)) {
224
+ return false;
225
+ }
226
+ }
227
+ if (previous) {
228
+ const sameBinding =
229
+ previous.peerSession === properties.peerSession &&
230
+ previous.receiverTransportSession ===
231
+ properties.receiverTransportSession &&
232
+ bytesEqual(
233
+ previous.receiverRequestChallenge,
234
+ request.receiverChallenge,
235
+ );
236
+ if (sameBinding) {
237
+ if (properties.requestTimestamp <= previous.lastRequestTimestamp) {
238
+ return false;
239
+ }
240
+ previous.lastRequestTimestamp = properties.requestTimestamp;
241
+ previous.capabilityTimestamp = properties.capabilityTimestamp;
242
+ previous.suspended = false;
243
+ this.enqueueState(previous, { kind: "snapshot" });
244
+ return true;
245
+ }
246
+
247
+ if (properties.capabilityTimestamp <= previous.capabilityTimestamp) {
248
+ return false;
249
+ }
250
+ this.clearState(previous);
251
+ previous = undefined;
252
+ if (this._retiringWorkersByPeer.has(peerHash)) {
253
+ return false;
254
+ }
255
+ }
256
+
257
+ const ownershipLifecycleController =
258
+ this.deps.captureReplicationOwnershipLifecycle();
259
+ const state: ReplicationInfoV2SendState = {
260
+ peerHash,
261
+ target: properties.from,
262
+ peerSession: properties.peerSession,
263
+ receiverTransportSession: properties.receiverTransportSession,
264
+ senderTransportSession,
265
+ capabilityTimestamp: properties.capabilityTimestamp,
266
+ lastRequestTimestamp: properties.requestTimestamp,
267
+ receiverRequestChallenge: request.receiverChallenge.slice(),
268
+ receiverChallenge: deriveReplicationInfoV2ReceiverBinding({
269
+ receiverChallenge: request.receiverChallenge,
270
+ receiver: properties.from,
271
+ receiverTransportSession: properties.receiverTransportSession,
272
+ sender: self,
273
+ senderTransportSession,
274
+ }),
275
+ senderEpoch: this._senderEpoch.slice(),
276
+ nextSequence: 1n,
277
+ established: false,
278
+ suspended: false,
279
+ controller: new AbortController(),
280
+ ownershipLifecycleController,
281
+ };
282
+ this._sendStates.set(peerHash, state);
283
+ this.enqueueState(state, { kind: "snapshot" });
284
+ return true;
285
+ }
286
+
287
+ enqueue(message: LegacyReplicationInfoMessage): void {
288
+ for (const state of [...this._sendStates.values()]) {
289
+ this.enqueueState(state, { kind: "message", message });
290
+ }
291
+ }
292
+
293
+ enqueueSnapshotForPeer(peerHash: string): void {
294
+ const state = this._sendStates.get(peerHash);
295
+ if (state) {
296
+ this.enqueueState(state, { kind: "snapshot" });
297
+ }
298
+ }
299
+
300
+ private enqueueState(
301
+ state: ReplicationInfoV2SendState,
302
+ request: SendRequest,
303
+ ): void {
304
+ if (!this.isCurrent(state)) {
305
+ state.pending = undefined;
306
+ if (
307
+ !this.isDestinationCurrent(state) ||
308
+ this.deps.isReplicationOwnershipLifecycleActive(
309
+ state.ownershipLifecycleController,
310
+ )
311
+ ) {
312
+ this.clearState(state);
313
+ }
314
+ return;
315
+ }
316
+ if (state.suspended) {
317
+ state.pending = undefined;
318
+ return;
319
+ }
320
+
321
+ if (!state.worker) {
322
+ state.pending = request;
323
+ let worker: Promise<void>;
324
+ worker = Promise.resolve()
325
+ .then(() => this.runWorker(state))
326
+ .catch((error) => {
327
+ const ownershipActive =
328
+ this.deps.isReplicationOwnershipLifecycleActive(
329
+ state.ownershipLifecycleController,
330
+ );
331
+ if (
332
+ ownershipActive &&
333
+ !state.controller.signal.aborted &&
334
+ !this.deps.isClosed()
335
+ ) {
336
+ logger.trace(
337
+ "Replication-info V2 destination stream failed for %s: %s",
338
+ state.peerHash,
339
+ (error as Error)?.message ?? String(error),
340
+ );
341
+ }
342
+ state.pending = undefined;
343
+ const ordinaryFailure =
344
+ ownershipActive && this.isDestinationCurrent(state);
345
+ if (ordinaryFailure) {
346
+ if (state.inFlightSequence !== undefined) {
347
+ state.inFlightSequence = undefined;
348
+ if (state.nextSequence > MAX_U64) {
349
+ this._spentPeerSessions.add(state.peerSession);
350
+ this.clearState(state);
351
+ return;
352
+ }
353
+ }
354
+ // A delivery error is ambiguous: the receiver may already have
355
+ // applied this sequence. Retain the exact grant, stop ordinary
356
+ // deltas, and require a newer same-challenge request to resume
357
+ // with an authoritative Full at the next safe sequence.
358
+ state.suspended = true;
359
+ return;
360
+ }
361
+ if (!this.isDestinationCurrent(state)) {
362
+ this.clearState(state);
363
+ }
364
+ })
365
+ .finally(() => {
366
+ if (state.worker === worker) {
367
+ state.worker = undefined;
368
+ // An enqueue can land after runWorker observes an empty slot but
369
+ // before this promise reaction clears `worker`. Re-arm that item
370
+ // here so the one-slot bound cannot become a stranded queue.
371
+ const pending = state.pending;
372
+ if (pending && this.isCurrent(state)) {
373
+ state.pending = undefined;
374
+ this.enqueueState(state, pending);
375
+ }
376
+ }
377
+ });
378
+ state.worker = worker;
379
+ return;
380
+ }
381
+
382
+ if (!state.pending) {
383
+ state.pending = request;
384
+ return;
385
+ }
386
+
387
+ // One pending item is the hard bound. Once another mutation arrives,
388
+ // replace the pending delta with a current authoritative snapshot.
389
+ state.pending = { kind: "snapshot" };
390
+ }
391
+
392
+ private async createMessage(
393
+ state: ReplicationInfoV2SendState,
394
+ request: SendRequest,
395
+ ): Promise<
396
+ | FullReplicationInfoV2Message
397
+ | AddedReplicationInfoV2Message
398
+ | StoppedReplicationInfoV2Message
399
+ > {
400
+ const common = {
401
+ receiverChallenge: state.receiverChallenge.slice(),
402
+ senderEpoch: state.senderEpoch.slice(),
403
+ sequence: state.nextSequence,
404
+ };
405
+ if (request.kind === "snapshot") {
406
+ const segments = (await this.deps.getMyReplicationSegments()).map(
407
+ (range) => range.toReplicationRange(),
408
+ );
409
+ this.deps.validatePersistedReplicationRangeSnapshot(segments);
410
+ return new FullReplicationInfoV2Message({ ...common, segments });
411
+ }
412
+ if (request.message instanceof AllReplicatingSegmentsMessage) {
413
+ const segments = request.message.segments;
414
+ this.deps.validatePersistedReplicationRangeSnapshot(segments);
415
+ return new FullReplicationInfoV2Message({ ...common, segments });
416
+ }
417
+ if (request.message instanceof AddedReplicationSegmentMessage) {
418
+ return new AddedReplicationInfoV2Message({
419
+ ...common,
420
+ segments: request.message.segments,
421
+ });
422
+ }
423
+ return new StoppedReplicationInfoV2Message({
424
+ ...common,
425
+ segmentIds: request.message.segmentIds,
426
+ });
427
+ }
428
+
429
+ private async runWorker(state: ReplicationInfoV2SendState): Promise<void> {
430
+ while (this.isCurrent(state)) {
431
+ const request = state.pending;
432
+ if (!request) {
433
+ return;
434
+ }
435
+ state.pending = undefined;
436
+ if (state.nextSequence > MAX_U64) {
437
+ this.clearState(state);
438
+ return;
439
+ }
440
+
441
+ const message = await this.createMessage(state, request);
442
+ if (!this.isCurrent(state)) {
443
+ return;
444
+ }
445
+ // Consume the sequence before the transport attempt. From this point on
446
+ // delivery is ambiguous even if ownership aborts before the await
447
+ // continuation runs, so `nextSequence` always remains the next value that
448
+ // has never been attempted with different content.
449
+ state.inFlightSequence = state.nextSequence;
450
+ state.nextSequence += 1n;
451
+ await this.deps.getRpc().send(message, {
452
+ mode: new AcknowledgeDelivery({
453
+ to: [state.target],
454
+ redundancy: 1,
455
+ }),
456
+ priority: CONVERGENCE_MESSAGE_PRIORITY,
457
+ signal: AbortSignal.any([
458
+ state.controller.signal,
459
+ state.ownershipLifecycleController.signal,
460
+ ]),
461
+ });
462
+ state.inFlightSequence = undefined;
463
+ if (!this.isCurrent(state)) {
464
+ return;
465
+ }
466
+ state.established = true;
467
+ if (state.nextSequence > MAX_U64) {
468
+ this._spentPeerSessions.add(state.peerSession);
469
+ this.clearState(state);
470
+ return;
471
+ }
472
+ }
473
+ }
474
+
475
+ /**
476
+ * Terminal close/drop runs after the normal ownership generation is aborted.
477
+ * Send an authoritative empty Full directly from the retained destination
478
+ * bindings. `nextSequence` is consumed before every transport attempt, so it
479
+ * is always safe for different terminal content even if a just-aborted frame
480
+ * was delivered. Receivers admit Full gaps as authoritative resynchronization.
481
+ */
482
+ async sendTerminalReset(signal: AbortSignal): Promise<void> {
483
+ const sends: Promise<unknown>[] = [];
484
+ for (const state of [...this._sendStates.values()]) {
485
+ if (
486
+ signal.aborted ||
487
+ !this.isDestinationCurrent(state) ||
488
+ state.nextSequence > MAX_U64
489
+ ) {
490
+ continue;
491
+ }
492
+ state.pending = undefined;
493
+ const message = new FullReplicationInfoV2Message({
494
+ receiverChallenge: state.receiverChallenge.slice(),
495
+ senderEpoch: state.senderEpoch.slice(),
496
+ sequence: state.nextSequence,
497
+ segments: [],
498
+ });
499
+ sends.push(
500
+ this.deps.getRpc().send(message, {
501
+ mode: new AcknowledgeDelivery({
502
+ to: [state.target],
503
+ redundancy: 1,
504
+ }),
505
+ priority: CONVERGENCE_MESSAGE_PRIORITY,
506
+ signal,
507
+ }),
508
+ );
509
+ }
510
+ await Promise.allSettled(sends);
511
+ }
512
+
513
+ async drain(signal?: AbortSignal): Promise<void> {
514
+ while (!signal?.aborted) {
515
+ const workers = [
516
+ ...[...this._sendStates.values()]
517
+ .map((state) => state.worker)
518
+ .filter((worker): worker is Promise<void> => worker != null),
519
+ ...this._retiringWorkersByPeer.values(),
520
+ ];
521
+ const uniqueWorkers = [...new Set(workers)];
522
+ if (uniqueWorkers.length === 0) {
523
+ return;
524
+ }
525
+ const settled = Promise.allSettled(uniqueWorkers).then(() => undefined);
526
+ if (!signal) {
527
+ await settled;
528
+ } else {
529
+ await new Promise<void>((resolve) => {
530
+ const onAbort = () => resolve();
531
+ signal.addEventListener("abort", onAbort, { once: true });
532
+ void settled.then(() => {
533
+ signal.removeEventListener("abort", onAbort);
534
+ resolve();
535
+ });
536
+ });
537
+ }
538
+ }
539
+ }
540
+ }
@@ -7,7 +7,7 @@ import {
7
7
  variant,
8
8
  vec,
9
9
  } from "@dao-xyz/borsh";
10
- import { randomBytes } from "@peerbit/crypto";
10
+ import { PublicSignKey, randomBytes } from "@peerbit/crypto";
11
11
  import { type Index } from "@peerbit/indexer-interface";
12
12
  import { TransportMessage } from "./message.js";
13
13
  import {
@@ -126,10 +126,11 @@ export class ReplicationPingMessage extends TransportMessage {
126
126
  }
127
127
 
128
128
  /**
129
- * Decode-first replication-info V2 messages. This rollout only registers and
130
- * advertises the final wire schemas; it never sends or applies these messages.
131
- * A later negotiated phase must bind the signed transport session and an
132
- * outstanding receiver challenge before any V2 payload is used.
129
+ * Challenge-bound replication-info V2 messages. Their `receiverChallenge`
130
+ * field carries a derived receiver binding, not the request's raw nonce. The
131
+ * binding covers both authenticated identities and signed transport sessions.
132
+ * Sender-ready nodes may emit them only after a signed, session-bound receiver
133
+ * request. Receive/apply activation remains separately capability-gated.
133
134
  */
134
135
  @variant([1, 6])
135
136
  export class FullReplicationInfoV2Message extends TransportMessage {
@@ -215,6 +216,39 @@ export class StoppedReplicationInfoV2Message extends TransportMessage {
215
216
  }
216
217
  }
217
218
 
219
+ /**
220
+ * Signed, receiver-led permission for one sender to start a V2 stream.
221
+ *
222
+ * The transport delivery target is deliberately unsigned because relays may
223
+ * rewrite it. Carry the intended sender in the signed payload so a valid
224
+ * request cannot be retargeted to another peer. `senderSession` is copied from
225
+ * the sender's signed capability envelope and binds the request to that exact
226
+ * transport incarnation.
227
+ */
228
+ @variant([1, 9])
229
+ export class RequestReplicationInfoV2Message extends TransportMessage {
230
+ /** Random receiver nonce used as input to the V2 receiver-binding hash. */
231
+ @field({ type: fixedArray("u8", 32) })
232
+ receiverChallenge: Uint8Array;
233
+
234
+ @field({ type: PublicSignKey })
235
+ intendedSender: PublicSignKey;
236
+
237
+ @field({ type: "u64" })
238
+ senderSession: bigint;
239
+
240
+ constructor(properties: {
241
+ receiverChallenge: Uint8Array;
242
+ intendedSender: PublicSignKey;
243
+ senderSession: bigint;
244
+ }) {
245
+ super();
246
+ this.receiverChallenge = properties.receiverChallenge;
247
+ this.intendedSender = properties.intendedSender;
248
+ this.senderSession = properties.senderSession;
249
+ }
250
+ }
251
+
218
252
  export type ReplicationInfoV2Message =
219
253
  | FullReplicationInfoV2Message
220
254
  | AddedReplicationInfoV2Message