@zofai/zo-sdk 0.2.17 → 0.2.19

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 (43) hide show
  1. package/dist/consts/deployments-usdz-mainnet.json +10 -0
  2. package/dist/consts/deployments-zo-oracle-mainnet.json +8 -0
  3. package/dist/consts/index.cjs.map +1 -1
  4. package/dist/consts/index.d.cts +2 -0
  5. package/dist/consts/index.d.cts.map +1 -1
  6. package/dist/consts/index.d.mts +2 -0
  7. package/dist/consts/index.d.mts.map +1 -1
  8. package/dist/consts/index.mjs.map +1 -1
  9. package/dist/interfaces/base.d.cts +9 -0
  10. package/dist/interfaces/base.d.cts.map +1 -1
  11. package/dist/interfaces/base.d.mts +9 -0
  12. package/dist/interfaces/base.d.mts.map +1 -1
  13. package/dist/oracle.cjs +19 -0
  14. package/dist/oracle.cjs.map +1 -1
  15. package/dist/oracle.d.cts +14 -1
  16. package/dist/oracle.d.cts.map +1 -1
  17. package/dist/oracle.d.mts +14 -1
  18. package/dist/oracle.d.mts.map +1 -1
  19. package/dist/oracle.mjs +20 -1
  20. package/dist/oracle.mjs.map +1 -1
  21. package/dist/oraclePro.cjs +5 -2
  22. package/dist/oraclePro.cjs.map +1 -1
  23. package/dist/oraclePro.d.cts.map +1 -1
  24. package/dist/oraclePro.d.mts.map +1 -1
  25. package/dist/oraclePro.mjs +5 -2
  26. package/dist/oraclePro.mjs.map +1 -1
  27. package/dist/storkClient.cjs +230 -0
  28. package/dist/storkClient.cjs.map +1 -1
  29. package/dist/storkClient.d.cts +89 -0
  30. package/dist/storkClient.d.cts.map +1 -1
  31. package/dist/storkClient.d.mts +89 -0
  32. package/dist/storkClient.d.mts.map +1 -1
  33. package/dist/storkClient.mjs +227 -0
  34. package/dist/storkClient.mjs.map +1 -1
  35. package/package.json +1 -1
  36. package/src/consts/deployments-usdz-mainnet.json +10 -0
  37. package/src/consts/deployments-zo-oracle-mainnet.json +8 -0
  38. package/src/consts/index.ts +2 -0
  39. package/src/interfaces/base.ts +17 -0
  40. package/src/oracle.ts +35 -2
  41. package/src/oraclePro.ts +7 -2
  42. package/src/storkClient.ts +308 -0
  43. package/tests/storkClient.test.ts +88 -0
package/src/oraclePro.ts CHANGED
@@ -5,6 +5,7 @@ import type {
5
5
  IOracleProConfig,
6
6
  IPythLazerConfig,
7
7
  IZoOracleConfig,
8
+ IZoOracleSymbolConfig,
8
9
  Network,
9
10
  } from './consts'
10
11
  import {
@@ -120,6 +121,10 @@ export function resolveLazerFeedIds(
120
121
  return [...ids]
121
122
  }
122
123
 
124
+ function resolveStorkAssetId(symbolKey: string, config: IZoOracleSymbolConfig): string {
125
+ return config.storkAssetId ?? symbolKey.replace('/', '')
126
+ }
127
+
123
128
  /** Map Stork asset symbol (e.g. `BTCUSD`) → 32-byte on-chain feeder id bytes. */
124
129
  export function buildStorkAssetIdMap(zoOracle: IZoOracleConfig): Map<string, Uint8Array> {
125
130
  const map = new Map<string, Uint8Array>()
@@ -127,7 +132,7 @@ export function buildStorkAssetIdMap(zoOracle: IZoOracleConfig): Map<string, Uin
127
132
  if (!config.storkFeederId) {
128
133
  continue
129
134
  }
130
- const asset = symbolKey.replace('/', '')
135
+ const asset = resolveStorkAssetId(symbolKey, config)
131
136
  map.set(asset, hexToBytes(config.storkFeederId))
132
137
  }
133
138
  return map
@@ -144,7 +149,7 @@ export function resolveStorkAssetsForTokens(
144
149
  if (!config.storkFeederId) {
145
150
  continue
146
151
  }
147
- moduleToAsset.set(normalizeCoinType(config.coinType), symbolKey.replace('/', ''))
152
+ moduleToAsset.set(normalizeCoinType(config.coinType), resolveStorkAssetId(symbolKey, config))
148
153
  }
149
154
 
150
155
  const assets = new Set<string>()
@@ -326,3 +326,311 @@ export async function fetchStorkUpdateDataFromKronos(
326
326
  }
327
327
  return updates
328
328
  }
329
+
330
+ export type StorkSignatureType = 'evm' | 'stark'
331
+
332
+ export interface StorkStreamUpdate {
333
+ traceId?: string
334
+ /** Stork asset symbol (e.g. `BTCUSD`) → kronos price entry. */
335
+ prices: Record<string, IStorkKronosPriceEntry>
336
+ }
337
+
338
+ export type StorkStreamServerMessage
339
+ = | { type: 'connected', message?: string }
340
+ | { type: 'oracle_prices', trace_id?: string, data: Record<string, IStorkKronosPriceEntry> }
341
+ | { type: 'error', message?: string }
342
+ | { type: 'pong' }
343
+
344
+ export type StorkStreamClientMessage
345
+ = | { type: 'subscribe', data: string[] }
346
+ | { type: 'unsubscribe', data: string[] }
347
+ | { type: 'ping' }
348
+
349
+ /** Derive ws(s):// stream URL from kronos http(s) base URL. */
350
+ export function apiBaseUrlToStorkWsUrl(
351
+ baseUrl: string,
352
+ signatureType: StorkSignatureType = 'evm',
353
+ ): string {
354
+ const url = new URL(baseUrl)
355
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
356
+ url.pathname = `/stork/stream/${signatureType}`
357
+ url.search = ''
358
+ url.hash = ''
359
+ return url.toString()
360
+ }
361
+
362
+ export interface IStorkStreamClientOptions {
363
+ /** Defaults to `evm` (`/stork/stream/evm`). Use `stark` for StarkEx-native signatures. */
364
+ signatureType?: StorkSignatureType
365
+ /** Defaults to apiBaseUrlToStorkWsUrl(baseUrl, signatureType). */
366
+ wsUrl?: string
367
+ /** Reconnect after disconnect. Default true. */
368
+ autoReconnect?: boolean
369
+ /** Initial reconnect delay in ms. Default 1000. */
370
+ reconnectDelayMs?: number
371
+ /** Ping interval in ms. Default 30000. Set 0 to disable. */
372
+ pingIntervalMs?: number
373
+ /** Custom WebSocket constructor (Node test mocks, etc.). */
374
+ WebSocketImpl?: typeof WebSocket
375
+ }
376
+
377
+ type StorkStreamUpdateHandler = (update: StorkStreamUpdate) => void
378
+ type StorkStreamMessageHandler = (message: StorkStreamServerMessage) => void
379
+
380
+ /**
381
+ * WebSocket client for kronos `/stork/stream/{evm|stark}`.
382
+ * Multiplexes asset subscriptions and re-subscribes after reconnect.
383
+ */
384
+ export class StorkStreamClient {
385
+ private readonly wsUrl: string
386
+ private readonly autoReconnect: boolean
387
+ private readonly reconnectDelayMs: number
388
+ private readonly pingIntervalMs: number
389
+ private readonly WebSocketImpl: typeof WebSocket
390
+
391
+ private ws: WebSocket | null = null
392
+ private connectPromise: Promise<void> | null = null
393
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null
394
+ private pingTimer: ReturnType<typeof setInterval> | null = null
395
+ private closedByUser = false
396
+
397
+ private readonly assetRefCount = new Map<string, number>()
398
+ private readonly updateHandlers = new Set<StorkStreamUpdateHandler>()
399
+ private readonly messageHandlers = new Set<StorkStreamMessageHandler>()
400
+
401
+ constructor(baseUrl: string, options?: IStorkStreamClientOptions) {
402
+ const signatureType = options?.signatureType ?? 'evm'
403
+ this.wsUrl = options?.wsUrl ?? apiBaseUrlToStorkWsUrl(baseUrl, signatureType)
404
+ this.autoReconnect = options?.autoReconnect ?? true
405
+ this.reconnectDelayMs = options?.reconnectDelayMs ?? 1000
406
+ this.pingIntervalMs = options?.pingIntervalMs ?? 30_000
407
+ this.WebSocketImpl = options?.WebSocketImpl ?? WebSocket
408
+ }
409
+
410
+ /** Open the WebSocket and wait for socket open. */
411
+ connect(): Promise<void> {
412
+ if (this.ws?.readyState === WebSocket.OPEN) {
413
+ return Promise.resolve()
414
+ }
415
+ if (this.connectPromise) {
416
+ return this.connectPromise
417
+ }
418
+
419
+ this.closedByUser = false
420
+ this.connectPromise = new Promise((resolve, reject) => {
421
+ const ws = new this.WebSocketImpl(this.wsUrl)
422
+ this.ws = ws
423
+
424
+ const onOpen = () => {
425
+ this.startPing()
426
+ this.resubscribeAll()
427
+ resolve()
428
+ }
429
+
430
+ const onError = () => {
431
+ if (ws.readyState !== WebSocket.OPEN) {
432
+ reject(new Error(`Stork WebSocket connection failed: ${this.wsUrl}`))
433
+ }
434
+ this.emitMessage({ type: 'error', message: `Stork WebSocket error: ${this.wsUrl}` })
435
+ }
436
+
437
+ ws.addEventListener('open', onOpen, { once: true })
438
+ ws.addEventListener('error', onError, { once: true })
439
+ ws.addEventListener('message', (event) => {
440
+ this.handleMessage(String(event.data))
441
+ })
442
+ ws.addEventListener('close', () => {
443
+ this.stopPing()
444
+ this.ws = null
445
+ this.connectPromise = null
446
+ if (!this.closedByUser && this.autoReconnect) {
447
+ this.scheduleReconnect()
448
+ }
449
+ })
450
+ })
451
+
452
+ return this.connectPromise
453
+ }
454
+
455
+ disconnect(): void {
456
+ this.closedByUser = true
457
+ this.clearReconnect()
458
+ this.stopPing()
459
+ if (this.ws) {
460
+ this.ws.close()
461
+ this.ws = null
462
+ }
463
+ this.connectPromise = null
464
+ }
465
+
466
+ /**
467
+ * Subscribe to live Stork price updates for asset symbols (e.g. `BTCUSD`).
468
+ * Returns an unsubscribe function.
469
+ */
470
+ subscribe(
471
+ assets: string[],
472
+ onUpdate: StorkStreamUpdateHandler,
473
+ ): () => void {
474
+ if (assets.length === 0) {
475
+ throw new Error('assets is required')
476
+ }
477
+
478
+ const newAssets = this.addAssetRefs(assets)
479
+ this.updateHandlers.add(onUpdate)
480
+
481
+ void this.connect().then(() => {
482
+ if (newAssets.length > 0) {
483
+ this.send({ type: 'subscribe', data: newAssets })
484
+ }
485
+ })
486
+
487
+ return () => {
488
+ this.unsubscribe(assets, onUpdate)
489
+ }
490
+ }
491
+
492
+ /** Remove one handler; unsubscribe upstream assets when no handlers remain. */
493
+ unsubscribe(assets: string[], onUpdate?: StorkStreamUpdateHandler): void {
494
+ if (onUpdate) {
495
+ this.updateHandlers.delete(onUpdate)
496
+ }
497
+ else {
498
+ this.updateHandlers.clear()
499
+ }
500
+
501
+ if (this.updateHandlers.size === 0) {
502
+ const removedAssets = this.clearAssetRefs()
503
+ if (removedAssets.length > 0 && this.ws?.readyState === WebSocket.OPEN) {
504
+ this.send({ type: 'unsubscribe', data: removedAssets })
505
+ }
506
+ return
507
+ }
508
+
509
+ const removedAssets = this.removeAssetRefs(assets)
510
+ if (removedAssets.length > 0 && this.ws?.readyState === WebSocket.OPEN) {
511
+ this.send({ type: 'unsubscribe', data: removedAssets })
512
+ }
513
+ }
514
+
515
+ onMessage(handler: StorkStreamMessageHandler): () => void {
516
+ this.messageHandlers.add(handler)
517
+ return () => {
518
+ this.messageHandlers.delete(handler)
519
+ }
520
+ }
521
+
522
+ private addAssetRefs(assets: string[]): string[] {
523
+ const newAssets: string[] = []
524
+ for (const asset of assets) {
525
+ const count = this.assetRefCount.get(asset) ?? 0
526
+ this.assetRefCount.set(asset, count + 1)
527
+ if (count === 0) {
528
+ newAssets.push(asset)
529
+ }
530
+ }
531
+ return newAssets
532
+ }
533
+
534
+ private removeAssetRefs(assets: string[]): string[] {
535
+ const removedAssets: string[] = []
536
+ for (const asset of assets) {
537
+ const count = this.assetRefCount.get(asset)
538
+ if (count === undefined) {
539
+ continue
540
+ }
541
+ if (count <= 1) {
542
+ this.assetRefCount.delete(asset)
543
+ removedAssets.push(asset)
544
+ }
545
+ else {
546
+ this.assetRefCount.set(asset, count - 1)
547
+ }
548
+ }
549
+ return removedAssets
550
+ }
551
+
552
+ private clearAssetRefs(): string[] {
553
+ const removedAssets = [...this.assetRefCount.keys()]
554
+ this.assetRefCount.clear()
555
+ return removedAssets
556
+ }
557
+
558
+ private send(message: StorkStreamClientMessage): void {
559
+ if (this.ws?.readyState !== WebSocket.OPEN) {
560
+ return
561
+ }
562
+ this.ws.send(JSON.stringify(message))
563
+ }
564
+
565
+ private resubscribeAll(): void {
566
+ const assets = [...this.assetRefCount.keys()]
567
+ if (assets.length > 0) {
568
+ this.send({ type: 'subscribe', data: assets })
569
+ }
570
+ }
571
+
572
+ private scheduleReconnect(): void {
573
+ if (this.reconnectTimer) {
574
+ return
575
+ }
576
+ this.reconnectTimer = setTimeout(() => {
577
+ this.reconnectTimer = null
578
+ void this.connect().catch(() => {
579
+ this.scheduleReconnect()
580
+ })
581
+ }, this.reconnectDelayMs)
582
+ }
583
+
584
+ private clearReconnect(): void {
585
+ if (this.reconnectTimer) {
586
+ clearTimeout(this.reconnectTimer)
587
+ this.reconnectTimer = null
588
+ }
589
+ }
590
+
591
+ private startPing(): void {
592
+ if (this.pingIntervalMs <= 0) {
593
+ return
594
+ }
595
+ this.stopPing()
596
+ this.pingTimer = setInterval(() => {
597
+ this.send({ type: 'ping' })
598
+ }, this.pingIntervalMs)
599
+ }
600
+
601
+ private stopPing(): void {
602
+ if (this.pingTimer) {
603
+ clearInterval(this.pingTimer)
604
+ this.pingTimer = null
605
+ }
606
+ }
607
+
608
+ private emitMessage(message: StorkStreamServerMessage): void {
609
+ for (const handler of this.messageHandlers) {
610
+ handler(message)
611
+ }
612
+ }
613
+
614
+ private handleMessage(raw: string): void {
615
+ let message: StorkStreamServerMessage
616
+ try {
617
+ message = JSON.parse(raw) as StorkStreamServerMessage
618
+ }
619
+ catch {
620
+ this.emitMessage({ type: 'error', message: `Invalid JSON: ${raw}` })
621
+ return
622
+ }
623
+
624
+ this.emitMessage(message)
625
+
626
+ if (message.type === 'oracle_prices') {
627
+ const update: StorkStreamUpdate = {
628
+ traceId: message.trace_id,
629
+ prices: message.data,
630
+ }
631
+ for (const handler of this.updateHandlers) {
632
+ handler(update)
633
+ }
634
+ }
635
+ }
636
+ }
@@ -1,11 +1,13 @@
1
1
  import { describe, expect, it, vi } from 'vitest'
2
2
 
3
3
  import {
4
+ apiBaseUrlToStorkWsUrl,
4
5
  buildStorkUpdateDataFromSignedPrice,
5
6
  fetchStorkUpdateDataFromKronos,
6
7
  getLatestStorkPrices,
7
8
  hexToBytes,
8
9
  parseStorkUpdateDataRaw,
10
+ StorkStreamClient,
9
11
  } from '../src/storkClient'
10
12
  import { appendStorkUpdatesToTransaction, estimateStorkUpdateFeeMist } from '../src/storkOracle'
11
13
  import { Transaction } from '@mysten/sui/transactions'
@@ -314,3 +316,89 @@ describe('storkOracle', () => {
314
316
  expect(tx.getData().commands.length).toBe(3)
315
317
  })
316
318
  })
319
+
320
+ describe('StorkStreamClient', () => {
321
+ class MockWebSocket {
322
+ static OPEN = 1
323
+ readyState = MockWebSocket.OPEN
324
+ sent: string[] = []
325
+ private listeners: Record<string, Array<(event: { data?: string }) => void>> = {}
326
+
327
+ constructor(public url: string) {
328
+ queueMicrotask(() => {
329
+ this.listeners.open?.forEach(cb => cb({}))
330
+ this.emit({ data: JSON.stringify({ type: 'connected' }) })
331
+ })
332
+ }
333
+
334
+ addEventListener(event: string, cb: (event: { data?: string }) => void, opts?: { once?: boolean }) {
335
+ if (!this.listeners[event]) {
336
+ this.listeners[event] = []
337
+ }
338
+ if (opts?.once) {
339
+ const onceWrapper = (ev: { data?: string }) => {
340
+ this.listeners[event] = this.listeners[event].filter(h => h !== onceWrapper)
341
+ cb(ev)
342
+ }
343
+ this.listeners[event].push(onceWrapper)
344
+ return
345
+ }
346
+ this.listeners[event].push(cb)
347
+ }
348
+
349
+ send(data: string) {
350
+ this.sent.push(data)
351
+ }
352
+
353
+ close() {
354
+ this.readyState = 3
355
+ this.listeners.close?.forEach(cb => cb({}))
356
+ }
357
+
358
+ emit(event: { data?: string }) {
359
+ this.listeners.message?.forEach(cb => cb(event))
360
+ }
361
+ }
362
+
363
+ it('derives evm and stark ws urls from kronos base url', () => {
364
+ expect(apiBaseUrlToStorkWsUrl('https://api.zofinance.io')).toBe('wss://api.zofinance.io/stork/stream/evm')
365
+ expect(apiBaseUrlToStorkWsUrl('http://localhost:8080', 'stark')).toBe('ws://localhost:8080/stork/stream/stark')
366
+ })
367
+
368
+ it('subscribes and forwards oracle_prices', async () => {
369
+ vi.stubGlobal('WebSocket', MockWebSocket)
370
+
371
+ const client = new StorkStreamClient('http://localhost:8080', {
372
+ WebSocketImpl: MockWebSocket as unknown as typeof WebSocket,
373
+ pingIntervalMs: 0,
374
+ })
375
+
376
+ const updates: string[] = []
377
+ client.subscribe(['BTCUSD'], (update) => {
378
+ updates.push(update.prices.BTCUSD?.asset_id ?? '')
379
+ })
380
+
381
+ await client.connect()
382
+ await new Promise<void>(resolve => setTimeout(resolve, 0))
383
+
384
+ expect(JSON.parse((client as any).ws.sent[0]).type).toBe('subscribe')
385
+ expect(JSON.parse((client as any).ws.sent[0]).data).toEqual(['BTCUSD'])
386
+
387
+ ;(client as any).ws.emit({
388
+ data: JSON.stringify({
389
+ type: 'oracle_prices',
390
+ trace_id: 'trace-1',
391
+ data: {
392
+ BTCUSD: {
393
+ asset_id: 'BTCUSD',
394
+ price: '100000000000000000000',
395
+ stork_signed_price: { price: '100000000000000000000' },
396
+ },
397
+ },
398
+ }),
399
+ })
400
+
401
+ expect(updates).toEqual(['BTCUSD'])
402
+ client.disconnect()
403
+ })
404
+ })