@k256/sdk 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,320 @@
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 (field names match REST /gossip/peer/:id) */
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
+ stake: readU64(view, o),
121
+ commission: readU8(view, o),
122
+ isDelinquent: readBool(view, o),
123
+ voteAccount: readPubkey(data, o),
124
+ lastVote: readU64(view, o),
125
+ rootSlot: readU64(view, o),
126
+ wallclock: readU64(view, o),
127
+ // Geo/ASN enrichment from IPinfo Lite MMDB (server-side)
128
+ countryCode: readVecU8AsString(view, data, o),
129
+ continentCode: readVecU8AsString(view, data, o),
130
+ asn: readVecU8AsString(view, data, o),
131
+ asName: readVecU8AsString(view, data, o),
132
+ asDomain: readVecU8AsString(view, data, o),
133
+ };
134
+ }
135
+
136
+ /** Read a wincode Vec<GossipPeer> */
137
+ function readGossipPeerVec(view: DataView, data: ArrayBuffer, o: Offset): GossipPeer[] {
138
+ const count = readU64(view, o);
139
+ const peers: GossipPeer[] = [];
140
+ for (let i = 0; i < count; i++) {
141
+ peers.push(readGossipPeer(view, data, o));
142
+ }
143
+ return peers;
144
+ }
145
+
146
+ /**
147
+ * Decode a binary WebSocket message from the leader-schedule server.
148
+ *
149
+ * @param data - Raw binary data from WebSocket
150
+ * @returns Decoded message or null if unrecognized type
151
+ */
152
+ export function decodeLeaderMessage(data: ArrayBuffer): LeaderDecodedMessage | null {
153
+ const view = new DataView(data);
154
+ if (data.byteLength < 1) return null;
155
+
156
+ const msgType = view.getUint8(0);
157
+ const payload = data.slice(1);
158
+ const pv = new DataView(payload);
159
+
160
+ switch (msgType) {
161
+ case LeaderMessageTag.Subscribed: {
162
+ const text = new TextDecoder().decode(payload);
163
+ try {
164
+ return { type: 'subscribed', data: JSON.parse(text) };
165
+ } catch {
166
+ return null;
167
+ }
168
+ }
169
+
170
+ case LeaderMessageTag.Error: {
171
+ return { type: 'error', data: { message: new TextDecoder().decode(payload) } };
172
+ }
173
+
174
+ case LeaderMessageTag.SlotUpdate: {
175
+ if (payload.byteLength < 48) return null;
176
+ const o: Offset = { v: 0 };
177
+ return {
178
+ type: 'slot_update',
179
+ kind: 'snapshot',
180
+ data: {
181
+ slot: readU64(pv, o),
182
+ leader: readPubkey(payload, o),
183
+ blockHeight: readU64(pv, o),
184
+ },
185
+ };
186
+ }
187
+
188
+ case LeaderMessageTag.Heartbeat: {
189
+ if (payload.byteLength < 24) return null;
190
+ const o: Offset = { v: 0 };
191
+ return {
192
+ type: 'heartbeat',
193
+ kind: 'snapshot',
194
+ data: {
195
+ timestampMs: readU64(pv, o),
196
+ currentSlot: readU64(pv, o),
197
+ connectedClients: readU32(pv, o),
198
+ gossipPeers: readU32(pv, o),
199
+ },
200
+ };
201
+ }
202
+
203
+ case LeaderMessageTag.SkipEvent: {
204
+ if (payload.byteLength < 48) return null;
205
+ const o: Offset = { v: 0 };
206
+ return {
207
+ type: 'skip_event',
208
+ kind: 'event',
209
+ key: 'leader',
210
+ data: {
211
+ slot: readU64(pv, o),
212
+ leader: readPubkey(payload, o),
213
+ assigned: readU32(pv, o),
214
+ produced: readU32(pv, o),
215
+ },
216
+ };
217
+ }
218
+
219
+ case LeaderMessageTag.RoutingHealth: {
220
+ if (payload.byteLength < 8) return null;
221
+ try {
222
+ const o: Offset = { v: 0 };
223
+ const leadersTotal = readU32(pv, o);
224
+ const leadersInGossip = readU32(pv, o);
225
+ const leadersMissingGossip = readPubkeyVec(pv, payload, o);
226
+ const leadersWithoutTpuQuic = readPubkeyVec(pv, payload, o);
227
+ const leadersDelinquent = readPubkeyVec(pv, payload, o);
228
+ return {
229
+ type: 'routing_health',
230
+ kind: 'snapshot',
231
+ data: {
232
+ leadersTotal,
233
+ leadersInGossip,
234
+ leadersMissingGossip,
235
+ leadersWithoutTpuQuic,
236
+ leadersDelinquent,
237
+ coverage: `${leadersTotal > 0 ? (leadersInGossip / leadersTotal * 100).toFixed(1) : 0}%`,
238
+ },
239
+ };
240
+ } catch { return null; }
241
+ }
242
+
243
+ case LeaderMessageTag.IpChange: {
244
+ if (payload.byteLength < 32) return null;
245
+ try {
246
+ const o: Offset = { v: 0 };
247
+ const identity = readPubkey(payload, o);
248
+ const oldIp = readVecU8AsString(pv, payload, o);
249
+ const newIp = readVecU8AsString(pv, payload, o);
250
+ const timestampMs = readU64(pv, o);
251
+ return {
252
+ type: 'ip_change',
253
+ kind: 'event',
254
+ key: 'identity',
255
+ data: { identity, oldIp, newIp, timestampMs },
256
+ };
257
+ } catch { return null; }
258
+ }
259
+
260
+ case LeaderMessageTag.GossipSnapshot: {
261
+ if (payload.byteLength < 8) return null;
262
+ try {
263
+ const o: Offset = { v: 0 };
264
+ const timestampMs = readU64(pv, o);
265
+ const peers = readGossipPeerVec(pv, payload, o);
266
+ return {
267
+ type: 'gossip_snapshot',
268
+ kind: 'snapshot',
269
+ key: 'identity',
270
+ data: { timestampMs, count: peers.length, peers },
271
+ };
272
+ } catch { return null; }
273
+ }
274
+
275
+ case LeaderMessageTag.GossipDiff: {
276
+ if (payload.byteLength < 8) return null;
277
+ try {
278
+ const o: Offset = { v: 0 };
279
+ const timestampMs = readU64(pv, o);
280
+ const added = readGossipPeerVec(pv, payload, o);
281
+ const removed = readPubkeyVec(pv, payload, o);
282
+ const updated = readGossipPeerVec(pv, payload, o);
283
+ return {
284
+ type: 'gossip_diff',
285
+ kind: 'diff',
286
+ key: 'identity',
287
+ data: { timestampMs, added, removed, updated },
288
+ };
289
+ } catch { return null; }
290
+ }
291
+
292
+ case LeaderMessageTag.LeaderSchedule: {
293
+ if (payload.byteLength < 16) return null;
294
+ try {
295
+ const o: Offset = { v: 0 };
296
+ const epoch = readU64(pv, o);
297
+ const slotsInEpoch = readU64(pv, o);
298
+ const validatorCount = readU64(pv, o);
299
+ const schedule: Array<{ identity: string; slots: number; slotIndices: number[] }> = [];
300
+ for (let i = 0; i < validatorCount; i++) {
301
+ const identity = readPubkey(payload, o);
302
+ const slotCount = readU64(pv, o);
303
+ const slotIndices: number[] = [];
304
+ for (let j = 0; j < slotCount; j++) {
305
+ slotIndices.push(readU32(pv, o));
306
+ }
307
+ schedule.push({ identity, slots: slotIndices.length, slotIndices });
308
+ }
309
+ return {
310
+ type: 'leader_schedule',
311
+ kind: 'snapshot',
312
+ data: { epoch, slotsInEpoch, validators: schedule.length, schedule },
313
+ };
314
+ } catch { return null; }
315
+ }
316
+
317
+ default:
318
+ return null;
319
+ }
320
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Leader Schedule WebSocket module for K256 SDK
3
+ *
4
+ * Real-time Solana leader schedule, gossip network, and routing data.
5
+ * Binary mode by default (wincode protocol). JSON mode opt-in via gateway.
6
+ *
7
+ * @module @k256/sdk/leader-ws
8
+ *
9
+ * @example
10
+ * ```typescript
11
+ * import { LeaderWebSocketClient } from '@k256/sdk/leader-ws';
12
+ *
13
+ * const client = new LeaderWebSocketClient({
14
+ * apiKey: 'your-api-key',
15
+ * onSlotUpdate: (msg) => console.log('Slot:', msg.data.slot),
16
+ * onRoutingHealth: (msg) => console.log('Coverage:', msg.data.coverage),
17
+ * onGossipDiff: (msg) => {
18
+ * // Merge into your local peer map using identity as key
19
+ * for (const peer of msg.data.added) peerMap.set(peer.identity, peer);
20
+ * for (const peer of msg.data.updated) peerMap.set(peer.identity, peer);
21
+ * for (const id of msg.data.removed) peerMap.delete(id);
22
+ * },
23
+ * });
24
+ *
25
+ * await client.connect();
26
+ * ```
27
+ */
28
+
29
+ // Client
30
+ export { LeaderWebSocketClient, LeaderWebSocketError } from './client';
31
+ export type {
32
+ LeaderWebSocketClientConfig,
33
+ LeaderErrorCode,
34
+ ConnectionState,
35
+ } from './client';
36
+
37
+ // Decoder (for advanced usage)
38
+ export { decodeLeaderMessage, LeaderMessageTag } from './decoder';
39
+
40
+ // Types
41
+ export { LeaderChannel, ALL_LEADER_CHANNELS } from './types';
42
+ export type {
43
+ LeaderChannelValue,
44
+ MessageKind,
45
+ MessageSchemaEntry,
46
+ LeaderDecodedMessage,
47
+ LeaderSubscribedMessage,
48
+ LeaderScheduleMessage,
49
+ GossipSnapshotMessage,
50
+ GossipDiffMessage,
51
+ GossipPeer,
52
+ SlotUpdateMessage,
53
+ RoutingHealthMessage,
54
+ SkipEventMessage,
55
+ IpChangeMessage,
56
+ LeaderHeartbeatMessage,
57
+ LeaderErrorMessage,
58
+ } from './types';
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Leader Schedule WebSocket message types and interfaces
3
+ *
4
+ * The leader-schedule WS uses wincode binary protocol (matching K2 pattern).
5
+ * Wire format: [1-byte tag][wincode payload]
6
+ * Every decoded message has:
7
+ * - type: message type name
8
+ * - kind: "snapshot" (full state) | "diff" (merge into snapshot) | "event" (append-only)
9
+ * - key: primary key field for merging (on diff/event types)
10
+ * - data: typed payload
11
+ *
12
+ * @see https://k256.xyz/docs/leader-schedule
13
+ */
14
+
15
+ /**
16
+ * Leader Schedule WebSocket channels
17
+ */
18
+ export const LeaderChannel = {
19
+ /** Full epoch leader schedule (on connect + epoch change) */
20
+ LeaderSchedule: 'leader_schedule',
21
+ /** Gossip peers (snapshot on connect, then diffs) */
22
+ Gossip: 'gossip',
23
+ /** Real-time slot updates with current leader */
24
+ Slots: 'slots',
25
+ /** Skip events, IP changes, routing health */
26
+ Alerts: 'alerts',
27
+ } as const;
28
+
29
+ export type LeaderChannelValue = typeof LeaderChannel[keyof typeof LeaderChannel];
30
+
31
+ /** All available channels */
32
+ export const ALL_LEADER_CHANNELS: LeaderChannelValue[] = [
33
+ LeaderChannel.LeaderSchedule,
34
+ LeaderChannel.Gossip,
35
+ LeaderChannel.Slots,
36
+ LeaderChannel.Alerts,
37
+ ];
38
+
39
+ /**
40
+ * Message kind — tells you how to consume the message
41
+ */
42
+ export type MessageKind = 'snapshot' | 'diff' | 'event';
43
+
44
+ // ═══════════════════════════════════════════════════════════════════════
45
+ // Message Interfaces
46
+ // ═══════════════════════════════════════════════════════════════════════
47
+
48
+ /** Protocol schema entry (included in subscribed handshake) */
49
+ export interface MessageSchemaEntry {
50
+ type: string;
51
+ tag: string;
52
+ kind: MessageKind;
53
+ key?: string;
54
+ description: string;
55
+ }
56
+
57
+ /** Subscribed response — connection handshake */
58
+ export interface LeaderSubscribedMessage {
59
+ type: 'subscribed';
60
+ data: {
61
+ channels: string[];
62
+ currentSlot: number;
63
+ epoch: number;
64
+ schema: MessageSchemaEntry[];
65
+ };
66
+ }
67
+
68
+ /** Full epoch leader schedule (snapshot — replaces previous) */
69
+ export interface LeaderScheduleMessage {
70
+ type: 'leader_schedule';
71
+ kind: 'snapshot';
72
+ data: {
73
+ epoch: number;
74
+ slotsInEpoch: number;
75
+ validators: number;
76
+ schedule: Array<{
77
+ identity: string;
78
+ slots: number;
79
+ slotIndices: number[];
80
+ }>;
81
+ };
82
+ }
83
+
84
+ /** Gossip peer data (field names match REST /gossip/peer/:id for consistency) */
85
+ export interface GossipPeer {
86
+ identity: string;
87
+ tpuQuic: string | null;
88
+ tpuUdp: string | null;
89
+ tpuForwardsQuic: string | null;
90
+ tpuForwardsUdp: string | null;
91
+ tpuVote: string | null;
92
+ tpuVoteQuic: string | null;
93
+ gossipAddr: string | null;
94
+ shredVersion: number;
95
+ version: string;
96
+ /** Activated stake in lamports (matches REST field name "stake") */
97
+ stake: number;
98
+ commission: number;
99
+ isDelinquent: boolean;
100
+ /** Vote account pubkey (matches REST field name "voteAccount") */
101
+ voteAccount: string;
102
+ lastVote: number;
103
+ rootSlot: number;
104
+ wallclock: number;
105
+ /** ISO 3166 country code (e.g. "US", "DE") — from IPinfo Lite MMDB on server */
106
+ countryCode: string;
107
+ /** Two-letter continent code (e.g. "NA", "EU") */
108
+ continentCode: string;
109
+ /** ASN string (e.g. "AS15169") */
110
+ asn: string;
111
+ /** AS organization name (e.g. "Google LLC") */
112
+ asName: string;
113
+ /** AS organization domain (e.g. "google.com") */
114
+ asDomain: string;
115
+ }
116
+
117
+ /** Full gossip peer list (snapshot — apply gossip_diff to keep current) */
118
+ export interface GossipSnapshotMessage {
119
+ type: 'gossip_snapshot';
120
+ kind: 'snapshot';
121
+ key: 'identity';
122
+ data: {
123
+ timestampMs: number;
124
+ count: number;
125
+ peers: GossipPeer[];
126
+ };
127
+ }
128
+
129
+ /** Incremental gossip changes (diff — merge into snapshot using identity) */
130
+ export interface GossipDiffMessage {
131
+ type: 'gossip_diff';
132
+ kind: 'diff';
133
+ key: 'identity';
134
+ data: {
135
+ timestampMs: number;
136
+ added: GossipPeer[];
137
+ removed: string[];
138
+ updated: GossipPeer[];
139
+ };
140
+ }
141
+
142
+ /** Current slot with leader identity (snapshot — each replaces previous) */
143
+ export interface SlotUpdateMessage {
144
+ type: 'slot_update';
145
+ kind: 'snapshot';
146
+ data: {
147
+ slot: number;
148
+ leader: string;
149
+ blockHeight: number;
150
+ };
151
+ }
152
+
153
+ /** Routing health summary (snapshot — each replaces previous) */
154
+ export interface RoutingHealthMessage {
155
+ type: 'routing_health';
156
+ kind: 'snapshot';
157
+ data: {
158
+ leadersTotal: number;
159
+ leadersInGossip: number;
160
+ leadersMissingGossip: string[];
161
+ leadersWithoutTpuQuic: string[];
162
+ leadersDelinquent: string[];
163
+ coverage: string;
164
+ };
165
+ }
166
+
167
+ /** Block production stats per validator (event — cumulative) */
168
+ export interface SkipEventMessage {
169
+ type: 'skip_event';
170
+ kind: 'event';
171
+ key: 'leader';
172
+ data: {
173
+ slot: number;
174
+ leader: string;
175
+ assigned: number;
176
+ produced: number;
177
+ };
178
+ }
179
+
180
+ /** Validator IP address change (event) */
181
+ export interface IpChangeMessage {
182
+ type: 'ip_change';
183
+ kind: 'event';
184
+ key: 'identity';
185
+ data: {
186
+ identity: string;
187
+ oldIp: string;
188
+ newIp: string;
189
+ timestampMs: number;
190
+ };
191
+ }
192
+
193
+ /** Server heartbeat (snapshot — each replaces previous) */
194
+ export interface LeaderHeartbeatMessage {
195
+ type: 'heartbeat';
196
+ kind: 'snapshot';
197
+ data: {
198
+ timestampMs: number;
199
+ currentSlot: number;
200
+ connectedClients: number;
201
+ gossipPeers: number;
202
+ };
203
+ }
204
+
205
+ /** Error from server */
206
+ export interface LeaderErrorMessage {
207
+ type: 'error';
208
+ data: {
209
+ message: string;
210
+ };
211
+ }
212
+
213
+ /** Union of all leader-schedule message types */
214
+ export type LeaderDecodedMessage =
215
+ | LeaderSubscribedMessage
216
+ | LeaderScheduleMessage
217
+ | GossipSnapshotMessage
218
+ | GossipDiffMessage
219
+ | SlotUpdateMessage
220
+ | RoutingHealthMessage
221
+ | SkipEventMessage
222
+ | IpChangeMessage
223
+ | LeaderHeartbeatMessage
224
+ | LeaderErrorMessage;