@k256/sdk 0.2.0 → 0.3.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.
@@ -0,0 +1,377 @@
1
+ /**
2
+ * Leader Schedule WebSocket Client
3
+ *
4
+ * Connects to the K256 leader-schedule service via the Gateway.
5
+ * Binary mode by default (wincode protocol, matching K2 pattern).
6
+ * JSON mode opt-in via mode: 'json' (gateway decodes wincode to JSON).
7
+ *
8
+ * @example
9
+ * ```typescript
10
+ * import { LeaderWebSocketClient } from '@k256/sdk/leader-ws';
11
+ *
12
+ * const client = new LeaderWebSocketClient({
13
+ * apiKey: 'your-api-key',
14
+ * onSlotUpdate: (msg) => console.log('Slot:', msg.data.slot, 'Leader:', msg.data.leader),
15
+ * onRoutingHealth: (msg) => console.log('Coverage:', msg.data.coverage),
16
+ * onGossipDiff: (msg) => console.log('Peers changed:', msg.data.added.length, 'added'),
17
+ * });
18
+ *
19
+ * await client.connect();
20
+ * ```
21
+ */
22
+
23
+ import { decodeLeaderMessage } from './decoder';
24
+ import {
25
+ ALL_LEADER_CHANNELS,
26
+ type LeaderChannelValue,
27
+ type LeaderDecodedMessage,
28
+ type LeaderSubscribedMessage,
29
+ type LeaderScheduleMessage,
30
+ type GossipSnapshotMessage,
31
+ type GossipDiffMessage,
32
+ type SlotUpdateMessage,
33
+ type RoutingHealthMessage,
34
+ type SkipEventMessage,
35
+ type IpChangeMessage,
36
+ type LeaderHeartbeatMessage,
37
+ type LeaderErrorMessage,
38
+ } from './types';
39
+
40
+ /**
41
+ * Connection state
42
+ */
43
+ export type ConnectionState =
44
+ | 'disconnected'
45
+ | 'connecting'
46
+ | 'connected'
47
+ | 'reconnecting'
48
+ | 'closed';
49
+
50
+ /**
51
+ * Leader WebSocket client configuration
52
+ */
53
+ export interface LeaderWebSocketClientConfig {
54
+ /** API key for authentication */
55
+ apiKey: string;
56
+ /** Gateway URL (default: wss://gateway.k256.xyz/v1/leader-ws) */
57
+ url?: string;
58
+ /** Message format: 'binary' (default, efficient) or 'json' (debugging via gateway) */
59
+ mode?: 'binary' | 'json';
60
+ /** Channels to subscribe to (default: all channels) */
61
+ channels?: LeaderChannelValue[];
62
+
63
+ // Reconnection settings
64
+ /** Enable automatic reconnection (default: true) */
65
+ autoReconnect?: boolean;
66
+ /** Initial reconnect delay in ms (default: 1000) */
67
+ reconnectDelayMs?: number;
68
+ /** Max reconnect delay in ms (default: 30000) */
69
+ maxReconnectDelayMs?: number;
70
+ /** Max reconnect attempts (default: Infinity) */
71
+ maxReconnectAttempts?: number;
72
+
73
+ // Event callbacks
74
+ /** Called when connection state changes */
75
+ onStateChange?: (state: ConnectionState, prevState: ConnectionState) => void;
76
+ /** Called on successful connection */
77
+ onConnect?: () => void;
78
+ /** Called on disconnection */
79
+ onDisconnect?: (code: number, reason: string, wasClean: boolean) => void;
80
+ /** Called on reconnection attempt */
81
+ onReconnecting?: (attempt: number, delayMs: number) => void;
82
+ /** Called on any error */
83
+ onError?: (error: LeaderWebSocketError) => void;
84
+
85
+ // Message callbacks
86
+ /** Called on subscription confirmed (includes protocol schema) */
87
+ onSubscribed?: (msg: LeaderSubscribedMessage) => void;
88
+ /** Called on full leader schedule (snapshot — replaces previous) */
89
+ onLeaderSchedule?: (msg: LeaderScheduleMessage) => void;
90
+ /** Called on full gossip peer snapshot (snapshot — key: identity) */
91
+ onGossipSnapshot?: (msg: GossipSnapshotMessage) => void;
92
+ /** Called on gossip diff (diff — merge into snapshot using identity) */
93
+ onGossipDiff?: (msg: GossipDiffMessage) => void;
94
+ /** Called on slot update (snapshot — each replaces previous) */
95
+ onSlotUpdate?: (msg: SlotUpdateMessage) => void;
96
+ /** Called on routing health (snapshot — each replaces previous) */
97
+ onRoutingHealth?: (msg: RoutingHealthMessage) => void;
98
+ /** Called on skip event (event — block production stats) */
99
+ onSkipEvent?: (msg: SkipEventMessage) => void;
100
+ /** Called on IP change event */
101
+ onIpChange?: (msg: IpChangeMessage) => void;
102
+ /** Called on heartbeat (every 10s) */
103
+ onHeartbeat?: (msg: LeaderHeartbeatMessage) => void;
104
+ /** Called on any message (raw) */
105
+ onMessage?: (msg: LeaderDecodedMessage) => void;
106
+ }
107
+
108
+ /**
109
+ * Error codes for leader WebSocket
110
+ */
111
+ export type LeaderErrorCode =
112
+ | 'CONNECTION_FAILED'
113
+ | 'CONNECTION_LOST'
114
+ | 'AUTH_FAILED'
115
+ | 'SERVER_ERROR'
116
+ | 'INVALID_MESSAGE'
117
+ | 'RECONNECT_FAILED';
118
+
119
+ /**
120
+ * WebSocket error with context
121
+ */
122
+ export class LeaderWebSocketError extends Error {
123
+ constructor(
124
+ public readonly code: LeaderErrorCode,
125
+ message: string,
126
+ public readonly closeCode?: number,
127
+ public readonly closeReason?: string,
128
+ ) {
129
+ super(message);
130
+ this.name = 'LeaderWebSocketError';
131
+ }
132
+
133
+ get isRecoverable(): boolean {
134
+ return this.code !== 'AUTH_FAILED';
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Leader Schedule WebSocket Client
140
+ *
141
+ * Connects to the leader-schedule service via the Gateway.
142
+ * Binary mode by default (wincode protocol). JSON mode opt-in via gateway.
143
+ * Automatically subscribes to configured channels on connect/reconnect.
144
+ */
145
+ export class LeaderWebSocketClient {
146
+ private ws: WebSocket | null = null;
147
+ private readonly config: Required<Pick<LeaderWebSocketClientConfig,
148
+ 'apiKey' | 'url' | 'mode' | 'channels' | 'autoReconnect' | 'reconnectDelayMs' | 'maxReconnectDelayMs' | 'maxReconnectAttempts'
149
+ >> & LeaderWebSocketClientConfig;
150
+
151
+ private _state: ConnectionState = 'disconnected';
152
+ private reconnectAttempts = 0;
153
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
154
+ private isIntentionallyClosed = false;
155
+
156
+ /** Current connection state */
157
+ get state(): ConnectionState { return this._state; }
158
+
159
+ /** Whether currently connected */
160
+ get isConnected(): boolean {
161
+ return this._state === 'connected' && this.ws?.readyState === WebSocket.OPEN;
162
+ }
163
+
164
+ constructor(config: LeaderWebSocketClientConfig) {
165
+ this.config = {
166
+ url: 'wss://gateway.k256.xyz/v1/leader-ws',
167
+ mode: 'binary',
168
+ channels: ALL_LEADER_CHANNELS,
169
+ autoReconnect: true,
170
+ reconnectDelayMs: 1000,
171
+ maxReconnectDelayMs: 30000,
172
+ maxReconnectAttempts: Infinity,
173
+ ...config,
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Connect to the leader-schedule WebSocket
179
+ */
180
+ async connect(): Promise<void> {
181
+ if (this._state === 'connected' || this._state === 'connecting') return;
182
+
183
+ this.isIntentionallyClosed = false;
184
+ this.setState('connecting');
185
+
186
+ return new Promise((resolve, reject) => {
187
+ try {
188
+ const url = `${this.config.url}?apiKey=${encodeURIComponent(this.config.apiKey)}`;
189
+ this.ws = new WebSocket(url);
190
+
191
+ // Binary mode needs arraybuffer
192
+ if (this.config.mode === 'binary') {
193
+ this.ws.binaryType = 'arraybuffer';
194
+ }
195
+
196
+ this.ws.onopen = () => {
197
+ this.setState('connected');
198
+ this.reconnectAttempts = 0;
199
+
200
+ if (this.config.mode === 'binary') {
201
+ // Binary mode: send 0x01 tag + JSON payload
202
+ const payload = JSON.stringify({ channels: this.config.channels });
203
+ const bytes = new TextEncoder().encode(payload);
204
+ const msg = new Uint8Array(1 + bytes.length);
205
+ msg[0] = 0x01; // MSG_SUBSCRIBE
206
+ msg.set(bytes, 1);
207
+ this.ws!.send(msg.buffer);
208
+ } else {
209
+ // JSON mode: send text (gateway decodes wincode to JSON)
210
+ this.ws!.send(JSON.stringify({
211
+ type: 'subscribe',
212
+ channels: this.config.channels,
213
+ format: 'json',
214
+ }));
215
+ }
216
+
217
+ this.config.onConnect?.();
218
+ resolve();
219
+ };
220
+
221
+ this.ws.onmessage = (event) => {
222
+ if (this.config.mode === 'binary' && event.data instanceof ArrayBuffer) {
223
+ // Binary mode: decode wincode with SDK decoder
224
+ const decoded = decodeLeaderMessage(event.data);
225
+ if (decoded) {
226
+ this.dispatchMessage(decoded);
227
+ }
228
+ } else if (typeof event.data === 'string') {
229
+ // JSON mode: parse text frames from gateway
230
+ this.handleJsonMessage(event.data);
231
+ }
232
+ };
233
+
234
+ this.ws.onclose = (event) => {
235
+ const wasConnected = this._state === 'connected';
236
+ this.ws = null;
237
+
238
+ if (this.isIntentionallyClosed) {
239
+ this.setState('closed');
240
+ this.config.onDisconnect?.(event.code, event.reason, event.wasClean);
241
+ return;
242
+ }
243
+
244
+ this.config.onDisconnect?.(event.code, event.reason, event.wasClean);
245
+
246
+ // Check for auth failure (don't reconnect)
247
+ if (event.code === 1008 || event.code === 4001 || event.code === 4003) {
248
+ this.setState('closed');
249
+ this.config.onError?.(new LeaderWebSocketError(
250
+ 'AUTH_FAILED', `Authentication failed: ${event.reason}`, event.code, event.reason
251
+ ));
252
+ if (!wasConnected) reject(new LeaderWebSocketError('AUTH_FAILED', event.reason, event.code));
253
+ return;
254
+ }
255
+
256
+ // Auto-reconnect
257
+ if (this.config.autoReconnect && this.reconnectAttempts < this.config.maxReconnectAttempts) {
258
+ this.scheduleReconnect();
259
+ } else {
260
+ this.setState('disconnected');
261
+ }
262
+
263
+ if (!wasConnected) reject(new LeaderWebSocketError('CONNECTION_FAILED', 'WebSocket closed before connect'));
264
+ };
265
+
266
+ this.ws.onerror = () => {
267
+ this.config.onError?.(new LeaderWebSocketError('CONNECTION_FAILED', 'WebSocket connection error'));
268
+ };
269
+ } catch (err) {
270
+ this.setState('disconnected');
271
+ reject(err);
272
+ }
273
+ });
274
+ }
275
+
276
+ /**
277
+ * Disconnect from the WebSocket
278
+ */
279
+ disconnect(): void {
280
+ this.isIntentionallyClosed = true;
281
+ this.clearTimers();
282
+
283
+ if (this.ws) {
284
+ if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
285
+ this.ws.close(1000, 'Client disconnect');
286
+ }
287
+ this.ws = null;
288
+ }
289
+
290
+ this.setState('closed');
291
+ }
292
+
293
+ // ── Private ──
294
+
295
+ /** Handle JSON text frame (from gateway JSON mode) */
296
+ private handleJsonMessage(raw: string): void {
297
+ try {
298
+ const msg = JSON.parse(raw) as LeaderDecodedMessage;
299
+ this.dispatchMessage(msg);
300
+ } catch {
301
+ this.config.onError?.(new LeaderWebSocketError('INVALID_MESSAGE', 'Failed to parse message'));
302
+ }
303
+ }
304
+
305
+ /** Dispatch a decoded message to typed callbacks */
306
+ private dispatchMessage(msg: LeaderDecodedMessage): void {
307
+ switch (msg.type) {
308
+ case 'subscribed':
309
+ this.config.onSubscribed?.(msg as LeaderSubscribedMessage);
310
+ break;
311
+ case 'leader_schedule':
312
+ this.config.onLeaderSchedule?.(msg as LeaderScheduleMessage);
313
+ break;
314
+ case 'gossip_snapshot':
315
+ this.config.onGossipSnapshot?.(msg as GossipSnapshotMessage);
316
+ break;
317
+ case 'gossip_diff':
318
+ this.config.onGossipDiff?.(msg as GossipDiffMessage);
319
+ break;
320
+ case 'slot_update':
321
+ this.config.onSlotUpdate?.(msg as SlotUpdateMessage);
322
+ break;
323
+ case 'routing_health':
324
+ this.config.onRoutingHealth?.(msg as RoutingHealthMessage);
325
+ break;
326
+ case 'skip_event':
327
+ this.config.onSkipEvent?.(msg as SkipEventMessage);
328
+ break;
329
+ case 'ip_change':
330
+ this.config.onIpChange?.(msg as IpChangeMessage);
331
+ break;
332
+ case 'heartbeat':
333
+ this.config.onHeartbeat?.(msg as LeaderHeartbeatMessage);
334
+ break;
335
+ case 'error':
336
+ this.config.onError?.(new LeaderWebSocketError(
337
+ 'SERVER_ERROR', (msg as LeaderErrorMessage).data.message
338
+ ));
339
+ break;
340
+ }
341
+
342
+ // Generic handler
343
+ this.config.onMessage?.(msg);
344
+ }
345
+
346
+ private setState(state: ConnectionState): void {
347
+ const prev = this._state;
348
+ if (prev === state) return;
349
+ this._state = state;
350
+ this.config.onStateChange?.(state, prev);
351
+ }
352
+
353
+ private scheduleReconnect(): void {
354
+ this.setState('reconnecting');
355
+ this.reconnectAttempts++;
356
+
357
+ const delay = Math.min(
358
+ this.config.reconnectDelayMs * Math.pow(2, this.reconnectAttempts - 1),
359
+ this.config.maxReconnectDelayMs
360
+ );
361
+
362
+ this.config.onReconnecting?.(this.reconnectAttempts, delay);
363
+
364
+ this.reconnectTimer = setTimeout(() => {
365
+ this.connect().catch(() => {
366
+ // Error handled in onclose/onerror
367
+ });
368
+ }, delay);
369
+ }
370
+
371
+ private clearTimers(): void {
372
+ if (this.reconnectTimer) {
373
+ clearTimeout(this.reconnectTimer);
374
+ this.reconnectTimer = null;
375
+ }
376
+ }
377
+ }
@@ -0,0 +1,314 @@
1
+ /**
2
+ * Binary message decoder for Leader Schedule WebSocket protocol
3
+ *
4
+ * Decodes wincode messages from the leader-schedule server into typed objects.
5
+ * Matches K2 decoder pattern: manual DataView offset walking, little-endian.
6
+ *
7
+ * Wire format: [1 byte MessageType][N bytes wincode payload]
8
+ */
9
+
10
+ import { base58Encode } from '../utils/base58';
11
+ import type {
12
+ LeaderDecodedMessage,
13
+ GossipPeer,
14
+ } from './types';
15
+
16
+ /** Message type tag constants (must match leader-schedule server protocol.rs) */
17
+ export const LeaderMessageTag = {
18
+ Subscribe: 0x01,
19
+ Subscribed: 0x02,
20
+ LeaderSchedule: 0x10,
21
+ GossipSnapshot: 0x11,
22
+ GossipDiff: 0x12,
23
+ SlotUpdate: 0x13,
24
+ RoutingHealth: 0x14,
25
+ SkipEvent: 0x15,
26
+ IpChange: 0x16,
27
+ Heartbeat: 0xFD,
28
+ Ping: 0xFE,
29
+ Error: 0xFF,
30
+ } as const;
31
+
32
+ /** Mutable offset tracker for sequential reading */
33
+ type Offset = { v: number };
34
+
35
+ /** Read a wincode u64 (little-endian) */
36
+ function readU64(view: DataView, o: Offset): number {
37
+ const val = Number(view.getBigUint64(o.v, true));
38
+ o.v += 8;
39
+ return val;
40
+ }
41
+
42
+ /** Read a wincode u32 (little-endian) */
43
+ function readU32(view: DataView, o: Offset): number {
44
+ const val = view.getUint32(o.v, true);
45
+ o.v += 4;
46
+ return val;
47
+ }
48
+
49
+ /** Read a wincode u16 (little-endian) */
50
+ function readU16(view: DataView, o: Offset): number {
51
+ const val = view.getUint16(o.v, true);
52
+ o.v += 2;
53
+ return val;
54
+ }
55
+
56
+ /** Read a wincode u8 */
57
+ function readU8(view: DataView, o: Offset): number {
58
+ const val = view.getUint8(o.v);
59
+ o.v += 1;
60
+ return val;
61
+ }
62
+
63
+ /** Read a wincode bool */
64
+ function readBool(view: DataView, o: Offset): boolean {
65
+ return readU8(view, o) !== 0;
66
+ }
67
+
68
+ /** Read a [u8; 32] pubkey as base58 string */
69
+ function readPubkey(data: ArrayBuffer, o: Offset): string {
70
+ const bytes = new Uint8Array(data, o.v, 32);
71
+ o.v += 32;
72
+ return base58Encode(bytes);
73
+ }
74
+
75
+ /** Read a wincode Vec<u8> as string */
76
+ function readVecU8AsString(view: DataView, data: ArrayBuffer, o: Offset): string {
77
+ const len = readU64(view, o);
78
+ const bytes = new Uint8Array(data, o.v, len);
79
+ o.v += len;
80
+ return new TextDecoder().decode(bytes);
81
+ }
82
+
83
+ /** Read a wincode Option<SocketAddrWire>: tag + ip[16] + port:u16 + is_ipv4:bool */
84
+ function readOptSocketAddr(view: DataView, o: Offset): string | null {
85
+ const tag = readU8(view, o);
86
+ if (tag === 0) return null;
87
+ const ipBytes = new Uint8Array(view.buffer, view.byteOffset + o.v, 16);
88
+ o.v += 16;
89
+ const port = readU16(view, o);
90
+ const isIpv4 = readBool(view, o);
91
+ if (isIpv4) {
92
+ return `${ipBytes[12]}.${ipBytes[13]}.${ipBytes[14]}.${ipBytes[15]}:${port}`;
93
+ }
94
+ return `[ipv6]:${port}`;
95
+ }
96
+
97
+ /** Read a wincode Vec<[u8;32]> as base58 string array */
98
+ function readPubkeyVec(view: DataView, data: ArrayBuffer, o: Offset): string[] {
99
+ const count = readU64(view, o);
100
+ const keys: string[] = [];
101
+ for (let i = 0; i < count; i++) {
102
+ keys.push(readPubkey(data, o));
103
+ }
104
+ return keys;
105
+ }
106
+
107
+ /** Read a single GossipPeer from wincode */
108
+ function readGossipPeer(view: DataView, data: ArrayBuffer, o: Offset): GossipPeer {
109
+ return {
110
+ identity: readPubkey(data, o),
111
+ tpuQuic: readOptSocketAddr(view, o),
112
+ tpuUdp: readOptSocketAddr(view, o),
113
+ tpuForwardsQuic: readOptSocketAddr(view, o),
114
+ tpuForwardsUdp: readOptSocketAddr(view, o),
115
+ tpuVote: readOptSocketAddr(view, o),
116
+ tpuVoteQuic: readOptSocketAddr(view, o),
117
+ gossipAddr: readOptSocketAddr(view, o),
118
+ shredVersion: readU16(view, o),
119
+ version: readVecU8AsString(view, data, o),
120
+ activatedStake: readU64(view, o),
121
+ commission: readU8(view, o),
122
+ isDelinquent: readBool(view, o),
123
+ votePubkey: readPubkey(data, o),
124
+ lastVote: readU64(view, o),
125
+ rootSlot: readU64(view, o),
126
+ wallclock: readU64(view, o),
127
+ };
128
+ }
129
+
130
+ /** Read a wincode Vec<GossipPeer> */
131
+ function readGossipPeerVec(view: DataView, data: ArrayBuffer, o: Offset): GossipPeer[] {
132
+ const count = readU64(view, o);
133
+ const peers: GossipPeer[] = [];
134
+ for (let i = 0; i < count; i++) {
135
+ peers.push(readGossipPeer(view, data, o));
136
+ }
137
+ return peers;
138
+ }
139
+
140
+ /**
141
+ * Decode a binary WebSocket message from the leader-schedule server.
142
+ *
143
+ * @param data - Raw binary data from WebSocket
144
+ * @returns Decoded message or null if unrecognized type
145
+ */
146
+ export function decodeLeaderMessage(data: ArrayBuffer): LeaderDecodedMessage | null {
147
+ const view = new DataView(data);
148
+ if (data.byteLength < 1) return null;
149
+
150
+ const msgType = view.getUint8(0);
151
+ const payload = data.slice(1);
152
+ const pv = new DataView(payload);
153
+
154
+ switch (msgType) {
155
+ case LeaderMessageTag.Subscribed: {
156
+ const text = new TextDecoder().decode(payload);
157
+ try {
158
+ return { type: 'subscribed', data: JSON.parse(text) };
159
+ } catch {
160
+ return null;
161
+ }
162
+ }
163
+
164
+ case LeaderMessageTag.Error: {
165
+ return { type: 'error', data: { message: new TextDecoder().decode(payload) } };
166
+ }
167
+
168
+ case LeaderMessageTag.SlotUpdate: {
169
+ if (payload.byteLength < 48) return null;
170
+ const o: Offset = { v: 0 };
171
+ return {
172
+ type: 'slot_update',
173
+ kind: 'snapshot',
174
+ data: {
175
+ slot: readU64(pv, o),
176
+ leader: readPubkey(payload, o),
177
+ blockHeight: readU64(pv, o),
178
+ },
179
+ };
180
+ }
181
+
182
+ case LeaderMessageTag.Heartbeat: {
183
+ if (payload.byteLength < 24) return null;
184
+ const o: Offset = { v: 0 };
185
+ return {
186
+ type: 'heartbeat',
187
+ kind: 'snapshot',
188
+ data: {
189
+ timestampMs: readU64(pv, o),
190
+ currentSlot: readU64(pv, o),
191
+ connectedClients: readU32(pv, o),
192
+ gossipPeers: readU32(pv, o),
193
+ },
194
+ };
195
+ }
196
+
197
+ case LeaderMessageTag.SkipEvent: {
198
+ if (payload.byteLength < 48) return null;
199
+ const o: Offset = { v: 0 };
200
+ return {
201
+ type: 'skip_event',
202
+ kind: 'event',
203
+ key: 'leader',
204
+ data: {
205
+ slot: readU64(pv, o),
206
+ leader: readPubkey(payload, o),
207
+ assigned: readU32(pv, o),
208
+ produced: readU32(pv, o),
209
+ },
210
+ };
211
+ }
212
+
213
+ case LeaderMessageTag.RoutingHealth: {
214
+ if (payload.byteLength < 8) return null;
215
+ try {
216
+ const o: Offset = { v: 0 };
217
+ const leadersTotal = readU32(pv, o);
218
+ const leadersInGossip = readU32(pv, o);
219
+ const leadersMissingGossip = readPubkeyVec(pv, payload, o);
220
+ const leadersWithoutTpuQuic = readPubkeyVec(pv, payload, o);
221
+ const leadersDelinquent = readPubkeyVec(pv, payload, o);
222
+ return {
223
+ type: 'routing_health',
224
+ kind: 'snapshot',
225
+ data: {
226
+ leadersTotal,
227
+ leadersInGossip,
228
+ leadersMissingGossip,
229
+ leadersWithoutTpuQuic,
230
+ leadersDelinquent,
231
+ coverage: `${leadersTotal > 0 ? (leadersInGossip / leadersTotal * 100).toFixed(1) : 0}%`,
232
+ },
233
+ };
234
+ } catch { return null; }
235
+ }
236
+
237
+ case LeaderMessageTag.IpChange: {
238
+ if (payload.byteLength < 32) return null;
239
+ try {
240
+ const o: Offset = { v: 0 };
241
+ const identity = readPubkey(payload, o);
242
+ const oldIp = readVecU8AsString(pv, payload, o);
243
+ const newIp = readVecU8AsString(pv, payload, o);
244
+ const timestampMs = readU64(pv, o);
245
+ return {
246
+ type: 'ip_change',
247
+ kind: 'event',
248
+ key: 'identity',
249
+ data: { identity, oldIp, newIp, timestampMs },
250
+ };
251
+ } catch { return null; }
252
+ }
253
+
254
+ case LeaderMessageTag.GossipSnapshot: {
255
+ if (payload.byteLength < 8) return null;
256
+ try {
257
+ const o: Offset = { v: 0 };
258
+ const timestampMs = readU64(pv, o);
259
+ const peers = readGossipPeerVec(pv, payload, o);
260
+ return {
261
+ type: 'gossip_snapshot',
262
+ kind: 'snapshot',
263
+ key: 'identity',
264
+ data: { timestampMs, count: peers.length, peers },
265
+ };
266
+ } catch { return null; }
267
+ }
268
+
269
+ case LeaderMessageTag.GossipDiff: {
270
+ if (payload.byteLength < 8) return null;
271
+ try {
272
+ const o: Offset = { v: 0 };
273
+ const timestampMs = readU64(pv, o);
274
+ const added = readGossipPeerVec(pv, payload, o);
275
+ const removed = readPubkeyVec(pv, payload, o);
276
+ const updated = readGossipPeerVec(pv, payload, o);
277
+ return {
278
+ type: 'gossip_diff',
279
+ kind: 'diff',
280
+ key: 'identity',
281
+ data: { timestampMs, added, removed, updated },
282
+ };
283
+ } catch { return null; }
284
+ }
285
+
286
+ case LeaderMessageTag.LeaderSchedule: {
287
+ if (payload.byteLength < 16) return null;
288
+ try {
289
+ const o: Offset = { v: 0 };
290
+ const epoch = readU64(pv, o);
291
+ const slotsInEpoch = readU64(pv, o);
292
+ const validatorCount = readU64(pv, o);
293
+ const schedule: Array<{ identity: string; slots: number; slotIndices: number[] }> = [];
294
+ for (let i = 0; i < validatorCount; i++) {
295
+ const identity = readPubkey(payload, o);
296
+ const slotCount = readU64(pv, o);
297
+ const slotIndices: number[] = [];
298
+ for (let j = 0; j < slotCount; j++) {
299
+ slotIndices.push(readU32(pv, o));
300
+ }
301
+ schedule.push({ identity, slots: slotIndices.length, slotIndices });
302
+ }
303
+ return {
304
+ type: 'leader_schedule',
305
+ kind: 'snapshot',
306
+ data: { epoch, slotsInEpoch, validators: schedule.length, schedule },
307
+ };
308
+ } catch { return null; }
309
+ }
310
+
311
+ default:
312
+ return null;
313
+ }
314
+ }