@zofai/zo-sdk 0.2.16 → 0.2.18
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.
- package/dist/consts/deployments-zo-oracle-mainnet.json +4 -4
- package/dist/interfaces/base.d.cts +9 -0
- package/dist/interfaces/base.d.cts.map +1 -1
- package/dist/interfaces/base.d.mts +9 -0
- package/dist/interfaces/base.d.mts.map +1 -1
- package/dist/oracle.cjs +19 -0
- package/dist/oracle.cjs.map +1 -1
- package/dist/oracle.d.cts +14 -1
- package/dist/oracle.d.cts.map +1 -1
- package/dist/oracle.d.mts +14 -1
- package/dist/oracle.d.mts.map +1 -1
- package/dist/oracle.mjs +20 -1
- package/dist/oracle.mjs.map +1 -1
- package/dist/storkClient.cjs +230 -0
- package/dist/storkClient.cjs.map +1 -1
- package/dist/storkClient.d.cts +89 -0
- package/dist/storkClient.d.cts.map +1 -1
- package/dist/storkClient.d.mts +89 -0
- package/dist/storkClient.d.mts.map +1 -1
- package/dist/storkClient.mjs +227 -0
- package/dist/storkClient.mjs.map +1 -1
- package/package.json +1 -1
- package/src/consts/deployments-zo-oracle-mainnet.json +4 -4
- package/src/interfaces/base.ts +17 -0
- package/src/oracle.ts +35 -2
- package/src/storkClient.ts +308 -0
- package/tests/storkClient.test.ts +88 -0
package/src/storkClient.ts
CHANGED
|
@@ -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
|
+
})
|