@zofai/zo-sdk 0.2.14 → 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.
- package/dist/abstract/BaseDataAPI.cjs +1 -1
- package/dist/abstract/BaseDataAPI.cjs.map +1 -1
- package/dist/abstract/BaseDataAPI.mjs +1 -1
- package/dist/abstract/BaseDataAPI.mjs.map +1 -1
- package/dist/consts/deployments-usdz-mainnet.json +1 -1
- package/dist/consts/deployments-zo-oracle-mainnet.json +329 -0
- package/dist/consts/index.cjs +35 -1
- package/dist/consts/index.cjs.map +1 -1
- package/dist/consts/index.d.cts +45 -0
- package/dist/consts/index.d.cts.map +1 -1
- package/dist/consts/index.d.mts +45 -0
- package/dist/consts/index.d.mts.map +1 -1
- package/dist/consts/index.mjs +31 -0
- package/dist/consts/index.mjs.map +1 -1
- package/dist/data.cjs +1 -1
- package/dist/data.cjs.map +1 -1
- package/dist/data.mjs +1 -1
- package/dist/data.mjs.map +1 -1
- package/dist/implementations/USDZAPI.cjs +426 -78
- package/dist/implementations/USDZAPI.cjs.map +1 -1
- package/dist/implementations/USDZAPI.d.cts +54 -0
- package/dist/implementations/USDZAPI.d.cts.map +1 -1
- package/dist/implementations/USDZAPI.d.mts +54 -0
- package/dist/implementations/USDZAPI.d.mts.map +1 -1
- package/dist/implementations/USDZAPI.mjs +427 -79
- package/dist/implementations/USDZAPI.mjs.map +1 -1
- package/dist/implementations/USDZDataAPI.cjs +38 -0
- package/dist/implementations/USDZDataAPI.cjs.map +1 -1
- package/dist/implementations/USDZDataAPI.d.cts +16 -0
- package/dist/implementations/USDZDataAPI.d.cts.map +1 -1
- package/dist/implementations/USDZDataAPI.d.mts +16 -0
- package/dist/implementations/USDZDataAPI.d.mts.map +1 -1
- package/dist/implementations/USDZDataAPI.mjs +38 -0
- package/dist/implementations/USDZDataAPI.mjs.map +1 -1
- package/dist/index.cjs +3 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -0
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +3 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +3 -0
- package/dist/index.mjs.map +1 -1
- package/dist/interfaces/base.d.cts +19 -1
- package/dist/interfaces/base.d.cts.map +1 -1
- package/dist/interfaces/base.d.mts +19 -1
- package/dist/interfaces/base.d.mts.map +1 -1
- package/dist/interfaces/usdz.d.cts +17 -0
- package/dist/interfaces/usdz.d.cts.map +1 -1
- package/dist/interfaces/usdz.d.mts +17 -0
- package/dist/interfaces/usdz.d.mts.map +1 -1
- package/dist/oracle.cjs +119 -1
- package/dist/oracle.cjs.map +1 -1
- package/dist/oracle.d.cts +57 -1
- package/dist/oracle.d.cts.map +1 -1
- package/dist/oracle.d.mts +57 -1
- package/dist/oracle.d.mts.map +1 -1
- package/dist/oracle.mjs +120 -2
- package/dist/oracle.mjs.map +1 -1
- package/dist/oraclePro.cjs +214 -0
- package/dist/oraclePro.cjs.map +1 -0
- package/dist/oraclePro.d.cts +92 -0
- package/dist/oraclePro.d.cts.map +1 -0
- package/dist/oraclePro.d.mts +92 -0
- package/dist/oraclePro.d.mts.map +1 -0
- package/dist/oraclePro.mjs +196 -0
- package/dist/oraclePro.mjs.map +1 -0
- package/dist/pythProClient.cjs +277 -0
- package/dist/pythProClient.cjs.map +1 -0
- package/dist/pythProClient.d.cts +143 -0
- package/dist/pythProClient.d.cts.map +1 -0
- package/dist/pythProClient.d.mts +143 -0
- package/dist/pythProClient.d.mts.map +1 -0
- package/dist/pythProClient.mjs +269 -0
- package/dist/pythProClient.mjs.map +1 -0
- package/dist/storkClient.cjs +217 -0
- package/dist/storkClient.cjs.map +1 -0
- package/dist/storkClient.d.cts +90 -0
- package/dist/storkClient.d.cts.map +1 -0
- package/dist/storkClient.d.mts +90 -0
- package/dist/storkClient.d.mts.map +1 -0
- package/dist/storkClient.mjs +208 -0
- package/dist/storkClient.mjs.map +1 -0
- package/dist/storkOracle.cjs +61 -0
- package/dist/storkOracle.cjs.map +1 -0
- package/dist/storkOracle.d.cts +21 -0
- package/dist/storkOracle.d.cts.map +1 -0
- package/dist/storkOracle.d.mts +21 -0
- package/dist/storkOracle.d.mts.map +1 -0
- package/dist/storkOracle.mjs +56 -0
- package/dist/storkOracle.mjs.map +1 -0
- package/package.json +1 -1
- package/src/abstract/BaseDataAPI.ts +1 -1
- package/src/consts/deployments-usdz-mainnet.json +1 -1
- package/src/consts/deployments-zo-oracle-mainnet.json +329 -0
- package/src/consts/index.ts +80 -0
- package/src/data.ts +1 -1
- package/src/implementations/USDZAPI.ts +731 -101
- package/src/implementations/USDZDataAPI.ts +42 -0
- package/src/index.ts +3 -0
- package/src/interfaces/base.ts +54 -1
- package/src/interfaces/usdz.ts +125 -0
- package/src/oracle.ts +222 -1
- package/src/oraclePro.ts +312 -0
- package/src/pythProClient.ts +406 -0
- package/src/storkClient.ts +328 -0
- package/src/storkOracle.ts +82 -0
- package/tests/pythProClient.test.ts +155 -0
- package/tests/storkClient.test.ts +316 -0
package/src/oraclePro.ts
ADDED
|
@@ -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
|
+
}
|