@peerbit/pubsub 5.3.11 → 5.4.0

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 (50) hide show
  1. package/dist/src/index.d.ts +31 -13
  2. package/dist/src/index.d.ts.map +1 -1
  3. package/dist/src/index.js +622 -145
  4. package/dist/src/index.js.map +1 -1
  5. package/package.json +8 -7
  6. package/src/index.ts +914 -216
  7. package/dist/benchmark/fanout-tree-parent-upgrade-default-ready.d.ts +0 -3
  8. package/dist/benchmark/fanout-tree-parent-upgrade-default-ready.d.ts.map +0 -1
  9. package/dist/benchmark/fanout-tree-parent-upgrade-default-ready.js +0 -90
  10. package/dist/benchmark/fanout-tree-parent-upgrade-default-ready.js.map +0 -1
  11. package/dist/benchmark/fanout-tree-parent-upgrade-eval.d.ts +0 -2
  12. package/dist/benchmark/fanout-tree-parent-upgrade-eval.d.ts.map +0 -1
  13. package/dist/benchmark/fanout-tree-parent-upgrade-eval.js +0 -712
  14. package/dist/benchmark/fanout-tree-parent-upgrade-eval.js.map +0 -1
  15. package/dist/benchmark/fanout-tree-parent-upgrade-multi-eval.d.ts +0 -2
  16. package/dist/benchmark/fanout-tree-parent-upgrade-multi-eval.d.ts.map +0 -1
  17. package/dist/benchmark/fanout-tree-parent-upgrade-multi-eval.js +0 -1591
  18. package/dist/benchmark/fanout-tree-parent-upgrade-multi-eval.js.map +0 -1
  19. package/dist/benchmark/fanout-tree-parent-upgrade-prepush.d.ts +0 -2
  20. package/dist/benchmark/fanout-tree-parent-upgrade-prepush.d.ts.map +0 -1
  21. package/dist/benchmark/fanout-tree-parent-upgrade-prepush.js +0 -297
  22. package/dist/benchmark/fanout-tree-parent-upgrade-prepush.js.map +0 -1
  23. package/dist/benchmark/fanout-tree-parent-upgrade-preset.d.ts +0 -73
  24. package/dist/benchmark/fanout-tree-parent-upgrade-preset.d.ts.map +0 -1
  25. package/dist/benchmark/fanout-tree-parent-upgrade-preset.js +0 -183
  26. package/dist/benchmark/fanout-tree-parent-upgrade-preset.js.map +0 -1
  27. package/dist/benchmark/fanout-tree-sim-lib.d.ts +0 -303
  28. package/dist/benchmark/fanout-tree-sim-lib.d.ts.map +0 -1
  29. package/dist/benchmark/fanout-tree-sim-lib.js +0 -1752
  30. package/dist/benchmark/fanout-tree-sim-lib.js.map +0 -1
  31. package/dist/benchmark/fanout-tree-sim.d.ts +0 -2
  32. package/dist/benchmark/fanout-tree-sim.d.ts.map +0 -1
  33. package/dist/benchmark/fanout-tree-sim.js +0 -870
  34. package/dist/benchmark/fanout-tree-sim.js.map +0 -1
  35. package/dist/benchmark/index.d.ts +0 -8
  36. package/dist/benchmark/index.d.ts.map +0 -1
  37. package/dist/benchmark/index.js +0 -62
  38. package/dist/benchmark/index.js.map +0 -1
  39. package/dist/benchmark/pubsub-topic-sim-lib.d.ts +0 -86
  40. package/dist/benchmark/pubsub-topic-sim-lib.d.ts.map +0 -1
  41. package/dist/benchmark/pubsub-topic-sim-lib.js +0 -680
  42. package/dist/benchmark/pubsub-topic-sim-lib.js.map +0 -1
  43. package/dist/benchmark/pubsub-topic-sim.d.ts +0 -9
  44. package/dist/benchmark/pubsub-topic-sim.d.ts.map +0 -1
  45. package/dist/benchmark/pubsub-topic-sim.js +0 -134
  46. package/dist/benchmark/pubsub-topic-sim.js.map +0 -1
  47. package/dist/benchmark/sim/bench-utils.d.ts +0 -25
  48. package/dist/benchmark/sim/bench-utils.d.ts.map +0 -1
  49. package/dist/benchmark/sim/bench-utils.js +0 -141
  50. package/dist/benchmark/sim/bench-utils.js.map +0 -1
package/src/index.ts CHANGED
@@ -1,15 +1,13 @@
1
+ import { type PeerId as Libp2pPeerId } from "@libp2p/interface";
1
2
  import {
2
- type Connection,
3
- type PeerId as Libp2pPeerId,
4
- } from "@libp2p/interface";
5
- import { PublicSignKey, getPublicKeyFromPeerId } from "@peerbit/crypto";
3
+ PublicSignKey,
4
+ getPublicKeyFromPeerId,
5
+ sha256Sync,
6
+ } from "@peerbit/crypto";
6
7
  import { logger as loggerFn } from "@peerbit/logger";
7
8
  import {
8
- assertCanonicalTopicRootCandidates,
9
- assertTopicRootCandidatesFrame,
10
9
  DataEvent,
11
10
  GetSubscribers,
12
- isCanonicalTopicRootCandidate,
13
11
  PeerUnavailable,
14
12
  type PubSub,
15
13
  PubSubData,
@@ -20,12 +18,18 @@ import {
20
18
  SubscriptionData,
21
19
  SubscriptionEvent,
22
20
  TOPIC_ROOT_CANDIDATES_MAX,
21
+ TOPIC_ROOT_CANDIDATE_CLAIMS_MAX,
22
+ TOPIC_ROOT_CANDIDATE_CLAIM_MAX_BYTES,
23
+ TopicRootCandidateClaims,
23
24
  TopicRootCandidates,
24
25
  TopicRootQuery,
25
26
  TopicRootQueryResponse,
26
27
  UnsubcriptionEvent,
27
- type UnsubscriptionReason,
28
28
  Unsubscribe,
29
+ type UnsubscriptionReason,
30
+ assertCanonicalTopicRootCandidates,
31
+ assertTopicRootCandidatesFrame,
32
+ isCanonicalTopicRootCandidate,
29
33
  } from "@peerbit/pubsub-interface";
30
34
  import {
31
35
  DirectStream,
@@ -58,6 +62,7 @@ import {
58
62
  } from "@peerbit/stream-interface";
59
63
  import { AbortError, TimeoutError, delay } from "@peerbit/time";
60
64
  import { Uint8ArrayList } from "uint8arraylist";
65
+ import { equals as bytesEqual } from "uint8arrays";
61
66
  import { toUint8Array } from "./bytes.js";
62
67
  import {
63
68
  type DebouncedAccumulatorCounterMap,
@@ -107,7 +112,10 @@ const logErrorIfStarted = (e?: { message: string }) => {
107
112
  e instanceof NotStartedError === false && logError(e);
108
113
  };
109
114
 
110
- const withAbort = async <T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> => {
115
+ const withAbort = async <T>(
116
+ promise: Promise<T>,
117
+ signal?: AbortSignal,
118
+ ): Promise<T> => {
111
119
  if (!signal) return promise;
112
120
  if (signal.aborted) {
113
121
  void promise.catch(() => {});
@@ -194,6 +202,35 @@ const DEFAULT_PUBSUB_SHARD_COUNT = 256;
194
202
  const PUBSUB_SHARD_COUNT_HARD_CAP = 16_384;
195
203
  const DEFAULT_PUBSUB_SHARD_TOPIC_PREFIX = "/peerbit/pubsub-shard/1/";
196
204
  const AUTO_TOPIC_ROOT_CANDIDATE_UPDATE_COOLDOWN_MS = 2_000;
205
+ const TOPIC_CONTROL_PLANE_PROTOCOL_V2_1 = "/peerbit/topic-control-plane/2.1.0";
206
+ const TOPIC_CONTROL_PLANE_PROTOCOL_V2_0 = "/peerbit/topic-control-plane/2.0.0";
207
+ const TOPIC_ROOT_CANDIDATE_CLAIM_DOMAIN = utf8Encoder.encode(
208
+ "peerbit/topic-root-candidate-claim/v1\0",
209
+ );
210
+ const TOPIC_ROOT_CANDIDATE_SCOPE_DOMAIN =
211
+ "peerbit/topic-root-candidate-scope/v1";
212
+ const TOPIC_ROOT_CANDIDATE_CLAIM_LIFETIME_MS = 90_000;
213
+ const TOPIC_ROOT_CANDIDATE_CLAIM_FUTURE_SKEW_MS = 30_000;
214
+ const TOPIC_ROOT_CANDIDATE_CLAIM_RECEIVER_MAX_LIFETIME_MS =
215
+ TOPIC_ROOT_CANDIDATE_CLAIM_LIFETIME_MS +
216
+ TOPIC_ROOT_CANDIDATE_CLAIM_FUTURE_SKEW_MS;
217
+ const TOPIC_ROOT_CANDIDATE_CLAIM_REFRESH_MS = 30_000;
218
+ const TOPIC_ROOT_CANDIDATE_CLAIM_REFRESH_JITTER_MS = 5_000;
219
+ const TOPIC_ROOT_CANDIDATE_CLAIM_REFRESH_RETRY_MS = 1_000;
220
+ const TOPIC_ROOT_CANDIDATE_CLAIM_ADVERTISEMENT_TIMEOUT_MS = 10_000;
221
+ // A sparse leaf can receive a 64-origin snapshot plus two refresh waves through
222
+ // one relay in the first minute. Keep per-relay headroom for that honest burst;
223
+ // the separate global cap still bounds total signature work.
224
+ const TOPIC_ROOT_CANDIDATE_CLAIM_VERIFY_BURST = 256;
225
+ const TOPIC_ROOT_CANDIDATE_CLAIM_GLOBAL_VERIFY_BURST = 512;
226
+ const TOPIC_ROOT_CANDIDATE_CLAIM_VERIFY_REFILL_MS = 60_000;
227
+ const TOPIC_ROOT_CANDIDATE_CLAIM_VERIFY_PEERS_MAX = 4_096;
228
+ type TopicRootCandidateClaimRecord = {
229
+ bytes: Uint8Array;
230
+ timestamp: bigint;
231
+ expires: bigint;
232
+ acceptUntil: number;
233
+ };
197
234
  const sameCandidates = (left: string[], right: string[]) =>
198
235
  left.length === right.length &&
199
236
  left.every((candidate, index) => candidate === right[index]);
@@ -378,8 +415,12 @@ export class TopicControlPlane
378
415
 
379
416
  private readonly shardCount: number;
380
417
  private readonly shardTopicPrefix: string;
418
+ private readonly topicRootCandidateClaimData: Uint8Array;
381
419
  private readonly hostShards: boolean;
382
- private readonly shardRootCache = new Map<string, { root: string; authoritative: boolean }>();
420
+ private readonly shardRootCache = new Map<
421
+ string,
422
+ { root: string; authoritative: boolean }
423
+ >();
383
424
  private readonly shardTopicCache = new Map<string, string>();
384
425
  private readonly shardRefCounts = new Map<string, number>();
385
426
  private readonly pinnedShards = new Set<string>();
@@ -413,10 +454,31 @@ export class TopicControlPlane
413
454
  private topicRootCandidateResolutionAbortController = new AbortController();
414
455
  private hostOwnedShardRootsInFlight?: Promise<void>;
415
456
  private hostOwnedShardRootsDirty = false;
416
- private autoCandidatesBroadcastTimers: Array<ReturnType<typeof setTimeout>> =
417
- [];
418
- private autoCandidatesGossipInterval?: ReturnType<typeof setInterval>;
419
- private autoCandidatesGossipUntil = 0;
457
+ private readonly signedTopicRootCandidateClaims = new Map<
458
+ string,
459
+ TopicRootCandidateClaimRecord
460
+ >();
461
+ // Active claims expire, but their per-origin timestamp floors survive for the
462
+ // auto-mode lifecycle so a frozen wall clock cannot renew a replayed lease.
463
+ // Retaining only the deterministic lowest origins keeps this state hard-bounded.
464
+ private readonly topicRootCandidateClaimReplayFloors = new Map<
465
+ string,
466
+ bigint
467
+ >();
468
+ private localSignedTopicRootCandidateClaim?: TopicRootCandidateClaimRecord;
469
+ private topicRootCandidateClaimMaintenanceTimer?: ReturnType<
470
+ typeof setTimeout
471
+ >;
472
+ private topicRootCandidateClaimRefreshNotBefore?: number;
473
+ private topicRootCandidateClaimMaintenanceRefreshInFlight?: Promise<void>;
474
+ private readonly topicRootCandidateClaimVerifyBudgets = new Map<
475
+ string,
476
+ { remaining: number; refilledAt: number }
477
+ >();
478
+ private topicRootCandidateClaimGlobalVerifyBudget = {
479
+ remaining: TOPIC_ROOT_CANDIDATE_CLAIM_GLOBAL_VERIFY_BURST,
480
+ refilledAt: Date.now(),
481
+ };
420
482
  private _onFanoutPeerUnreachable?: (
421
483
  ev: CustomEvent<{ topic: string; root: string; publicKeyHash: string }>,
422
484
  ) => void;
@@ -459,7 +521,11 @@ export class TopicControlPlane
459
521
  components: TopicControlPlaneComponents,
460
522
  props?: TopicControlPlaneOptions,
461
523
  ) {
462
- super(components, ["/peerbit/topic-control-plane/2.0.0"], props);
524
+ super(
525
+ components,
526
+ [TOPIC_CONTROL_PLANE_PROTOCOL_V2_1, TOPIC_CONTROL_PLANE_PROTOCOL_V2_0],
527
+ props,
528
+ );
463
529
  this.subscriptions = new Map();
464
530
  this.pendingSubscriptions = new Set();
465
531
  this.topics = new Map();
@@ -484,8 +550,8 @@ export class TopicControlPlane
484
550
  this.fanout = props.fanout;
485
551
 
486
552
  // Default to a local-only shard-root candidate set so standalone peers can
487
- // subscribe/publish without explicit bootstraps. We'll expand candidates
488
- // opportunistically as neighbours connect.
553
+ // subscribe/publish without explicit bootstraps. Signed peer claims expand
554
+ // the set opportunistically as the ad-hoc overlay connects.
489
555
  if (this.topicRootControlPlane.getTopicRootCandidates().length === 0) {
490
556
  this.autoTopicRootCandidates = true;
491
557
  this.autoTopicRootCandidateSet = new Set([this.publicKeyHash]);
@@ -499,6 +565,23 @@ export class TopicControlPlane
499
565
  );
500
566
  const prefix = props?.shardTopicPrefix ?? DEFAULT_PUBSUB_SHARD_TOPIC_PREFIX;
501
567
  this.shardTopicPrefix = prefix.endsWith("/") ? prefix : prefix + "/";
568
+ const scopeDigest = sha256Sync(
569
+ utf8Encoder.encode(
570
+ JSON.stringify([
571
+ TOPIC_ROOT_CANDIDATE_SCOPE_DOMAIN,
572
+ this.shardTopicPrefix,
573
+ this.shardCount,
574
+ ]),
575
+ ),
576
+ );
577
+ this.topicRootCandidateClaimData = new Uint8Array(
578
+ TOPIC_ROOT_CANDIDATE_CLAIM_DOMAIN.byteLength + scopeDigest.byteLength,
579
+ );
580
+ this.topicRootCandidateClaimData.set(TOPIC_ROOT_CANDIDATE_CLAIM_DOMAIN, 0);
581
+ this.topicRootCandidateClaimData.set(
582
+ scopeDigest,
583
+ TOPIC_ROOT_CANDIDATE_CLAIM_DOMAIN.byteLength,
584
+ );
502
585
  this.hostShards = props?.hostShards ?? false;
503
586
 
504
587
  const baseFanoutChannelOptions = {
@@ -583,25 +666,21 @@ export class TopicControlPlane
583
666
  * candidate mode.
584
667
  *
585
668
  * Auto mode is a convenience for small ad-hoc networks where no bootstraps/
586
- * routers are configured. When an explicit candidate set is provided (e.g.
587
- * from bootstraps or a test harness), we must stop mutating/gossiping the
669
+ * routers are configured. Automatic claims authenticate key ownership and
670
+ * freshness, not liveness, admission, or Sybil resistance; hostile or isolated
671
+ * deployments should configure explicit candidates. When an explicit candidate
672
+ * set is provided (e.g. from bootstraps or a test harness), we stop mutating the
588
673
  * candidate set; otherwise shard root resolution can diverge and overlays can
589
674
  * partition (especially in sparse graphs).
590
675
  */
591
676
  public setTopicRootCandidates(candidates: string[]) {
592
677
  this.topicRootControlPlane.setTopicRootCandidates(candidates);
593
678
 
594
- // Disable auto mode and stop its background gossip/timers.
679
+ // Disable auto mode and clear its signed-claim state/timers.
595
680
  this.autoTopicRootCandidates = false;
596
681
  this.autoTopicRootCandidateSet = undefined;
682
+ this.clearSignedTopicRootCandidateState();
597
683
  this.clearAutoTopicRootCandidateUpdateSchedule();
598
- for (const t of this.autoCandidatesBroadcastTimers) clearTimeout(t);
599
- this.autoCandidatesBroadcastTimers = [];
600
- if (this.autoCandidatesGossipInterval) {
601
- clearInterval(this.autoCandidatesGossipInterval);
602
- this.autoCandidatesGossipInterval = undefined;
603
- }
604
- this.autoCandidatesGossipUntil = 0;
605
684
 
606
685
  // Re-resolve roots under the new mapping.
607
686
  this.shardRootCache.clear();
@@ -614,19 +693,42 @@ export class TopicControlPlane
614
693
  }
615
694
 
616
695
  public override async start() {
696
+ // Match DirectStream's idempotent start guard before mutating automatic
697
+ // candidates. In particular, a repeated start must not restore unsigned
698
+ // self while a signed-protocol peer is connected.
699
+ if (this.started) return;
617
700
  this.topicControlPlaneStopping = false;
701
+ if (
702
+ this.autoTopicRootCandidates &&
703
+ !this.localSignedTopicRootCandidateClaim
704
+ ) {
705
+ this.autoTopicRootCandidateSet = new Set([this.publicKeyHash]);
706
+ this.topicRootControlPlane.setTopicRootCandidates([this.publicKeyHash]);
707
+ this.shardRootCache.clear();
708
+ }
618
709
  if (this.topicRootResolutionAbortController.signal.aborted) {
619
710
  this.topicRootResolutionAbortController = new AbortController();
620
711
  }
621
712
  await this.fanout.start();
622
713
  this._onFanoutPeerUnreachable =
623
- this._onFanoutPeerUnreachable ||
624
- this.onFanoutPeerUnreachable.bind(this);
714
+ this._onFanoutPeerUnreachable || this.onFanoutPeerUnreachable.bind(this);
625
715
  await this.fanout.addEventListener(
626
716
  "fanout:peer-unreachable",
627
717
  this._onFanoutPeerUnreachable as any,
628
718
  );
629
719
  await super.start();
720
+ if (this.autoTopicRootCandidates) {
721
+ let retryDelayMs = 0;
722
+ try {
723
+ if (!(await this.refreshLocalTopicRootCandidateClaim())) {
724
+ retryDelayMs = TOPIC_ROOT_CANDIDATE_CLAIM_REFRESH_RETRY_MS;
725
+ }
726
+ } catch (error: any) {
727
+ logErrorIfStarted(error);
728
+ retryDelayMs = TOPIC_ROOT_CANDIDATE_CLAIM_REFRESH_RETRY_MS;
729
+ }
730
+ this.scheduleTopicRootCandidateClaimMaintenance(retryDelayMs);
731
+ }
630
732
 
631
733
  if (this.hostShards) {
632
734
  await this.hostShardRootsNow();
@@ -645,6 +747,7 @@ export class TopicControlPlane
645
747
  new AbortError("topic control plane stopped"),
646
748
  );
647
749
  this.clearAutoTopicRootCandidateUpdateSchedule();
750
+ this.clearSignedTopicRootCandidateState();
648
751
  this.topicControlPlaneLifecycleRevision += 1;
649
752
  this.reconcileShardOverlaysDirty = false;
650
753
  for (const opening of this.ensureFanoutChannelInFlight.values()) {
@@ -683,13 +786,6 @@ export class TopicControlPlane
683
786
  }
684
787
  }
685
788
  this.fanoutChannels.clear();
686
- for (const t of this.autoCandidatesBroadcastTimers) clearTimeout(t);
687
- this.autoCandidatesBroadcastTimers = [];
688
- if (this.autoCandidatesGossipInterval) {
689
- clearInterval(this.autoCandidatesGossipInterval);
690
- this.autoCandidatesGossipInterval = undefined;
691
- }
692
- this.autoCandidatesGossipUntil = 0;
693
789
  this.hostOwnedShardRootsDirty = false;
694
790
 
695
791
  this.subscriptions.clear();
@@ -717,25 +813,8 @@ export class TopicControlPlane
717
813
  this.reconcileShardOverlaysDirty = false;
718
814
  }
719
815
 
720
- public override async onPeerConnected(
721
- peerId: Libp2pPeerId,
722
- connection: Connection,
723
- ) {
724
- await super.onPeerConnected(peerId, connection);
725
-
726
- // If we're in auto-candidate mode, expand the deterministic shard-root
727
- // candidate set as neighbours connect, then reconcile shard overlays and
728
- // re-announce subscriptions so membership knowledge converges.
729
- if (!this.autoTopicRootCandidates) return;
730
- let peerHash: string;
731
- try {
732
- peerHash = getPublicKeyFromPeerId(peerId).hashcode();
733
- } catch {
734
- return;
735
- }
736
- void this.maybeUpdateAutoTopicRootCandidates(peerHash);
737
- }
738
-
816
+ // Candidate trust starts only after protocol negotiation in `addPeer()`;
817
+ // a transport connection alone cannot bypass signed 2.1 self-claims.
739
818
  // Ensure auto-candidate mode converges even when libp2p topology callbacks
740
819
  // are delayed or only fire for one side of a connection. `addPeer()` runs for
741
820
  // both inbound + outbound protocol streams once the remote public key is known.
@@ -745,14 +824,70 @@ export class TopicControlPlane
745
824
  protocol: string,
746
825
  connId: string,
747
826
  ): PeerStreams {
827
+ const hadSignedPeer = [...this.peers.values()].some(
828
+ (peer) => peer.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1,
829
+ );
830
+ const existingPeer = this.peers.get(publicKey.hashcode());
748
831
  const peer = super.addPeer(peerId, publicKey, protocol, connId);
749
832
  if (this.autoTopicRootCandidates) {
750
- void this.maybeUpdateAutoTopicRootCandidates(publicKey.hashcode());
751
- this.scheduleAutoTopicRootCandidatesBroadcast([peer]);
833
+ const hasSignedPeer = [...this.peers.values()].some(
834
+ (candidate) => candidate.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1,
835
+ );
836
+ this.rebuildAutoTopicRootCandidatesFromClaims(BigInt(Date.now()), {
837
+ immediate: hadSignedPeer !== hasSignedPeer,
838
+ });
839
+ if (peer.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1) {
840
+ const sendWhenOutboundReady = () => {
841
+ if (
842
+ !this.autoTopicRootCandidates ||
843
+ this.peers.get(publicKey.hashcode()) !== peer
844
+ ) {
845
+ return;
846
+ }
847
+ void this.sendAutoTopicRootCandidates([peer]).catch(() => {});
848
+ };
849
+ if (peer.isWritable) {
850
+ void this.sendAutoTopicRootCandidates([peer]).catch(() => {});
851
+ }
852
+ if (!existingPeer) {
853
+ peer.addEventListener("stream:outbound", sendWhenOutboundReady);
854
+ peer.addEventListener(
855
+ "close",
856
+ () =>
857
+ peer.removeEventListener(
858
+ "stream:outbound",
859
+ sendWhenOutboundReady,
860
+ ),
861
+ { once: true },
862
+ );
863
+ }
864
+ }
752
865
  }
753
866
  return peer;
754
867
  }
755
868
 
869
+ protected override async _removePeer(publicKey: PublicSignKey) {
870
+ const hash = publicKey.hashcode();
871
+ const protocol = this.peers.get(hash)?.protocol;
872
+ const hadSignedPeer = [...this.peers.values()].some(
873
+ (peer) => peer.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1,
874
+ );
875
+ const removed = await super._removePeer(publicKey);
876
+ if (
877
+ this.autoTopicRootCandidates &&
878
+ (protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_0 ||
879
+ protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1)
880
+ ) {
881
+ const hasSignedPeer = [...this.peers.values()].some(
882
+ (peer) => peer.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1,
883
+ );
884
+ this.rebuildAutoTopicRootCandidatesFromClaims(BigInt(Date.now()), {
885
+ immediate: hadSignedPeer !== hasSignedPeer,
886
+ });
887
+ }
888
+ return removed;
889
+ }
890
+
756
891
  private maybeDisableAutoTopicRootCandidatesIfExternallyConfigured(): boolean {
757
892
  if (!this.autoTopicRootCandidates) return false;
758
893
 
@@ -768,6 +903,7 @@ export class TopicControlPlane
768
903
  // intact and reconcile shard overlays under the new mapping.
769
904
  this.autoTopicRootCandidates = false;
770
905
  this.autoTopicRootCandidateSet = undefined;
906
+ this.clearSignedTopicRootCandidateState();
771
907
  this.clearAutoTopicRootCandidateUpdateSchedule();
772
908
  this.shardRootCache.clear();
773
909
 
@@ -780,129 +916,629 @@ export class TopicControlPlane
780
916
  return true;
781
917
  }
782
918
 
783
- private maybeUpdateAutoTopicRootCandidates(peerHash: string) {
784
- if (!this.autoTopicRootCandidates) return;
785
- if (
786
- !isCanonicalTopicRootCandidate(peerHash) ||
787
- peerHash === this.publicKeyHash
788
- )
789
- return;
919
+ private clearTopicRootCandidateClaimTimer() {
920
+ if (this.topicRootCandidateClaimMaintenanceTimer) {
921
+ clearTimeout(this.topicRootCandidateClaimMaintenanceTimer);
922
+ this.topicRootCandidateClaimMaintenanceTimer = undefined;
923
+ }
924
+ }
790
925
 
791
- this.queueAutoTopicRootCandidateUpdate([peerHash]);
926
+ private clearSignedTopicRootCandidateState() {
927
+ this.clearTopicRootCandidateClaimTimer();
928
+ this.signedTopicRootCandidateClaims.clear();
929
+ this.topicRootCandidateClaimReplayFloors.clear();
930
+ this.localSignedTopicRootCandidateClaim = undefined;
931
+ this.topicRootCandidateClaimRefreshNotBefore = undefined;
932
+ this.topicRootCandidateClaimMaintenanceRefreshInFlight = undefined;
933
+ this.topicRootCandidateClaimVerifyBudgets.clear();
934
+ this.topicRootCandidateClaimGlobalVerifyBudget = {
935
+ remaining: TOPIC_ROOT_CANDIDATE_CLAIM_GLOBAL_VERIFY_BURST,
936
+ refilledAt: Date.now(),
937
+ };
792
938
  }
793
939
 
794
- private normalizeAutoTopicRootCandidates(candidates: string[]): string[] {
795
- const canonicalCandidates = candidates.filter(
796
- isCanonicalTopicRootCandidate,
940
+ private isTopicRootCandidateClaimTimeValid(
941
+ timestamp: bigint,
942
+ expires: bigint,
943
+ now: bigint,
944
+ ): boolean {
945
+ return (
946
+ expires > now &&
947
+ timestamp <= now + BigInt(TOPIC_ROOT_CANDIDATE_CLAIM_FUTURE_SKEW_MS) &&
948
+ expires - timestamp === BigInt(TOPIC_ROOT_CANDIDATE_CLAIM_LIFETIME_MS)
797
949
  );
798
- if (this.nativeTopicControl) {
799
- return this.nativeTopicControl.normalizeAutoCandidates(
800
- canonicalCandidates,
801
- this.publicKeyHash,
950
+ }
951
+
952
+ private topicRootCandidateClaimAcceptanceDeadline(
953
+ timestamp: bigint,
954
+ expires: bigint,
955
+ wallNow: bigint,
956
+ monotonicNow: number,
957
+ maxRemainingMs: number,
958
+ ): number | undefined {
959
+ if (!this.isTopicRootCandidateClaimTimeValid(timestamp, expires, wallNow)) {
960
+ return undefined;
961
+ }
962
+ return (
963
+ monotonicNow +
964
+ Math.min(Number(expires - wallNow), Math.max(0, maxRemainingMs))
965
+ );
966
+ }
967
+
968
+ private async refreshLocalTopicRootCandidateClaim(): Promise<boolean> {
969
+ if (
970
+ !this.autoTopicRootCandidates ||
971
+ !this.started ||
972
+ this.stopping ||
973
+ this.topicControlPlaneStopping
974
+ ) {
975
+ return false;
976
+ }
977
+ if (!this.canRetainTopicRootCandidateClaimOrigin(this.publicKeyHash)) {
978
+ return false;
979
+ }
980
+ const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
981
+ const claim = await this.createMessage(this.topicRootCandidateClaimData, {
982
+ mode: new AnyWhere(),
983
+ priority: 1,
984
+ expiresInMs: TOPIC_ROOT_CANDIDATE_CLAIM_LIFETIME_MS,
985
+ skipRecipientValidation: true,
986
+ } as any);
987
+ const bytes = toUint8Array(claim.bytes());
988
+ if (bytes.byteLength > TOPIC_ROOT_CANDIDATE_CLAIM_MAX_BYTES) {
989
+ throw new Error(
990
+ `Local topic-root candidate claim exceeds ${TOPIC_ROOT_CANDIDATE_CLAIM_MAX_BYTES} bytes`,
802
991
  );
803
992
  }
804
- const unique = new Set<string>();
805
- for (const c of canonicalCandidates) {
806
- unique.add(c);
993
+ if (
994
+ lifecycleRevision !== this.topicControlPlaneLifecycleRevision ||
995
+ !this.autoTopicRootCandidates ||
996
+ !this.started ||
997
+ this.stopping ||
998
+ this.topicControlPlaneStopping
999
+ ) {
1000
+ return false;
807
1001
  }
808
- if (isCanonicalTopicRootCandidate(this.publicKeyHash)) {
809
- unique.add(this.publicKeyHash);
1002
+ const timestamp = claim.header.timestamp;
1003
+ const expires = claim.header.expires;
1004
+ const acceptanceDeadline = this.topicRootCandidateClaimAcceptanceDeadline(
1005
+ timestamp,
1006
+ expires,
1007
+ BigInt(Date.now()),
1008
+ performance.now(),
1009
+ TOPIC_ROOT_CANDIDATE_CLAIM_LIFETIME_MS,
1010
+ );
1011
+ if (acceptanceDeadline === undefined) {
1012
+ return false;
1013
+ }
1014
+ const record = {
1015
+ bytes,
1016
+ timestamp,
1017
+ expires,
1018
+ acceptUntil: acceptanceDeadline,
1019
+ };
1020
+ const current = this.localSignedTopicRootCandidateClaim;
1021
+ if (current && record.timestamp <= current.timestamp) {
1022
+ return false;
1023
+ }
1024
+ if (!this.retainSignedTopicRootCandidateClaim(this.publicKeyHash, record)) {
1025
+ return false;
810
1026
  }
811
- const sorted = [...unique].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
812
- return sorted.slice(0, TOPIC_ROOT_CANDIDATES_MAX);
1027
+ this.localSignedTopicRootCandidateClaim = record;
1028
+ this.topicRootCandidateClaimRefreshNotBefore = undefined;
1029
+ this.rebuildAutoTopicRootCandidatesFromClaims();
1030
+ return this.localSignedTopicRootCandidateClaim === record;
813
1031
  }
814
1032
 
815
- private scheduleAutoTopicRootCandidatesBroadcast(targets?: PeerStreams[]) {
816
- if (!this.autoTopicRootCandidates) return;
817
- if (!this.started || this.stopping) return;
818
-
819
- if (targets && targets.length > 0) {
820
- void this.sendAutoTopicRootCandidates(targets).catch(() => {});
1033
+ private scheduleTopicRootCandidateClaimMaintenance(minDelayMs = 0) {
1034
+ if (
1035
+ !this.autoTopicRootCandidates ||
1036
+ !this.started ||
1037
+ this.stopping ||
1038
+ this.topicControlPlaneStopping
1039
+ ) {
821
1040
  return;
822
1041
  }
1042
+ this.clearTopicRootCandidateClaimTimer();
1043
+ const now = Date.now();
1044
+ const monotonicNow = performance.now();
1045
+ const local = this.localSignedTopicRootCandidateClaim;
1046
+ const localOriginEligible = this.canRetainTopicRootCandidateClaimOrigin(
1047
+ this.publicKeyHash,
1048
+ );
1049
+ if (localOriginEligible && minDelayMs > 0) {
1050
+ this.topicRootCandidateClaimRefreshNotBefore = Math.max(
1051
+ this.topicRootCandidateClaimRefreshNotBefore ?? 0,
1052
+ monotonicNow + minDelayMs,
1053
+ );
1054
+ } else if (!localOriginEligible) {
1055
+ this.topicRootCandidateClaimRefreshNotBefore = undefined;
1056
+ }
1057
+ const canScheduleLocalRefresh =
1058
+ localOriginEligible &&
1059
+ !this.topicRootCandidateClaimMaintenanceRefreshInFlight;
1060
+ const refreshDueAt = local
1061
+ ? Number(local.timestamp) +
1062
+ TOPIC_ROOT_CANDIDATE_CLAIM_REFRESH_MS +
1063
+ Math.floor(Math.random() * TOPIC_ROOT_CANDIDATE_CLAIM_REFRESH_JITTER_MS)
1064
+ : canScheduleLocalRefresh
1065
+ ? now
1066
+ : Number.POSITIVE_INFINITY;
1067
+ let expiryDelayMs = Number.POSITIVE_INFINITY;
1068
+ for (const claim of this.signedTopicRootCandidateClaims.values()) {
1069
+ expiryDelayMs = Math.min(
1070
+ expiryDelayMs,
1071
+ Number(claim.expires) - now,
1072
+ claim.acceptUntil - monotonicNow,
1073
+ );
1074
+ }
1075
+ if (local) {
1076
+ expiryDelayMs = Math.min(
1077
+ expiryDelayMs,
1078
+ Number(local.expires) - now,
1079
+ local.acceptUntil - monotonicNow,
1080
+ );
1081
+ }
1082
+ const refreshDelayMs = canScheduleLocalRefresh
1083
+ ? Math.max(
1084
+ refreshDueAt - now,
1085
+ (this.topicRootCandidateClaimRefreshNotBefore ?? monotonicNow) -
1086
+ monotonicNow,
1087
+ 0,
1088
+ )
1089
+ : Number.POSITIVE_INFINITY;
1090
+ expiryDelayMs = Math.max(Math.ceil(expiryDelayMs), 0);
1091
+ const delayMs = Math.min(2_147_483_647, refreshDelayMs, expiryDelayMs);
1092
+ const timer = setTimeout(() => {
1093
+ if (this.topicRootCandidateClaimMaintenanceTimer !== timer) return;
1094
+ this.topicRootCandidateClaimMaintenanceTimer = undefined;
1095
+ let retryDelayMs = 0;
1096
+ try {
1097
+ this.rebuildAutoTopicRootCandidatesFromClaims();
1098
+ const currentLocal = this.localSignedTopicRootCandidateClaim;
1099
+ const refreshDue =
1100
+ !this.topicRootCandidateClaimMaintenanceRefreshInFlight &&
1101
+ this.canRetainTopicRootCandidateClaimOrigin(this.publicKeyHash) &&
1102
+ performance.now() >=
1103
+ (this.topicRootCandidateClaimRefreshNotBefore ?? 0) &&
1104
+ (!currentLocal ||
1105
+ Date.now() - Number(currentLocal.timestamp) >=
1106
+ TOPIC_ROOT_CANDIDATE_CLAIM_REFRESH_MS);
1107
+ if (refreshDue) {
1108
+ this.startTopicRootCandidateClaimMaintenanceRefresh();
1109
+ }
1110
+ } catch (error: any) {
1111
+ logErrorIfStarted(error);
1112
+ retryDelayMs = TOPIC_ROOT_CANDIDATE_CLAIM_REFRESH_RETRY_MS;
1113
+ }
1114
+ if (!this.topicRootCandidateClaimMaintenanceTimer) {
1115
+ // Re-arm immediately. While refresh is in flight, its due time is
1116
+ // treated as infinity but independent claim expiries remain scheduled.
1117
+ this.scheduleTopicRootCandidateClaimMaintenance(retryDelayMs);
1118
+ }
1119
+ }, delayMs);
1120
+ timer.unref?.();
1121
+ this.topicRootCandidateClaimMaintenanceTimer = timer;
1122
+ }
823
1123
 
824
- for (const t of this.autoCandidatesBroadcastTimers) clearTimeout(t);
825
- this.autoCandidatesBroadcastTimers = [];
1124
+ private startTopicRootCandidateClaimMaintenanceRefresh() {
1125
+ if (this.topicRootCandidateClaimMaintenanceRefreshInFlight) return;
1126
+ const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
1127
+ let task!: Promise<void>;
1128
+ task = (async () => {
1129
+ try {
1130
+ const refreshed = await this.refreshLocalTopicRootCandidateClaim();
1131
+ if (
1132
+ this.topicRootCandidateClaimMaintenanceRefreshInFlight !== task ||
1133
+ lifecycleRevision !== this.topicControlPlaneLifecycleRevision
1134
+ ) {
1135
+ return;
1136
+ }
1137
+ const local = this.localSignedTopicRootCandidateClaim;
1138
+ if (!refreshed || !local) {
1139
+ if (this.canRetainTopicRootCandidateClaimOrigin(this.publicKeyHash)) {
1140
+ this.topicRootCandidateClaimRefreshNotBefore =
1141
+ performance.now() + TOPIC_ROOT_CANDIDATE_CLAIM_REFRESH_RETRY_MS;
1142
+ } else {
1143
+ this.topicRootCandidateClaimRefreshNotBefore = undefined;
1144
+ }
1145
+ return;
1146
+ }
1147
+ this.topicRootCandidateClaimRefreshNotBefore = undefined;
1148
+ // Arm the new local lease's expiry before outbound I/O can stall.
1149
+ this.scheduleTopicRootCandidateClaimMaintenance();
1150
+ try {
1151
+ await this.sendSignedTopicRootCandidateClaims(
1152
+ [local.bytes],
1153
+ undefined,
1154
+ AbortSignal.timeout(
1155
+ TOPIC_ROOT_CANDIDATE_CLAIM_ADVERTISEMENT_TIMEOUT_MS,
1156
+ ),
1157
+ );
1158
+ } catch (error: any) {
1159
+ // A later renewal or outbound-stream snapshot retries this
1160
+ // best-effort advertisement; the signed lease itself is valid.
1161
+ logErrorIfStarted(error);
1162
+ }
1163
+ } catch (error: any) {
1164
+ if (
1165
+ this.topicRootCandidateClaimMaintenanceRefreshInFlight === task &&
1166
+ lifecycleRevision === this.topicControlPlaneLifecycleRevision
1167
+ ) {
1168
+ logErrorIfStarted(error);
1169
+ this.topicRootCandidateClaimRefreshNotBefore =
1170
+ this.canRetainTopicRootCandidateClaimOrigin(this.publicKeyHash)
1171
+ ? performance.now() + TOPIC_ROOT_CANDIDATE_CLAIM_REFRESH_RETRY_MS
1172
+ : undefined;
1173
+ }
1174
+ } finally {
1175
+ if (
1176
+ this.topicRootCandidateClaimMaintenanceRefreshInFlight === task &&
1177
+ lifecycleRevision === this.topicControlPlaneLifecycleRevision
1178
+ ) {
1179
+ this.topicRootCandidateClaimMaintenanceRefreshInFlight = undefined;
1180
+ this.scheduleTopicRootCandidateClaimMaintenance();
1181
+ }
1182
+ }
1183
+ })();
1184
+ this.topicRootCandidateClaimMaintenanceRefreshInFlight = task;
1185
+ }
826
1186
 
827
- // Burst a few times to survive early "stream not writable yet" races.
828
- const delays = [25, 500, 2_000];
829
- for (const delayMs of delays) {
830
- const t = setTimeout(() => {
831
- void this.sendAutoTopicRootCandidates().catch(() => {});
832
- }, delayMs);
833
- t.unref?.();
834
- this.autoCandidatesBroadcastTimers.push(t);
1187
+ private pruneExpiredTopicRootCandidateClaims(
1188
+ now: bigint = BigInt(Date.now()),
1189
+ monotonicNow: number = performance.now(),
1190
+ ) {
1191
+ const localClaim = this.localSignedTopicRootCandidateClaim;
1192
+ if (
1193
+ localClaim &&
1194
+ (localClaim.expires <= now || localClaim.acceptUntil <= monotonicNow)
1195
+ ) {
1196
+ this.localSignedTopicRootCandidateClaim = undefined;
1197
+ }
1198
+ let changed = false;
1199
+ for (const [origin, claim] of this.signedTopicRootCandidateClaims) {
1200
+ if (claim.expires <= now || claim.acceptUntil <= monotonicNow) {
1201
+ this.signedTopicRootCandidateClaims.delete(origin);
1202
+ changed = true;
1203
+ }
835
1204
  }
1205
+ return changed;
1206
+ }
836
1207
 
837
- // Keep gossiping for a while after changes so partially connected topologies
838
- // converge even under slow stream negotiation.
839
- this.autoCandidatesGossipUntil = Date.now() + 60_000;
840
- this.ensureAutoCandidatesGossipInterval();
1208
+ private rebuildAutoTopicRootCandidatesFromClaims(
1209
+ now: bigint = BigInt(Date.now()),
1210
+ options?: { immediate?: boolean },
1211
+ ): boolean {
1212
+ if (!this.autoTopicRootCandidates) return false;
1213
+ const removedExpired = this.pruneExpiredTopicRootCandidateClaims(now);
1214
+ const legacyDirectCandidates = [...this.peers.values()]
1215
+ .filter((peer) => peer.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_0)
1216
+ .map((peer) => peer.publicKey.hashcode());
1217
+ const hasSignedPeer = [...this.peers.values()].some(
1218
+ (peer) => peer.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1,
1219
+ );
1220
+ const next = [
1221
+ ...new Set([
1222
+ ...(this.localSignedTopicRootCandidateClaim || hasSignedPeer
1223
+ ? []
1224
+ : [this.publicKeyHash]),
1225
+ ...legacyDirectCandidates,
1226
+ ...this.signedTopicRootCandidateClaims.keys(),
1227
+ ]),
1228
+ ]
1229
+ .filter(isCanonicalTopicRootCandidate)
1230
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
1231
+ .slice(0, TOPIC_ROOT_CANDIDATES_MAX);
1232
+ return this.setAutoTopicRootCandidateSnapshot(next, {
1233
+ // A cooldown may coalesce additions, but it must never extend an expired
1234
+ // candidate's effective lease. The full snapshot is authoritative, so any
1235
+ // queued pre-expiry snapshot can be discarded safely here.
1236
+ immediate: removedExpired || options?.immediate,
1237
+ });
841
1238
  }
842
1239
 
843
- private ensureAutoCandidatesGossipInterval() {
844
- if (!this.autoTopicRootCandidates) return;
845
- if (!this.started || this.stopping) return;
846
- if (this.autoCandidatesGossipInterval) return;
847
- this.autoCandidatesGossipInterval = setInterval(() => {
848
- if (!this.started || this.stopping || !this.autoTopicRootCandidates)
849
- return;
1240
+ private retainSignedTopicRootCandidateClaim(
1241
+ origin: string,
1242
+ claim: TopicRootCandidateClaimRecord,
1243
+ ): boolean {
1244
+ if (
1245
+ !this.canAdvanceTopicRootCandidateClaimReplayFloor(
1246
+ origin,
1247
+ claim.timestamp,
1248
+ )
1249
+ ) {
1250
+ return false;
1251
+ }
1252
+ const existing = this.signedTopicRootCandidateClaims.get(origin);
1253
+ if (existing) {
1254
+ if (claim.timestamp <= existing.timestamp) {
1255
+ return false;
1256
+ }
1257
+ }
1258
+ if (!this.topicRootCandidateClaimReplayFloors.has(origin)) {
850
1259
  if (
851
- this.autoCandidatesGossipUntil > 0 &&
852
- Date.now() > this.autoCandidatesGossipUntil
1260
+ this.topicRootCandidateClaimReplayFloors.size >=
1261
+ TOPIC_ROOT_CANDIDATES_MAX
853
1262
  ) {
854
- if (this.autoCandidatesGossipInterval) {
855
- clearInterval(this.autoCandidatesGossipInterval);
856
- this.autoCandidatesGossipInterval = undefined;
1263
+ const highest = [...this.topicRootCandidateClaimReplayFloors.keys()]
1264
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
1265
+ .at(-1)!;
1266
+ // `canAdvance...` admitted a lower origin. Removing the displaced
1267
+ // origin's live/local state preserves the same lowest-origin invariant;
1268
+ // because the retained maximum only decreases, it cannot re-enter.
1269
+ this.topicRootCandidateClaimReplayFloors.delete(highest);
1270
+ this.signedTopicRootCandidateClaims.delete(highest);
1271
+ if (highest === this.publicKeyHash) {
1272
+ this.localSignedTopicRootCandidateClaim = undefined;
857
1273
  }
858
- return;
859
1274
  }
860
- void this.sendAutoTopicRootCandidates().catch(() => {});
861
- }, 2_000);
862
- this.autoCandidatesGossipInterval.unref?.();
1275
+ }
1276
+ this.topicRootCandidateClaimReplayFloors.set(origin, claim.timestamp);
1277
+ this.signedTopicRootCandidateClaims.set(origin, claim);
1278
+ return true;
863
1279
  }
864
1280
 
865
- private async sendAutoTopicRootCandidates(targets?: PeerStreams[]) {
866
- if (!this.started) throw new NotStartedError();
867
- const streams = targets ?? [...this.peers.values()];
868
- if (streams.length === 0) return;
1281
+ private canAdvanceTopicRootCandidateClaimReplayFloor(
1282
+ origin: string,
1283
+ timestamp: bigint,
1284
+ ): boolean {
1285
+ const floor = this.topicRootCandidateClaimReplayFloors.get(origin);
1286
+ if (floor !== undefined) {
1287
+ return timestamp > floor;
1288
+ }
1289
+ return this.canRetainTopicRootCandidateClaimOrigin(origin);
1290
+ }
869
1291
 
870
- const candidates = this.topicRootControlPlane.getTopicRootCandidates();
871
- if (candidates.length === 0) return;
1292
+ private canRetainTopicRootCandidateClaimOrigin(origin: string): boolean {
1293
+ if (this.topicRootCandidateClaimReplayFloors.has(origin)) {
1294
+ return true;
1295
+ }
1296
+ if (
1297
+ this.topicRootCandidateClaimReplayFloors.size < TOPIC_ROOT_CANDIDATES_MAX
1298
+ ) {
1299
+ return true;
1300
+ }
1301
+ const highest = [...this.topicRootCandidateClaimReplayFloors.keys()]
1302
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
1303
+ .at(-1)!;
1304
+ return origin < highest;
1305
+ }
872
1306
 
873
- const msg = new TopicRootCandidates({ candidates });
874
- const embedded = await this.createMessage(this.encodePubSubMessage(msg), {
875
- mode: new AnyWhere(),
876
- priority: 1,
877
- skipRecipientValidation: true,
878
- } as any);
879
- await this.publishMessageMaybe(this.publicKey, embedded, streams);
1307
+ private consumeTopicRootCandidateClaimVerifyBudget(
1308
+ peerHash: string,
1309
+ ): boolean {
1310
+ const now = Date.now();
1311
+ if (
1312
+ now - this.topicRootCandidateClaimGlobalVerifyBudget.refilledAt >=
1313
+ TOPIC_ROOT_CANDIDATE_CLAIM_VERIFY_REFILL_MS
1314
+ ) {
1315
+ this.topicRootCandidateClaimGlobalVerifyBudget = {
1316
+ remaining: TOPIC_ROOT_CANDIDATE_CLAIM_GLOBAL_VERIFY_BURST,
1317
+ refilledAt: now,
1318
+ };
1319
+ }
1320
+ if (this.topicRootCandidateClaimGlobalVerifyBudget.remaining <= 0) {
1321
+ return false;
1322
+ }
1323
+ let budget = this.topicRootCandidateClaimVerifyBudgets.get(peerHash);
1324
+ if (!budget) {
1325
+ if (
1326
+ this.topicRootCandidateClaimVerifyBudgets.size >=
1327
+ TOPIC_ROOT_CANDIDATE_CLAIM_VERIFY_PEERS_MAX
1328
+ ) {
1329
+ const oldest = this.topicRootCandidateClaimVerifyBudgets.keys().next()
1330
+ .value as string | undefined;
1331
+ if (oldest) this.topicRootCandidateClaimVerifyBudgets.delete(oldest);
1332
+ }
1333
+ budget = {
1334
+ remaining: TOPIC_ROOT_CANDIDATE_CLAIM_VERIFY_BURST,
1335
+ refilledAt: now,
1336
+ };
1337
+ this.topicRootCandidateClaimVerifyBudgets.set(peerHash, budget);
1338
+ } else if (
1339
+ now - budget.refilledAt >=
1340
+ TOPIC_ROOT_CANDIDATE_CLAIM_VERIFY_REFILL_MS
1341
+ ) {
1342
+ budget.remaining = TOPIC_ROOT_CANDIDATE_CLAIM_VERIFY_BURST;
1343
+ budget.refilledAt = now;
1344
+ // Refresh insertion order so bounded eviction remains O(1) and LRU-like.
1345
+ this.topicRootCandidateClaimVerifyBudgets.delete(peerHash);
1346
+ this.topicRootCandidateClaimVerifyBudgets.set(peerHash, budget);
1347
+ }
1348
+ if (budget.remaining <= 0) return false;
1349
+ budget.remaining -= 1;
1350
+ this.topicRootCandidateClaimGlobalVerifyBudget.remaining -= 1;
1351
+ return true;
880
1352
  }
881
1353
 
882
- private mergeAutoTopicRootCandidatesFromPeer(candidates: string[]): boolean {
883
- if (!this.autoTopicRootCandidates) return false;
884
- return this.queueAutoTopicRootCandidateUpdate(candidates);
1354
+ private async importSignedTopicRootCandidateClaim(
1355
+ rawClaim: Uint8Array,
1356
+ outerPeerHash: string,
1357
+ ): Promise<boolean> {
1358
+ const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
1359
+ if (
1360
+ rawClaim.byteLength === 0 ||
1361
+ rawClaim.byteLength > TOPIC_ROOT_CANDIDATE_CLAIM_MAX_BYTES
1362
+ ) {
1363
+ return false;
1364
+ }
1365
+ let claim: DataMessage;
1366
+ try {
1367
+ claim = DataMessage.from(new Uint8ArrayList(rawClaim));
1368
+ } catch {
1369
+ return false;
1370
+ }
1371
+ const canonicalBytes = toUint8Array(claim.bytes());
1372
+ if (!bytesEqual(canonicalBytes, rawClaim)) return false;
1373
+ if (
1374
+ !claim.data ||
1375
+ !bytesEqual(claim.data, this.topicRootCandidateClaimData)
1376
+ ) {
1377
+ return false;
1378
+ }
1379
+ const signatures = claim.header.signatures?.signatures;
1380
+ if (!signatures || signatures.length !== 1) return false;
1381
+ const signer = signatures[0]?.publicKey;
1382
+ if (!signer) return false;
1383
+ const origin = signer.hashcode();
1384
+ if (!isCanonicalTopicRootCandidate(origin)) return false;
1385
+ if (origin === this.publicKeyHash) return false;
1386
+
1387
+ const now = BigInt(Date.now());
1388
+ const monotonicNow = performance.now();
1389
+ const timestamp = claim.header.timestamp;
1390
+ const expires = claim.header.expires;
1391
+ const acceptanceDeadline = this.topicRootCandidateClaimAcceptanceDeadline(
1392
+ timestamp,
1393
+ expires,
1394
+ now,
1395
+ monotonicNow,
1396
+ TOPIC_ROOT_CANDIDATE_CLAIM_RECEIVER_MAX_LIFETIME_MS,
1397
+ );
1398
+ if (acceptanceDeadline === undefined) {
1399
+ return false;
1400
+ }
1401
+ if (!this.canAdvanceTopicRootCandidateClaimReplayFloor(origin, timestamp)) {
1402
+ return false;
1403
+ }
1404
+
1405
+ this.rebuildAutoTopicRootCandidatesFromClaims(now);
1406
+ if (!this.consumeTopicRootCandidateClaimVerifyBudget(outerPeerHash)) {
1407
+ return false;
1408
+ }
1409
+ this.seedNativeWireVerification(claim, rawClaim);
1410
+ let verified = false;
1411
+ try {
1412
+ verified = await claim.verify(true);
1413
+ } catch {
1414
+ return false;
1415
+ }
1416
+ if (!verified) return false;
1417
+ if (
1418
+ lifecycleRevision !== this.topicControlPlaneLifecycleRevision ||
1419
+ !this.isTopicRootCandidateClaimTimeValid(
1420
+ timestamp,
1421
+ expires,
1422
+ BigInt(Date.now()),
1423
+ ) ||
1424
+ performance.now() >= acceptanceDeadline ||
1425
+ !this.autoTopicRootCandidates ||
1426
+ !this.started ||
1427
+ this.stopping ||
1428
+ this.topicControlPlaneStopping
1429
+ ) {
1430
+ return false;
1431
+ }
1432
+
1433
+ if (
1434
+ !this.retainSignedTopicRootCandidateClaim(origin, {
1435
+ bytes: rawClaim.slice(),
1436
+ timestamp,
1437
+ expires,
1438
+ acceptUntil: acceptanceDeadline,
1439
+ })
1440
+ ) {
1441
+ return false;
1442
+ }
1443
+ this.scheduleTopicRootCandidateClaimMaintenance();
1444
+ return true;
1445
+ }
1446
+
1447
+ private async sendSignedTopicRootCandidateClaims(
1448
+ claims: Uint8Array[],
1449
+ targets?: PeerStreams[],
1450
+ signal?: AbortSignal,
1451
+ ) {
1452
+ if (claims.length === 0) return;
1453
+ const streams = (targets ?? [...this.peers.values()]).filter(
1454
+ (stream) => stream.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1,
1455
+ );
1456
+ if (streams.length === 0) return;
1457
+ for (
1458
+ let offset = 0;
1459
+ offset < claims.length;
1460
+ offset += TOPIC_ROOT_CANDIDATE_CLAIMS_MAX
1461
+ ) {
1462
+ throwIfAborted(signal);
1463
+ const message = new TopicRootCandidateClaims({
1464
+ claims: claims.slice(offset, offset + TOPIC_ROOT_CANDIDATE_CLAIMS_MAX),
1465
+ });
1466
+ const embedded = await withAbort(
1467
+ this.createMessage(this.encodePubSubMessage(message), {
1468
+ mode: new AnyWhere(),
1469
+ priority: 1,
1470
+ skipRecipientValidation: true,
1471
+ } as any),
1472
+ signal,
1473
+ );
1474
+ throwIfAborted(signal);
1475
+ await this.publishMessageMaybe(
1476
+ this.publicKey,
1477
+ embedded,
1478
+ streams,
1479
+ undefined,
1480
+ signal,
1481
+ );
1482
+ }
885
1483
  }
886
1484
 
887
- private queueAutoTopicRootCandidateUpdate(
888
- candidates: readonly string[],
1485
+ private async sendAutoTopicRootCandidates(streams: PeerStreams[]) {
1486
+ if (!this.started) throw new NotStartedError();
1487
+ const signedStreams = streams.filter(
1488
+ (stream) => stream.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1,
1489
+ );
1490
+ if (signedStreams.length === 0) return;
1491
+ this.rebuildAutoTopicRootCandidatesFromClaims();
1492
+ if (!this.localSignedTopicRootCandidateClaim) {
1493
+ try {
1494
+ await this.refreshLocalTopicRootCandidateClaim();
1495
+ } catch (error: any) {
1496
+ // Failure to refresh the nested local claim must not suppress
1497
+ // authenticated remote claims; outer-envelope signing is still tried.
1498
+ logErrorIfStarted(error);
1499
+ }
1500
+ }
1501
+ const claims = [...this.signedTopicRootCandidateClaims.entries()]
1502
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
1503
+ .map(([, claim]) => claim.bytes);
1504
+ await this.sendSignedTopicRootCandidateClaims(claims, signedStreams);
1505
+ const localClaim = this.localSignedTopicRootCandidateClaim;
1506
+ if (
1507
+ localClaim &&
1508
+ !this.signedTopicRootCandidateClaims.has(this.publicKeyHash)
1509
+ ) {
1510
+ await this.sendSignedTopicRootCandidateClaims(
1511
+ [localClaim.bytes],
1512
+ signedStreams,
1513
+ );
1514
+ }
1515
+ }
1516
+
1517
+ private setAutoTopicRootCandidateSnapshot(
1518
+ next: string[],
1519
+ options?: { immediate?: boolean },
889
1520
  ): boolean {
890
1521
  if (
891
1522
  !this.autoTopicRootCandidates ||
892
1523
  this.stopping ||
893
1524
  this.topicControlPlaneStopping
894
- )
1525
+ ) {
895
1526
  return false;
896
- if (this.maybeDisableAutoTopicRootCandidatesIfExternallyConfigured())
1527
+ }
1528
+ if (this.maybeDisableAutoTopicRootCandidatesIfExternallyConfigured()) {
897
1529
  return false;
1530
+ }
898
1531
  const managed = this.autoTopicRootCandidateSet;
899
1532
  if (!managed) return false;
900
-
1533
+ if (options?.immediate) {
1534
+ this.clearAutoTopicRootCandidateUpdateSchedule();
1535
+ const current = [...managed];
1536
+ if (sameCandidates(current, next)) return false;
1537
+ this.scheduleAutoTopicRootCandidateUpdateCooldown();
1538
+ this.applyAutoTopicRootCandidates(next);
1539
+ return true;
1540
+ }
901
1541
  const before = this.pendingAutoTopicRootCandidates ?? [...managed];
902
- const next = this.normalizeAutoTopicRootCandidates([
903
- ...before,
904
- ...candidates,
905
- ]);
906
1542
  if (sameCandidates(before, next)) return false;
907
1543
 
908
1544
  if (this.autoTopicRootCandidateUpdateTimer) {
@@ -921,7 +1557,6 @@ export class TopicControlPlane
921
1557
  this.shardRootCache.clear();
922
1558
  this.scheduleReconcileShardOverlays();
923
1559
  this.scheduleHostOwnedShardRoots();
924
- this.scheduleAutoTopicRootCandidatesBroadcast();
925
1560
  }
926
1561
 
927
1562
  private scheduleAutoTopicRootCandidateUpdateCooldown() {
@@ -1057,11 +1692,14 @@ export class TopicControlPlane
1057
1692
  topics: userTopics,
1058
1693
  requestSubscribers: true,
1059
1694
  });
1060
- const embedded = await this.createMessage(this.encodePubSubMessage(msg), {
1061
- mode: new AnyWhere(),
1062
- priority: 1,
1063
- skipRecipientValidation: true,
1064
- } as any);
1695
+ const embedded = await this.createMessage(
1696
+ this.encodePubSubMessage(msg),
1697
+ {
1698
+ mode: new AnyWhere(),
1699
+ priority: 1,
1700
+ skipRecipientValidation: true,
1701
+ } as any,
1702
+ );
1065
1703
  const st = this.fanoutChannels.get(shardTopic);
1066
1704
  if (!st) return;
1067
1705
  await st.channel.publish(toUint8Array(embedded.bytes()));
@@ -1277,6 +1915,11 @@ export class TopicControlPlane
1277
1915
  if (bytes[0] === 4) {
1278
1916
  assertTopicRootCandidatesFrame(bytes);
1279
1917
  }
1918
+ // Signed candidate claims are low-frequency opaque host-side frames. Keep
1919
+ // this explicit seam ahead of the native variants 0-7 codec.
1920
+ if (bytes[0] === 8) {
1921
+ return TopicRootCandidateClaims.from(bytes);
1922
+ }
1280
1923
  const native = this.nativeTopicControl;
1281
1924
  if (native) {
1282
1925
  const decoded = native.decodePubSubMessage(bytes);
@@ -1367,9 +2010,8 @@ export class TopicControlPlane
1367
2010
  for (;;) {
1368
2011
  for (const signal of terminalSignals) throwIfAborted(signal);
1369
2012
  const candidateGeneration = this.getTopicRootCandidateGeneration();
1370
- const candidateSignal = this.syncTopicRootCandidateResolutionSignal(
1371
- candidateGeneration,
1372
- );
2013
+ const candidateSignal =
2014
+ this.syncTopicRootCandidateResolutionSignal(candidateGeneration);
1373
2015
  const linked = linkAbortSignals([...terminalSignals, candidateSignal]);
1374
2016
  try {
1375
2017
  const result = await operation({
@@ -1444,7 +2086,8 @@ export class TopicControlPlane
1444
2086
  return this.normalizePeerTopicRootState(topic, resolvedThroughPeers);
1445
2087
  }
1446
2088
 
1447
- const deterministic = this.topicRootControlPlane.resolveDeterministicTopicRoot(topic);
2089
+ const deterministic =
2090
+ this.topicRootControlPlane.resolveDeterministicTopicRoot(topic);
1448
2091
  if (
1449
2092
  deterministic === this.publicKeyHash &&
1450
2093
  this.autoTopicRootCandidates &&
@@ -1547,7 +2190,7 @@ export class TopicControlPlane
1547
2190
  let requestId = this.nextTopicRootQueryId >>> 0;
1548
2191
  do {
1549
2192
  requestId = requestId === 0 ? 1 : requestId;
1550
- this.nextTopicRootQueryId = ((requestId + 1) >>> 0) || 1;
2193
+ this.nextTopicRootQueryId = (requestId + 1) >>> 0 || 1;
1551
2194
  if (!this.pendingTopicRootQueries.has(requestId)) {
1552
2195
  return requestId;
1553
2196
  }
@@ -1559,14 +2202,17 @@ export class TopicControlPlane
1559
2202
  peer: PeerStreams,
1560
2203
  pubsubMessage: PubSubMessage,
1561
2204
  ) {
1562
- const embedded = await this.createMessage(this.encodePubSubMessage(pubsubMessage), {
1563
- mode: new SilentDelivery({
1564
- to: [peer.publicKey.hashcode()],
1565
- redundancy: 1,
1566
- }),
1567
- priority: 1,
1568
- skipRecipientValidation: true,
1569
- } as any);
2205
+ const embedded = await this.createMessage(
2206
+ this.encodePubSubMessage(pubsubMessage),
2207
+ {
2208
+ mode: new SilentDelivery({
2209
+ to: [peer.publicKey.hashcode()],
2210
+ redundancy: 1,
2211
+ }),
2212
+ priority: 1,
2213
+ skipRecipientValidation: true,
2214
+ } as any,
2215
+ );
1570
2216
  await this.publishMessage(this.publicKey, embedded, [peer]);
1571
2217
  }
1572
2218
 
@@ -1700,10 +2346,12 @@ export class TopicControlPlane
1700
2346
  async ({ candidateGeneration, signal }) => {
1701
2347
  throwIfAborted(signal);
1702
2348
  this.assertTopicControlPlaneActive(lifecycleRevision);
1703
- const root =
1704
- await this.topicRootControlPlane.resolveCanonicalTopicRoot(topic, {
2349
+ const root = await this.topicRootControlPlane.resolveCanonicalTopicRoot(
2350
+ topic,
2351
+ {
1705
2352
  signal,
1706
- });
2353
+ },
2354
+ );
1707
2355
  this.assertTopicControlPlaneActive(lifecycleRevision);
1708
2356
  if (root !== this.publicKeyHash) {
1709
2357
  return root;
@@ -1764,10 +2412,7 @@ export class TopicControlPlane
1764
2412
  }
1765
2413
 
1766
2414
  if (attempt < 2) {
1767
- await withAbort(
1768
- delay(150 * (attempt + 1), options),
1769
- options?.signal,
1770
- );
2415
+ await withAbort(delay(150 * (attempt + 1), options), options?.signal);
1771
2416
  }
1772
2417
  }
1773
2418
  return undefined;
@@ -2084,16 +2729,16 @@ export class TopicControlPlane
2084
2729
  // stream is already established (especially in small test nets without
2085
2730
  // trackers/bootstraps). Best-effort only: join can still succeed via
2086
2731
  // trackers/other routing if this times out.
2087
- try {
2088
- await this.fanout.waitFor(root, {
2089
- target: "neighbor",
2090
- // Best-effort pre-check only: do not block subscribe/publish setup
2091
- // for long periods if the root is not yet a direct stream neighbor.
2092
- timeout: 1_000,
2093
- });
2094
- } catch {
2095
- // ignore
2096
- }
2732
+ try {
2733
+ await this.fanout.waitFor(root, {
2734
+ target: "neighbor",
2735
+ // Best-effort pre-check only: do not block subscribe/publish setup
2736
+ // for long periods if the root is not yet a direct stream neighbor.
2737
+ timeout: 1_000,
2738
+ });
2739
+ } catch {
2740
+ // ignore
2741
+ }
2097
2742
  const joinOpts = options?.signal
2098
2743
  ? { ...(this.fanoutJoinOptions ?? {}), signal: options.signal }
2099
2744
  : this.fanoutJoinOptions;
@@ -2261,11 +2906,14 @@ export class TopicControlPlane
2261
2906
  topics: userTopics,
2262
2907
  requestSubscribers: true,
2263
2908
  });
2264
- const embedded = await this.createMessage(this.encodePubSubMessage(msg), {
2265
- mode: new AnyWhere(),
2266
- priority: 1,
2267
- skipRecipientValidation: true,
2268
- } as any);
2909
+ const embedded = await this.createMessage(
2910
+ this.encodePubSubMessage(msg),
2911
+ {
2912
+ mode: new AnyWhere(),
2913
+ priority: 1,
2914
+ skipRecipientValidation: true,
2915
+ } as any,
2916
+ );
2269
2917
  const st = this.fanoutChannels.get(shardTopic);
2270
2918
  if (!st)
2271
2919
  throw new Error(`Fanout channel missing for shard: ${shardTopic}`);
@@ -2318,11 +2966,15 @@ export class TopicControlPlane
2318
2966
  }
2319
2967
 
2320
2968
  // Best-effort: do not block callers on network I/O (can hang under teardown).
2321
- void this.debounceUnsubscribeAggregator.add({ key: topic }).catch(logErrorIfStarted);
2969
+ void this.debounceUnsubscribeAggregator
2970
+ .add({ key: topic })
2971
+ .catch(logErrorIfStarted);
2322
2972
  return true;
2323
2973
  }
2324
2974
 
2325
- private async _announceUnsubscribe(topics: { key: string; counter: number }[]) {
2975
+ private async _announceUnsubscribe(
2976
+ topics: { key: string; counter: number }[],
2977
+ ) {
2326
2978
  if (!this.started) throw new NotStartedError();
2327
2979
 
2328
2980
  const byShard = new Map<string, string[]>();
@@ -2340,11 +2992,14 @@ export class TopicControlPlane
2340
2992
  // Announce first.
2341
2993
  try {
2342
2994
  const msg = new Unsubscribe({ topics: userTopics });
2343
- const embedded = await this.createMessage(this.encodePubSubMessage(msg), {
2344
- mode: new AnyWhere(),
2345
- priority: 1,
2346
- skipRecipientValidation: true,
2347
- } as any);
2995
+ const embedded = await this.createMessage(
2996
+ this.encodePubSubMessage(msg),
2997
+ {
2998
+ mode: new AnyWhere(),
2999
+ priority: 1,
3000
+ skipRecipientValidation: true,
3001
+ } as any,
3002
+ );
2348
3003
  const st = this.fanoutChannels.get(shardTopic);
2349
3004
  if (st) {
2350
3005
  // Best-effort: do not let a stuck proxy publish stall teardown.
@@ -2413,11 +3068,14 @@ export class TopicControlPlane
2413
3068
  timestamp: batch.timestamp,
2414
3069
  topics: batch.topics,
2415
3070
  });
2416
- const embedded = await this.createMessage(this.encodePubSubMessage(msg), {
2417
- mode: new AnyWhere(),
2418
- priority: 1,
2419
- skipRecipientValidation: true,
2420
- } as any);
3071
+ const embedded = await this.createMessage(
3072
+ this.encodePubSubMessage(msg),
3073
+ {
3074
+ mode: new AnyWhere(),
3075
+ priority: 1,
3076
+ skipRecipientValidation: true,
3077
+ } as any,
3078
+ );
2421
3079
  await this.ensureFanoutChannel(shardTopic, { ephemeral: true });
2422
3080
  const st = this.fanoutChannels.get(shardTopic);
2423
3081
  if (st) {
@@ -2462,9 +3120,10 @@ export class TopicControlPlane
2462
3120
  private onFanoutPeerUnreachable(
2463
3121
  ev: CustomEvent<{ topic: string; root: string; publicKeyHash: string }>,
2464
3122
  ) {
2465
- void this
2466
- .announcePeerUnavailableOnShard(ev.detail.publicKeyHash, ev.detail.topic)
2467
- .catch(logErrorIfStarted);
3123
+ void this.announcePeerUnavailableOnShard(
3124
+ ev.detail.publicKeyHash,
3125
+ ev.detail.topic,
3126
+ ).catch(logErrorIfStarted);
2468
3127
  }
2469
3128
 
2470
3129
  getSubscribers(topic: string): PublicSignKey[] | undefined {
@@ -2544,11 +3203,14 @@ export class TopicControlPlane
2544
3203
  const persistent = (this.shardRefCounts.get(shardTopic) ?? 0) > 0;
2545
3204
  await this.ensureFanoutChannel(shardTopic, { ephemeral: !persistent });
2546
3205
 
2547
- const embedded = await this.createMessage(this.encodePubSubMessage(msg), {
2548
- mode: new AnyWhere(),
2549
- priority: 1,
2550
- skipRecipientValidation: true,
2551
- } as any);
3206
+ const embedded = await this.createMessage(
3207
+ this.encodePubSubMessage(msg),
3208
+ {
3209
+ mode: new AnyWhere(),
3210
+ priority: 1,
3211
+ skipRecipientValidation: true,
3212
+ } as any,
3213
+ );
2552
3214
  const payload = toUint8Array(embedded.bytes());
2553
3215
 
2554
3216
  const st = this.fanoutChannels.get(shardTopic);
@@ -2810,9 +3472,9 @@ export class TopicControlPlane
2810
3472
  }))
2811
3473
  .filter((batch) => batch.topics.length > 0);
2812
3474
  if (batches.length > 0) {
2813
- void this
2814
- .announcePeerUnavailable(publicKeyHash, batches)
2815
- .catch(logErrorIfStarted);
3475
+ void this.announcePeerUnavailable(publicKeyHash, batches).catch(
3476
+ logErrorIfStarted,
3477
+ );
2816
3478
  }
2817
3479
  }
2818
3480
 
@@ -2822,7 +3484,10 @@ export class TopicControlPlane
2822
3484
  return [];
2823
3485
  }
2824
3486
 
2825
- const grouped = new Map<string, { session: bigint; timestamp: bigint; topics: string[] }>();
3487
+ const grouped = new Map<
3488
+ string,
3489
+ { session: bigint; timestamp: bigint; topics: string[] }
3490
+ >();
2826
3491
  for (const topic of peerTopics) {
2827
3492
  const existing = this.topics.get(topic)?.get(peerHash);
2828
3493
  if (!existing) {
@@ -2959,12 +3624,10 @@ export class TopicControlPlane
2959
3624
  if (!this.lastSubscriptionMessages.has(subscriberKey)) {
2960
3625
  this.lastSubscriptionMessages.set(subscriberKey, new Map());
2961
3626
  }
2962
- this.lastSubscriptionMessages
2963
- .get(subscriberKey)!
2964
- .set(topic, {
2965
- session,
2966
- timestamp,
2967
- });
3627
+ this.lastSubscriptionMessages.get(subscriberKey)!.set(topic, {
3628
+ session,
3629
+ timestamp,
3630
+ });
2968
3631
  }
2969
3632
  return true;
2970
3633
  }
@@ -3100,7 +3763,7 @@ export class TopicControlPlane
3100
3763
  } catch {
3101
3764
  // ignore and fall back
3102
3765
  }
3103
- await st.channel.publishMaybe(payload);
3766
+ await st.channel.publishMaybe(payload);
3104
3767
  }
3105
3768
 
3106
3769
  private async processDirectPubSubMessage(input: {
@@ -3112,6 +3775,7 @@ export class TopicControlPlane
3112
3775
  const { pubsubMessage, message, from, stream } = input;
3113
3776
  const isDirectRootControl =
3114
3777
  pubsubMessage instanceof TopicRootCandidates ||
3778
+ pubsubMessage instanceof TopicRootCandidateClaims ||
3115
3779
  pubsubMessage instanceof TopicRootQuery ||
3116
3780
  pubsubMessage instanceof TopicRootQueryResponse;
3117
3781
  const directRootSigner = isDirectRootControl
@@ -3125,8 +3789,41 @@ export class TopicControlPlane
3125
3789
  }
3126
3790
 
3127
3791
  if (pubsubMessage instanceof TopicRootCandidates) {
3128
- // Used only to converge deterministic shard-root candidates in auto mode.
3129
- this.mergeAutoTopicRootCandidatesFromPeer(pubsubMessage.candidates);
3792
+ if (stream.protocol !== TOPIC_CONTROL_PLANE_PROTOCOL_V2_0) {
3793
+ return;
3794
+ }
3795
+ // `addPeer()` already admitted the authenticated direct stream identity.
3796
+ // The advertised list is intentionally ignored.
3797
+ return;
3798
+ }
3799
+
3800
+ if (pubsubMessage instanceof TopicRootCandidateClaims) {
3801
+ if (
3802
+ stream.protocol !== TOPIC_CONTROL_PLANE_PROTOCOL_V2_1 ||
3803
+ !this.autoTopicRootCandidates
3804
+ ) {
3805
+ return;
3806
+ }
3807
+ const imported: Uint8Array[] = [];
3808
+ const outerPeerHash = stream.publicKey.hashcode();
3809
+ for (const claim of pubsubMessage.claims) {
3810
+ if (
3811
+ await this.importSignedTopicRootCandidateClaim(claim, outerPeerHash)
3812
+ ) {
3813
+ imported.push(claim);
3814
+ }
3815
+ }
3816
+ if (imported.length > 0) {
3817
+ this.rebuildAutoTopicRootCandidatesFromClaims();
3818
+ // Relay the exact nested claims; no relay signature is substituted for
3819
+ // their origin signatures.
3820
+ void this.sendSignedTopicRootCandidateClaims(
3821
+ imported,
3822
+ [...this.peers.values()].filter(
3823
+ (peer) => peer.publicKey.hashcode() !== outerPeerHash,
3824
+ ),
3825
+ ).catch(() => {});
3826
+ }
3130
3827
  return;
3131
3828
  }
3132
3829
 
@@ -3465,6 +4162,7 @@ export class TopicControlPlane
3465
4162
  if (
3466
4163
  !(pubsubMessage instanceof PubSubData) &&
3467
4164
  !(pubsubMessage instanceof TopicRootCandidates) &&
4165
+ !(pubsubMessage instanceof TopicRootCandidateClaims) &&
3468
4166
  !(pubsubMessage instanceof TopicRootQuery) &&
3469
4167
  !(pubsubMessage instanceof GetSubscribers) &&
3470
4168
  !(pubsubMessage instanceof Subscribe) &&
@@ -3485,8 +4183,8 @@ export class TopicControlPlane
3485
4183
  }
3486
4184
 
3487
4185
  if (pubsubMessage instanceof PubSubData) {
3488
- const wantsTopic = pubsubMessage.topics.some((t) =>
3489
- this.subscriptions.has(t) || this.pendingSubscriptions.has(t),
4186
+ const wantsTopic = pubsubMessage.topics.some(
4187
+ (t) => this.subscriptions.has(t) || this.pendingSubscriptions.has(t),
3490
4188
  );
3491
4189
  isForMe = pubsubMessage.strict ? isForMe && wantsTopic : wantsTopic;
3492
4190
  }