@zofai/zo-sdk 0.2.13 → 0.2.15

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 (109) hide show
  1. package/.claude/settings.local.json +11 -1
  2. package/dist/abstract/BaseDataAPI.cjs +1 -1
  3. package/dist/abstract/BaseDataAPI.cjs.map +1 -1
  4. package/dist/abstract/BaseDataAPI.mjs +1 -1
  5. package/dist/abstract/BaseDataAPI.mjs.map +1 -1
  6. package/dist/consts/deployments-usdz-mainnet.json +1 -31
  7. package/dist/consts/deployments-zo-oracle-mainnet.json +329 -0
  8. package/dist/consts/index.cjs +35 -1
  9. package/dist/consts/index.cjs.map +1 -1
  10. package/dist/consts/index.d.cts +45 -0
  11. package/dist/consts/index.d.cts.map +1 -1
  12. package/dist/consts/index.d.mts +45 -0
  13. package/dist/consts/index.d.mts.map +1 -1
  14. package/dist/consts/index.mjs +31 -0
  15. package/dist/consts/index.mjs.map +1 -1
  16. package/dist/data.cjs +1 -1
  17. package/dist/data.cjs.map +1 -1
  18. package/dist/data.mjs +1 -1
  19. package/dist/data.mjs.map +1 -1
  20. package/dist/implementations/USDZAPI.cjs +426 -78
  21. package/dist/implementations/USDZAPI.cjs.map +1 -1
  22. package/dist/implementations/USDZAPI.d.cts +54 -0
  23. package/dist/implementations/USDZAPI.d.cts.map +1 -1
  24. package/dist/implementations/USDZAPI.d.mts +54 -0
  25. package/dist/implementations/USDZAPI.d.mts.map +1 -1
  26. package/dist/implementations/USDZAPI.mjs +427 -79
  27. package/dist/implementations/USDZAPI.mjs.map +1 -1
  28. package/dist/implementations/USDZDataAPI.cjs +38 -0
  29. package/dist/implementations/USDZDataAPI.cjs.map +1 -1
  30. package/dist/implementations/USDZDataAPI.d.cts +16 -0
  31. package/dist/implementations/USDZDataAPI.d.cts.map +1 -1
  32. package/dist/implementations/USDZDataAPI.d.mts +16 -0
  33. package/dist/implementations/USDZDataAPI.d.mts.map +1 -1
  34. package/dist/implementations/USDZDataAPI.mjs +38 -0
  35. package/dist/implementations/USDZDataAPI.mjs.map +1 -1
  36. package/dist/index.cjs +3 -0
  37. package/dist/index.cjs.map +1 -1
  38. package/dist/index.d.cts +3 -0
  39. package/dist/index.d.cts.map +1 -1
  40. package/dist/index.d.mts +3 -0
  41. package/dist/index.d.mts.map +1 -1
  42. package/dist/index.mjs +3 -0
  43. package/dist/index.mjs.map +1 -1
  44. package/dist/interfaces/base.d.cts +19 -1
  45. package/dist/interfaces/base.d.cts.map +1 -1
  46. package/dist/interfaces/base.d.mts +19 -1
  47. package/dist/interfaces/base.d.mts.map +1 -1
  48. package/dist/interfaces/usdz.d.cts +17 -0
  49. package/dist/interfaces/usdz.d.cts.map +1 -1
  50. package/dist/interfaces/usdz.d.mts +17 -0
  51. package/dist/interfaces/usdz.d.mts.map +1 -1
  52. package/dist/oracle.cjs +119 -1
  53. package/dist/oracle.cjs.map +1 -1
  54. package/dist/oracle.d.cts +57 -1
  55. package/dist/oracle.d.cts.map +1 -1
  56. package/dist/oracle.d.mts +57 -1
  57. package/dist/oracle.d.mts.map +1 -1
  58. package/dist/oracle.mjs +120 -2
  59. package/dist/oracle.mjs.map +1 -1
  60. package/dist/oraclePro.cjs +214 -0
  61. package/dist/oraclePro.cjs.map +1 -0
  62. package/dist/oraclePro.d.cts +92 -0
  63. package/dist/oraclePro.d.cts.map +1 -0
  64. package/dist/oraclePro.d.mts +92 -0
  65. package/dist/oraclePro.d.mts.map +1 -0
  66. package/dist/oraclePro.mjs +196 -0
  67. package/dist/oraclePro.mjs.map +1 -0
  68. package/dist/pythProClient.cjs +277 -0
  69. package/dist/pythProClient.cjs.map +1 -0
  70. package/dist/pythProClient.d.cts +143 -0
  71. package/dist/pythProClient.d.cts.map +1 -0
  72. package/dist/pythProClient.d.mts +143 -0
  73. package/dist/pythProClient.d.mts.map +1 -0
  74. package/dist/pythProClient.mjs +269 -0
  75. package/dist/pythProClient.mjs.map +1 -0
  76. package/dist/storkClient.cjs +217 -0
  77. package/dist/storkClient.cjs.map +1 -0
  78. package/dist/storkClient.d.cts +90 -0
  79. package/dist/storkClient.d.cts.map +1 -0
  80. package/dist/storkClient.d.mts +90 -0
  81. package/dist/storkClient.d.mts.map +1 -0
  82. package/dist/storkClient.mjs +208 -0
  83. package/dist/storkClient.mjs.map +1 -0
  84. package/dist/storkOracle.cjs +61 -0
  85. package/dist/storkOracle.cjs.map +1 -0
  86. package/dist/storkOracle.d.cts +21 -0
  87. package/dist/storkOracle.d.cts.map +1 -0
  88. package/dist/storkOracle.d.mts +21 -0
  89. package/dist/storkOracle.d.mts.map +1 -0
  90. package/dist/storkOracle.mjs +56 -0
  91. package/dist/storkOracle.mjs.map +1 -0
  92. package/package.json +1 -1
  93. package/src/abstract/BaseDataAPI.ts +1 -1
  94. package/src/consts/deployments-usdz-mainnet.json +1 -31
  95. package/src/consts/deployments-zo-oracle-mainnet.json +329 -0
  96. package/src/consts/index.ts +80 -0
  97. package/src/data.ts +1 -1
  98. package/src/implementations/USDZAPI.ts +731 -101
  99. package/src/implementations/USDZDataAPI.ts +42 -0
  100. package/src/index.ts +3 -0
  101. package/src/interfaces/base.ts +54 -1
  102. package/src/interfaces/usdz.ts +125 -0
  103. package/src/oracle.ts +222 -1
  104. package/src/oraclePro.ts +312 -0
  105. package/src/pythProClient.ts +406 -0
  106. package/src/storkClient.ts +328 -0
  107. package/src/storkOracle.ts +82 -0
  108. package/tests/pythProClient.test.ts +155 -0
  109. package/tests/storkClient.test.ts +316 -0
@@ -0,0 +1,312 @@
1
+ import type { Transaction, TransactionObjectArgument } from '@mysten/sui/transactions'
2
+ import { SUI_CLOCK_OBJECT_ID } from '@mysten/sui/utils'
3
+
4
+ import type {
5
+ IOracleProConfig,
6
+ IPythLazerConfig,
7
+ IZoOracleConfig,
8
+ Network,
9
+ } from './consts'
10
+ import {
11
+ buildOracleProConfig,
12
+ getPythFeederToPriceId,
13
+ getZoOracleConfig,
14
+ } from './consts'
15
+ import type { PythProChannel } from './pythProClient'
16
+ import { fetchPythProUpdateBytesFromKronos } from './pythProClient'
17
+ import { hexToBytes } from './storkClient'
18
+ import { parseSymbolKey } from './utils'
19
+
20
+ /** Shared oracle bundle for most entry functions (Clock is passed separately when required). */
21
+ export interface OracleInputs {
22
+ pythProUpdate: TransactionObjectArgument
23
+ storkState: TransactionObjectArgument
24
+ oracleRegistry: TransactionObjectArgument
25
+ clock: TransactionObjectArgument
26
+ }
27
+
28
+ /** Alternate order used by liquidate + execute v3_1 (no clock). */
29
+ export interface OracleInputsAlt {
30
+ oracleRegistry: TransactionObjectArgument
31
+ storkState: TransactionObjectArgument
32
+ pythProUpdate: TransactionObjectArgument
33
+ }
34
+
35
+ export interface IInitPythProOracleTxbResult {
36
+ tx: Transaction
37
+ oracle: OracleInputs
38
+ }
39
+
40
+ export interface IFetchPythProUpdateOptions {
41
+ channel?: PythProChannel
42
+ /** Request ema_price for swap volatility fee. */
43
+ includeEmaPrice?: boolean
44
+ /** Override kronos REST base URL (default: instance apiEndpoint or `DEFAULT_API_ENDPOINT`). */
45
+ kronosUrl?: string
46
+ }
47
+
48
+ /** Normalize coin type strings for zo_oracle ↔ LP deployment matching. */
49
+ export function normalizeCoinType(coinType: string): string {
50
+ const parts = coinType.split('::')
51
+ if (parts.length < 2 || !parts[0].startsWith('0x')) {
52
+ return coinType.toLowerCase()
53
+ }
54
+ const hex = parts[0].slice(2).replace(/^0+/, '') || '0'
55
+ return `0x${hex}::${parts.slice(1).join('::')}`.toLowerCase()
56
+ }
57
+
58
+ export function requireOracleProConfig(network: Network): IOracleProConfig {
59
+ return buildOracleProConfig(getZoOracleConfig(network))
60
+ }
61
+
62
+ /**
63
+ * Build coin module -> Lazer feed id using zo_oracle symbol configs + Hermes price id map.
64
+ */
65
+ export function buildCoinModuleToLazerFeedId(
66
+ pythLazer: IPythLazerConfig,
67
+ zoOracle: IZoOracleConfig,
68
+ network: Network,
69
+ ): Map<string, number> {
70
+ const pythFeederToPriceId = getPythFeederToPriceId(network)
71
+ const map = new Map<string, number>()
72
+
73
+ for (const config of Object.values(zoOracle.symbolConfigs)) {
74
+ const priceId = pythFeederToPriceId[config.pythFeederId]
75
+ if (!priceId) {
76
+ continue
77
+ }
78
+ const lazerId = pythLazer.feedIds[priceId]
79
+ if (lazerId === undefined) {
80
+ continue
81
+ }
82
+ map.set(normalizeCoinType(config.coinType), lazerId)
83
+ }
84
+
85
+ return map
86
+ }
87
+
88
+ export function resolveLazerFeedIds(
89
+ zoOracle: IZoOracleConfig,
90
+ coins: Record<string, { module: string }>,
91
+ tokens: string[],
92
+ network: Network,
93
+ legacyFeeders?: Record<string, string>,
94
+ ): number[] {
95
+ const { pythLazer } = zoOracle
96
+ const coinModuleMap = buildCoinModuleToLazerFeedId(pythLazer, zoOracle, network)
97
+ const feederToPriceId = getPythFeederToPriceId(network)
98
+
99
+ const ids = new Set<number>()
100
+ for (const token of tokens) {
101
+ const module = coins[token]?.module
102
+ if (!module) {
103
+ throw new Error(`Unknown token: ${token}`)
104
+ }
105
+
106
+ let lazerId = coinModuleMap.get(normalizeCoinType(module))
107
+
108
+ if (lazerId === undefined && legacyFeeders?.[token]) {
109
+ const priceId = feederToPriceId[legacyFeeders[token]]
110
+ if (priceId) {
111
+ lazerId = pythLazer.feedIds[priceId]
112
+ }
113
+ }
114
+
115
+ if (lazerId === undefined) {
116
+ throw new Error(`No Pyth Lazer feed id for token "${token}" (${module})`)
117
+ }
118
+ ids.add(lazerId)
119
+ }
120
+ return [...ids]
121
+ }
122
+
123
+ /** Map Stork asset symbol (e.g. `BTCUSD`) → 32-byte on-chain feeder id bytes. */
124
+ export function buildStorkAssetIdMap(zoOracle: IZoOracleConfig): Map<string, Uint8Array> {
125
+ const map = new Map<string, Uint8Array>()
126
+ for (const [symbolKey, config] of Object.entries(zoOracle.symbolConfigs)) {
127
+ if (!config.storkFeederId) {
128
+ continue
129
+ }
130
+ const asset = symbolKey.replace('/', '')
131
+ map.set(asset, hexToBytes(config.storkFeederId))
132
+ }
133
+ return map
134
+ }
135
+
136
+ /** Map SDK token id → kronos Stork asset id (e.g. `btc` → `BTCUSD`). */
137
+ export function resolveStorkAssetsForTokens(
138
+ zoOracle: IZoOracleConfig,
139
+ coins: Record<string, { module: string }>,
140
+ tokens: string[],
141
+ ): string[] {
142
+ const moduleToAsset = new Map<string, string>()
143
+ for (const [symbolKey, config] of Object.entries(zoOracle.symbolConfigs)) {
144
+ if (!config.storkFeederId) {
145
+ continue
146
+ }
147
+ moduleToAsset.set(normalizeCoinType(config.coinType), symbolKey.replace('/', ''))
148
+ }
149
+
150
+ const assets = new Set<string>()
151
+ for (const token of tokens) {
152
+ const module = coins[token]?.module
153
+ if (!module) {
154
+ continue
155
+ }
156
+ const asset = moduleToAsset.get(normalizeCoinType(module))
157
+ if (asset) {
158
+ assets.add(asset)
159
+ }
160
+ }
161
+ return [...assets]
162
+ }
163
+
164
+ export function toUpdateBytes(raw: Uint8Array | number[]): number[] {
165
+ return raw instanceof Uint8Array ? Array.from(raw) : raw
166
+ }
167
+
168
+ /**
169
+ * PTB step 0: verify a Pyth Pro leEcdsa update on-chain.
170
+ * Returns the verified `pyth_lazer::update_v2::Update` object.
171
+ */
172
+ export function parseAndVerifyPythProUpdate(
173
+ tx: Transaction,
174
+ config: IOracleProConfig,
175
+ rawUpdateBytes: Uint8Array | number[],
176
+ ): TransactionObjectArgument {
177
+ const [pythProUpdate] = tx.moveCall({
178
+ target: `${config.pythLazer.package}::pyth_lazer::parse_and_verify_le_ecdsa_update_v2`,
179
+ arguments: [
180
+ tx.object(config.pythLazer.state),
181
+ tx.object(SUI_CLOCK_OBJECT_ID),
182
+ tx.pure.vector('u8', toUpdateBytes(rawUpdateBytes)),
183
+ ],
184
+ })
185
+ return pythProUpdate
186
+ }
187
+
188
+ export function buildOracleInputs(
189
+ tx: Transaction,
190
+ config: IOracleProConfig,
191
+ pythProUpdate: TransactionObjectArgument,
192
+ ): OracleInputs {
193
+ return {
194
+ pythProUpdate,
195
+ storkState: tx.object(config.storkState),
196
+ oracleRegistry: tx.object(config.oracleRegistry),
197
+ clock: tx.object(SUI_CLOCK_OBJECT_ID),
198
+ }
199
+ }
200
+
201
+ /** Pyth Pro + Stork objects: `[pythProUpdate, storkState, oracleRegistry]`. */
202
+ export function oracleArgsStandard(oracle: OracleInputs): TransactionObjectArgument[] {
203
+ return [oracle.pythProUpdate, oracle.storkState, oracle.oracleRegistry]
204
+ }
205
+
206
+ /** `[pythProUpdate, storkState, oracleRegistry, clock]` — for public fns that take clock after oracle args. */
207
+ export function oracleArgsWithClock(oracle: OracleInputs): TransactionObjectArgument[] {
208
+ return [...oracleArgsStandard(oracle), oracle.clock]
209
+ }
210
+
211
+ export function oracleArgsAlt(oracle: OracleInputsAlt): TransactionObjectArgument[] {
212
+ return [oracle.oracleRegistry, oracle.storkState, oracle.pythProUpdate]
213
+ }
214
+
215
+ export function buildOracleInputsAlt(oracle: OracleInputs): OracleInputsAlt {
216
+ return {
217
+ oracleRegistry: oracle.oracleRegistry,
218
+ storkState: oracle.storkState,
219
+ pythProUpdate: oracle.pythProUpdate,
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Fetch leEcdsa update bytes via kronos REST (`/pyth-pro/latest-price?includeUpdateBytes=true`).
225
+ */
226
+ export function fetchPythProUpdateBytes(
227
+ kronosUrl: string,
228
+ feedIds: number[],
229
+ options?: Pick<IFetchPythProUpdateOptions, 'channel' | 'includeEmaPrice'>,
230
+ ): Promise<Uint8Array> {
231
+ return fetchPythProUpdateBytesFromKronos(kronosUrl, feedIds, options)
232
+ }
233
+
234
+ export interface IValuateVaultsV2Params {
235
+ tx: Transaction
236
+ upgradedPackage: string
237
+ lpType: string
238
+ market: string
239
+ vaults: Record<string, { reservingFeeModel: string }>
240
+ coinModules: Record<string, { module: string }>
241
+ oracle: OracleInputs
242
+ }
243
+
244
+ /** create_vaults_valuation + valuate_vault_v2 for each vault. */
245
+ export function valuateVaultsV2(params: IValuateVaultsV2Params): TransactionObjectArgument {
246
+ const { tx, upgradedPackage, lpType, market, vaults, coinModules, oracle } = params
247
+
248
+ const vaultsValuation = tx.moveCall({
249
+ target: `${upgradedPackage}::market::create_vaults_valuation`,
250
+ typeArguments: [lpType],
251
+ arguments: [oracle.clock, tx.object(market)],
252
+ })
253
+
254
+ for (const key of Object.keys(vaults)) {
255
+ const vault = vaults[key]
256
+ tx.moveCall({
257
+ target: `${upgradedPackage}::market::valuate_vault_v2`,
258
+ typeArguments: [lpType, coinModules[key].module],
259
+ arguments: [
260
+ tx.object(market),
261
+ tx.object(vault.reservingFeeModel),
262
+ ...oracleArgsWithClock(oracle),
263
+ vaultsValuation,
264
+ ],
265
+ })
266
+ }
267
+
268
+ return vaultsValuation
269
+ }
270
+
271
+ export interface IValuateSymbolsV2Params {
272
+ tx: Transaction
273
+ upgradedPackage: string
274
+ marketPackage: string
275
+ lpType: string
276
+ market: string
277
+ symbols: Record<string, { fundingFeeModel: string }>
278
+ coinModules: Record<string, { module: string }>
279
+ oracle: OracleInputs
280
+ }
281
+
282
+ /** create_symbols_valuation + valuate_symbol_v2 for each symbol. */
283
+ export function valuateSymbolsV2(params: IValuateSymbolsV2Params): TransactionObjectArgument {
284
+ const { tx, upgradedPackage, marketPackage, lpType, market, symbols, coinModules, oracle } = params
285
+
286
+ const symbolsValuation = tx.moveCall({
287
+ target: `${upgradedPackage}::market::create_symbols_valuation`,
288
+ typeArguments: [lpType],
289
+ arguments: [oracle.clock, tx.object(market)],
290
+ })
291
+
292
+ for (const key of Object.keys(symbols)) {
293
+ const [direction, token] = parseSymbolKey(key)
294
+ const symbol = symbols[key]
295
+ tx.moveCall({
296
+ target: `${upgradedPackage}::market::valuate_symbol_v2`,
297
+ typeArguments: [
298
+ lpType,
299
+ coinModules[token].module,
300
+ `${marketPackage}::market::${direction.toUpperCase()}`,
301
+ ],
302
+ arguments: [
303
+ tx.object(market),
304
+ tx.object(symbol.fundingFeeModel),
305
+ ...oracleArgsWithClock(oracle),
306
+ symbolsValuation,
307
+ ],
308
+ })
309
+ }
310
+
311
+ return symbolsValuation
312
+ }
@@ -0,0 +1,406 @@
1
+ /** Kronos Pyth Pro (Pyth Lazer) REST + WebSocket client. */
2
+
3
+ export type PythProChannel
4
+ = | 'real_time'
5
+ | 'fixed_rate@50ms'
6
+ | 'fixed_rate@200ms'
7
+ | 'fixed_rate@1000ms'
8
+
9
+ export interface PythProPriceFeed {
10
+ priceFeedId: number
11
+ price: string
12
+ exponent: number
13
+ confidence: string
14
+ feedUpdateTimestamp: string
15
+ priceUsd: number
16
+ emaPrice?: string | null
17
+ emaPriceUsd?: number | null
18
+ }
19
+
20
+ export interface PythProLatestPriceData {
21
+ timestampUs: string
22
+ priceFeeds: PythProPriceFeed[]
23
+ leEcdsaHex: string | null
24
+ }
25
+
26
+ export interface PythProLatestPriceResponse {
27
+ success: boolean
28
+ timestamp?: string
29
+ data?: PythProLatestPriceData
30
+ message?: string
31
+ }
32
+
33
+ export interface IGetLatestPythProPricesOptions {
34
+ priceFeedIds: number[]
35
+ channel?: PythProChannel
36
+ includeEmaPrice?: boolean
37
+ includeUpdateBytes?: boolean
38
+ /** Override kronos REST base URL (default: `DEFAULT_API_ENDPOINT` or SDK instance apiEndpoint). */
39
+ kronosUrl?: string
40
+ }
41
+
42
+ export interface PythProStreamUpdate {
43
+ subscriptionId: number
44
+ timestampUs: string
45
+ priceFeeds: PythProPriceFeed[]
46
+ leEcdsaHex?: string | null
47
+ }
48
+
49
+ export interface IPythProStreamSubscription {
50
+ subscriptionId: number
51
+ priceFeedIds: number[]
52
+ channel?: PythProChannel
53
+ includeEmaPrice?: boolean
54
+ includeUpdateBytes?: boolean
55
+ }
56
+
57
+ export type PythProStreamServerMessage
58
+ = | { type: 'connected', message?: string }
59
+ | { type: 'subscribed', subscriptionId: number }
60
+ | { type: 'unsubscribed', subscriptionId: number }
61
+ | { type: 'streamUpdated' } & PythProStreamUpdate
62
+ | { type: 'subscriptionError', subscriptionId: number, message?: string }
63
+ | { type: 'subscribedWithInvalidFeedIdsIgnored', subscriptionId: number, ignoredFeedIds?: number[] }
64
+ | { type: 'error', message?: string }
65
+ | { type: 'pong' }
66
+
67
+ export type PythProStreamClientMessage
68
+ = | { type: 'subscribe' } & IPythProStreamSubscription
69
+ | { type: 'unsubscribe', subscriptionId: number }
70
+ | { type: 'ping' }
71
+
72
+ export function hexToBytes(hex: string): Uint8Array {
73
+ const normalized = hex.startsWith('0x') ? hex.slice(2) : hex
74
+ const bytes = new Uint8Array(normalized.length / 2)
75
+ for (let i = 0; i < normalized.length; i += 2) {
76
+ bytes[i / 2] = Number.parseInt(normalized.slice(i, i + 2), 16)
77
+ }
78
+ return bytes
79
+ }
80
+
81
+ /** Derive ws(s):// stream URL from kronos http(s) base URL. */
82
+ export function apiBaseUrlToPythProWsUrl(baseUrl: string): string {
83
+ const url = new URL(baseUrl)
84
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
85
+ url.pathname = '/pyth-pro/stream'
86
+ url.search = ''
87
+ url.hash = ''
88
+ return url.toString()
89
+ }
90
+
91
+ function normalizeBaseUrl(baseUrl: string): string {
92
+ return baseUrl.replace(/\/+$/, '')
93
+ }
94
+
95
+ function parseLatestPriceResponse(json: PythProLatestPriceResponse): PythProLatestPriceData {
96
+ if (!json.success || !json.data) {
97
+ throw new Error(json.message ?? 'Pyth Pro latest-price request failed')
98
+ }
99
+ return json.data
100
+ }
101
+
102
+ /**
103
+ * Fetch latest Pyth Pro prices from kronos REST (`GET` or `POST /pyth-pro/latest-price`).
104
+ */
105
+ export async function getLatestPythProPrices(
106
+ baseUrl: string,
107
+ options: IGetLatestPythProPricesOptions,
108
+ ): Promise<PythProLatestPriceData> {
109
+ const { priceFeedIds, channel, includeEmaPrice, includeUpdateBytes } = options
110
+ if (priceFeedIds.length === 0) {
111
+ throw new Error('priceFeedIds is required')
112
+ }
113
+
114
+ const root = normalizeBaseUrl(baseUrl)
115
+
116
+ if (priceFeedIds.length <= 20) {
117
+ const params = new URLSearchParams()
118
+ params.set('priceFeedIds', priceFeedIds.join(','))
119
+ if (channel) {
120
+ params.set('channel', channel)
121
+ }
122
+ if (includeEmaPrice) {
123
+ params.set('includeEmaPrice', 'true')
124
+ }
125
+ if (includeUpdateBytes) {
126
+ params.set('includeUpdateBytes', 'true')
127
+ }
128
+
129
+ const res = await fetch(`${root}/pyth-pro/latest-price?${params}`)
130
+ const json = await res.json() as PythProLatestPriceResponse
131
+ if (!res.ok && json.message) {
132
+ throw new Error(json.message)
133
+ }
134
+ return parseLatestPriceResponse(json)
135
+ }
136
+
137
+ const res = await fetch(`${root}/pyth-pro/latest-price`, {
138
+ method: 'POST',
139
+ headers: { 'Content-Type': 'application/json' },
140
+ body: JSON.stringify({
141
+ priceFeedIds,
142
+ channel,
143
+ includeEmaPrice: includeEmaPrice ?? false,
144
+ includeUpdateBytes: includeUpdateBytes ?? false,
145
+ }),
146
+ })
147
+ const json = await res.json() as PythProLatestPriceResponse
148
+ if (!res.ok && json.message) {
149
+ throw new Error(json.message)
150
+ }
151
+ return parseLatestPriceResponse(json)
152
+ }
153
+
154
+ /**
155
+ * Fetch leEcdsa update bytes via kronos (for on-chain `parse_and_verify_le_ecdsa_update_v2`).
156
+ */
157
+ export async function fetchPythProUpdateBytesFromKronos(
158
+ baseUrl: string,
159
+ feedIds: number[],
160
+ options?: Pick<IGetLatestPythProPricesOptions, 'channel' | 'includeEmaPrice'>,
161
+ ): Promise<Uint8Array> {
162
+ const data = await getLatestPythProPrices(baseUrl, {
163
+ priceFeedIds: feedIds,
164
+ channel: options?.channel,
165
+ includeEmaPrice: options?.includeEmaPrice,
166
+ includeUpdateBytes: true,
167
+ })
168
+ if (!data.leEcdsaHex) {
169
+ throw new Error('No leEcdsaHex in kronos Pyth Pro response (set includeUpdateBytes: true)')
170
+ }
171
+ return hexToBytes(data.leEcdsaHex)
172
+ }
173
+
174
+ export interface IPythProStreamClientOptions {
175
+ /** Defaults to apiBaseUrlToPythProWsUrl(baseUrl). */
176
+ wsUrl?: string
177
+ /** Reconnect after disconnect. Default true. */
178
+ autoReconnect?: boolean
179
+ /** Initial reconnect delay in ms. Default 1000. */
180
+ reconnectDelayMs?: number
181
+ /** Ping interval in ms. Default 30000. Set 0 to disable. */
182
+ pingIntervalMs?: number
183
+ /** Custom WebSocket constructor (Node test mocks, etc.). */
184
+ WebSocketImpl?: typeof WebSocket
185
+ }
186
+
187
+ type StreamUpdateHandler = (update: PythProStreamUpdate) => void
188
+ type StreamMessageHandler = (message: PythProStreamServerMessage) => void
189
+
190
+ /**
191
+ * WebSocket client for kronos `/pyth-pro/stream`.
192
+ * Multiplexes subscriptions and re-subscribes after reconnect.
193
+ */
194
+ export class PythProStreamClient {
195
+ private readonly wsUrl: string
196
+ private readonly autoReconnect: boolean
197
+ private readonly reconnectDelayMs: number
198
+ private readonly pingIntervalMs: number
199
+ private readonly WebSocketImpl: typeof WebSocket
200
+
201
+ private ws: WebSocket | null = null
202
+ private connectPromise: Promise<void> | null = null
203
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null
204
+ private pingTimer: ReturnType<typeof setInterval> | null = null
205
+ private closedByUser = false
206
+
207
+ private readonly subscriptions = new Map<number, IPythProStreamSubscription>()
208
+ private readonly updateHandlers = new Map<number, Set<StreamUpdateHandler>>()
209
+ private readonly messageHandlers = new Set<StreamMessageHandler>()
210
+
211
+ constructor(baseUrl: string, options?: IPythProStreamClientOptions) {
212
+ this.wsUrl = options?.wsUrl ?? apiBaseUrlToPythProWsUrl(baseUrl)
213
+ this.autoReconnect = options?.autoReconnect ?? true
214
+ this.reconnectDelayMs = options?.reconnectDelayMs ?? 1000
215
+ this.pingIntervalMs = options?.pingIntervalMs ?? 30_000
216
+ this.WebSocketImpl = options?.WebSocketImpl ?? WebSocket
217
+ }
218
+
219
+ /** Open the WebSocket and wait for the server `connected` message (or socket open). */
220
+ connect(): Promise<void> {
221
+ if (this.ws?.readyState === WebSocket.OPEN) {
222
+ return Promise.resolve()
223
+ }
224
+ if (this.connectPromise) {
225
+ return this.connectPromise
226
+ }
227
+
228
+ this.closedByUser = false
229
+ this.connectPromise = new Promise((resolve, reject) => {
230
+ const ws = new this.WebSocketImpl(this.wsUrl)
231
+ this.ws = ws
232
+
233
+ const onOpen = () => {
234
+ this.startPing()
235
+ this.resubscribeAll()
236
+ resolve()
237
+ }
238
+
239
+ const onError = (event: Event) => {
240
+ if (ws.readyState !== WebSocket.OPEN) {
241
+ reject(new Error(`Pyth Pro WebSocket connection failed: ${this.wsUrl}`))
242
+ }
243
+ this.emitMessage({ type: 'error', message: String(event) })
244
+ }
245
+
246
+ ws.addEventListener('open', onOpen, { once: true })
247
+ ws.addEventListener('error', onError, { once: true })
248
+ ws.addEventListener('message', (event) => {
249
+ this.handleMessage(String(event.data))
250
+ })
251
+ ws.addEventListener('close', () => {
252
+ this.stopPing()
253
+ this.ws = null
254
+ this.connectPromise = null
255
+ if (!this.closedByUser && this.autoReconnect) {
256
+ this.scheduleReconnect()
257
+ }
258
+ })
259
+ })
260
+
261
+ return this.connectPromise
262
+ }
263
+
264
+ disconnect(): void {
265
+ this.closedByUser = true
266
+ this.clearReconnect()
267
+ this.stopPing()
268
+ if (this.ws) {
269
+ this.ws.close()
270
+ this.ws = null
271
+ }
272
+ this.connectPromise = null
273
+ }
274
+
275
+ /**
276
+ * Subscribe to live price updates. Returns an unsubscribe function.
277
+ * `onUpdate` is called for each `streamUpdated` matching `subscriptionId`.
278
+ */
279
+ subscribe(
280
+ options: IPythProStreamSubscription,
281
+ onUpdate: StreamUpdateHandler,
282
+ ): () => void {
283
+ this.subscriptions.set(options.subscriptionId, options)
284
+
285
+ let handlers = this.updateHandlers.get(options.subscriptionId)
286
+ if (!handlers) {
287
+ handlers = new Set()
288
+ this.updateHandlers.set(options.subscriptionId, handlers)
289
+ }
290
+ handlers.add(onUpdate)
291
+
292
+ void this.connect().then(() => {
293
+ this.send({ type: 'subscribe', ...options })
294
+ })
295
+
296
+ return () => {
297
+ this.unsubscribe(options.subscriptionId, onUpdate)
298
+ }
299
+ }
300
+
301
+ /** Remove one handler or the whole subscription when `onUpdate` is omitted. */
302
+ unsubscribe(subscriptionId: number, onUpdate?: StreamUpdateHandler): void {
303
+ const handlers = this.updateHandlers.get(subscriptionId)
304
+ if (onUpdate && handlers) {
305
+ handlers.delete(onUpdate)
306
+ if (handlers.size > 0) {
307
+ return
308
+ }
309
+ }
310
+
311
+ this.updateHandlers.delete(subscriptionId)
312
+ this.subscriptions.delete(subscriptionId)
313
+ if (this.ws?.readyState === WebSocket.OPEN) {
314
+ this.send({ type: 'unsubscribe', subscriptionId })
315
+ }
316
+ }
317
+
318
+ onMessage(handler: StreamMessageHandler): () => void {
319
+ this.messageHandlers.add(handler)
320
+ return () => {
321
+ this.messageHandlers.delete(handler)
322
+ }
323
+ }
324
+
325
+ private send(message: PythProStreamClientMessage): void {
326
+ if (this.ws?.readyState !== WebSocket.OPEN) {
327
+ return
328
+ }
329
+ this.ws.send(JSON.stringify(message))
330
+ }
331
+
332
+ private resubscribeAll(): void {
333
+ for (const sub of this.subscriptions.values()) {
334
+ this.send({ type: 'subscribe', ...sub })
335
+ }
336
+ }
337
+
338
+ private scheduleReconnect(): void {
339
+ if (this.reconnectTimer) {
340
+ return
341
+ }
342
+ this.reconnectTimer = setTimeout(() => {
343
+ this.reconnectTimer = null
344
+ void this.connect().catch(() => {
345
+ this.scheduleReconnect()
346
+ })
347
+ }, this.reconnectDelayMs)
348
+ }
349
+
350
+ private clearReconnect(): void {
351
+ if (this.reconnectTimer) {
352
+ clearTimeout(this.reconnectTimer)
353
+ this.reconnectTimer = null
354
+ }
355
+ }
356
+
357
+ private startPing(): void {
358
+ if (this.pingIntervalMs <= 0) {
359
+ return
360
+ }
361
+ this.stopPing()
362
+ this.pingTimer = setInterval(() => {
363
+ this.send({ type: 'ping' })
364
+ }, this.pingIntervalMs)
365
+ }
366
+
367
+ private stopPing(): void {
368
+ if (this.pingTimer) {
369
+ clearInterval(this.pingTimer)
370
+ this.pingTimer = null
371
+ }
372
+ }
373
+
374
+ private emitMessage(message: PythProStreamServerMessage): void {
375
+ for (const handler of this.messageHandlers) {
376
+ handler(message)
377
+ }
378
+ }
379
+
380
+ private handleMessage(raw: string): void {
381
+ let message: PythProStreamServerMessage
382
+ try {
383
+ message = JSON.parse(raw) as PythProStreamServerMessage
384
+ }
385
+ catch {
386
+ this.emitMessage({ type: 'error', message: `Invalid JSON: ${raw}` })
387
+ return
388
+ }
389
+
390
+ this.emitMessage(message)
391
+
392
+ if (message.type === 'streamUpdated') {
393
+ const handlers = this.updateHandlers.get(message.subscriptionId)
394
+ if (handlers) {
395
+ for (const handler of handlers) {
396
+ handler({
397
+ subscriptionId: message.subscriptionId,
398
+ timestampUs: message.timestampUs,
399
+ priceFeeds: message.priceFeeds,
400
+ leEcdsaHex: message.leEcdsaHex,
401
+ })
402
+ }
403
+ }
404
+ }
405
+ }
406
+ }