@waterx/sdk 4.0.0 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +34 -34
  2. package/dist/cjs/src/account/config.d.ts +0 -16
  3. package/dist/cjs/src/oracle/aggregate.d.ts +19 -21
  4. package/dist/cjs/src/oracle/aggregate.js +57 -69
  5. package/dist/cjs/src/oracle/config.d.ts +32 -52
  6. package/dist/cjs/src/oracle/config.js +1 -35
  7. package/dist/cjs/src/oracle/host.d.ts +1 -1
  8. package/dist/cjs/src/oracle/index.d.ts +2 -2
  9. package/dist/cjs/src/oracle/index.js +20 -6
  10. package/dist/cjs/src/oracle/pyth.d.ts +68 -6
  11. package/dist/cjs/src/oracle/pyth.js +338 -22
  12. package/dist/cjs/src/oracle/rule-registry.d.ts +10 -6
  13. package/dist/cjs/src/oracle/rule-registry.js +10 -6
  14. package/dist/cjs/src/oracle/rules/pyth-core-rule.js +17 -2
  15. package/dist/cjs/src/oracle/rules/pyth-lazer-rule.d.ts +6 -6
  16. package/dist/cjs/src/oracle/rules/pyth-lazer-rule.js +25 -22
  17. package/dist/cjs/src/oracle/rules/pyth-rule.js +5 -0
  18. package/dist/cjs/src/oracle/update-fetch.d.ts +32 -2
  19. package/dist/cjs/src/oracle/update-fetch.js +60 -3
  20. package/dist/cjs/src/perp/client.d.ts +33 -19
  21. package/dist/cjs/src/perp/client.js +16 -10
  22. package/dist/cjs/src/perp/config.d.ts +4 -6
  23. package/dist/cjs/src/perp/config.js +8 -12
  24. package/dist/cjs/src/perp/index.d.ts +2 -2
  25. package/dist/cjs/src/perp/index.js +3 -4
  26. package/dist/cjs/src/unified-client.d.ts +19 -11
  27. package/dist/cjs/src/unified-client.js +2 -1
  28. package/dist/src/account/config.d.ts +0 -16
  29. package/dist/src/oracle/aggregate.d.ts +19 -21
  30. package/dist/src/oracle/aggregate.js +57 -69
  31. package/dist/src/oracle/config.d.ts +32 -52
  32. package/dist/src/oracle/config.js +0 -34
  33. package/dist/src/oracle/host.d.ts +1 -1
  34. package/dist/src/oracle/index.d.ts +2 -2
  35. package/dist/src/oracle/index.js +15 -7
  36. package/dist/src/oracle/pyth.d.ts +68 -6
  37. package/dist/src/oracle/pyth.js +334 -22
  38. package/dist/src/oracle/rule-registry.d.ts +10 -6
  39. package/dist/src/oracle/rule-registry.js +10 -6
  40. package/dist/src/oracle/rules/pyth-core-rule.js +18 -3
  41. package/dist/src/oracle/rules/pyth-lazer-rule.d.ts +6 -6
  42. package/dist/src/oracle/rules/pyth-lazer-rule.js +26 -23
  43. package/dist/src/oracle/rules/pyth-rule.js +5 -0
  44. package/dist/src/oracle/update-fetch.d.ts +32 -2
  45. package/dist/src/oracle/update-fetch.js +57 -3
  46. package/dist/src/perp/client.d.ts +33 -19
  47. package/dist/src/perp/client.js +17 -11
  48. package/dist/src/perp/config.d.ts +4 -6
  49. package/dist/src/perp/config.js +9 -12
  50. package/dist/src/perp/index.d.ts +2 -2
  51. package/dist/src/perp/index.js +1 -1
  52. package/dist/src/unified-client.d.ts +19 -11
  53. package/dist/src/unified-client.js +2 -1
  54. package/package.json +1 -1
@@ -15,40 +15,343 @@
15
15
  */
16
16
  import { fromHex, toHex } from "@mysten/bcs";
17
17
  import { bcs } from "@mysten/sui/bcs";
18
- import { FetchPolicyError, fetchWithPolicy } from "./update-fetch.js";
18
+ import { fetchWithPolicy, joinEndpointPath, rethrowExhaustedFetch, trimTrailingSlashes, } from "./update-fetch.js";
19
19
  export class PythCache {
20
20
  pythStateInfo;
21
21
  wormholePackageId;
22
22
  priceTableInfo;
23
23
  priceFeedObjectIdCache = new Map();
24
24
  }
25
- // ============================================================================
26
- // Hermes REST
27
- // ============================================================================
25
+ /**
26
+ * How long a "this endpoint lacks feed X" verdict stays memoized. The verdict
27
+ * is a claim about *someone else's* deployment — a feed can be added to the
28
+ * catalog, an entitlement can be granted, a Pro plan can be upgraded — so it
29
+ * must expire rather than bind the whole process lifetime. Long enough that a
30
+ * genuinely-absent feed costs one discovery per window instead of one per
31
+ * build; short enough that a recovered endpoint self-heals without a restart.
32
+ */
33
+ export const MISSING_FEED_MEMO_TTL_MS = 15 * 60_000;
34
+ /**
35
+ * Per-endpoint memo of feed ids this endpoint has rejected as unknown (a 404
36
+ * on `/v2/updates/price/latest`). The Pyth Pro compat endpoint carries a
37
+ * SUBSET of Core's feeds — mainnet `WTIUSD`/`BRENTUSD` (Commodities
38
+ * USOILSPOT/UKOILSPOT) are absent, for example. Pyth 404s the WHOLE batch if
39
+ * ANY id is unknown, and the body naming the bad ids is NOT reliably delivered
40
+ * to `fetch` (Cloudflare returns it to curl but `content-length: 0` to node's
41
+ * undici), so the ids are isolated by catalog read (or bisection) and
42
+ * remembered here — later batches skip them instead of 404ing every time.
43
+ *
44
+ * Entries carry an expiry ({@link MISSING_FEED_MEMO_TTL_MS}); an
45
+ * endpoint-level fault never lands here at all (see {@link
46
+ * HermesEndpointRejectedAllFeedsError}).
47
+ */
48
+ const missingFeedIdsByEndpoint = new Map();
49
+ /**
50
+ * Thrown when discovery concludes that an endpoint rejects EVERY requested
51
+ * feed id without a catalog vouching for that verdict. `instanceof`-able
52
+ * (mirrors `FetchPolicyError` / {@link OracleFeeSourceUnavailableError}).
53
+ *
54
+ * "All of them are missing" is the signature of an endpoint/credential fault —
55
+ * a wrong base path (the Pyth Pro `/hermes` prefix dropped), a changed route,
56
+ * a revoked or downgraded entitlement — not of N individually-absent feeds.
57
+ * Memoizing it would convert a loud, fixable misconfiguration into a silent
58
+ * permanent one: every id marked missing ⇒ `fetchPriceFeedsUpdateData` returns
59
+ * `[]` ⇒ `buildPythPriceUpdateCalls` throws "Hermes returned empty results",
60
+ * blaming Hermes for having no data, for the rest of the process's life. So
61
+ * this case writes NOTHING to the memo and throws instead; the next call
62
+ * re-probes and recovers on its own once the endpoint does.
63
+ *
64
+ * The message keeps the `Hermes price fetch failed: <status>` prefix on its
65
+ * first line — the documented contract downstream consumers string-match (see
66
+ * {@link fetchPriceFeedsUpdateData} and the e2e transient detector).
67
+ */
68
+ export class HermesEndpointRejectedAllFeedsError extends Error {
69
+ endpoint;
70
+ requestedCount;
71
+ constructor(endpoint, requestedCount, catalogState) {
72
+ super(`Hermes price fetch failed: 404 — endpoint rejected ALL ${requestedCount} requested feed id(s) ` +
73
+ `and its feed catalog was ${catalogState}, so nothing vouches for those feeds being ` +
74
+ `individually absent. Treating this as an endpoint/credential fault (wrong base path — ` +
75
+ `e.g. a dropped Pyth Pro '/hermes' prefix — changed route, or revoked entitlement) rather ` +
76
+ `than caching every feed as missing. Endpoint: ${endpoint}`);
77
+ this.endpoint = endpoint;
78
+ this.requestedCount = requestedCount;
79
+ this.name = "HermesEndpointRejectedAllFeedsError";
80
+ }
81
+ }
82
+ /**
83
+ * Memo key for `(endpoint, credential)` — the same trailing-slash trim
84
+ * {@link joinEndpointPath} applies to the request URL (shared helper, so the
85
+ * two can't drift), plus the apiKey: "feed X is missing" is a property of the
86
+ * endpoint AND the credential (Pyth Pro entitlements are per-key), so two
87
+ * clients in one process with different keys must not cross-poison each
88
+ * other's memo. Without the trim, two consumers spelling the same endpoint
89
+ * differently (`…/hermes` vs `…/hermes/`) would fragment the memo and re-run
90
+ * the whole 404 discovery despite one of them having already paid for it.
91
+ */
92
+ function memoKey(endpoint, apiKey) {
93
+ return `${trimTrailingSlashes(endpoint)}\u0000${apiKey ?? ""}`;
94
+ }
95
+ function recordMissingFeeds(endpoint, feedIds, apiKey) {
96
+ const key = memoKey(endpoint, apiKey);
97
+ let entries = missingFeedIdsByEndpoint.get(key);
98
+ if (!entries) {
99
+ entries = new Map();
100
+ missingFeedIdsByEndpoint.set(key, entries);
101
+ }
102
+ const expiresAt = Date.now() + MISSING_FEED_MEMO_TTL_MS;
103
+ // Key on the bare (0x-stripped, lowercased) form — the SAME normalization the
104
+ // catalog comparison and the in-flight latch use — so a feed can't fragment
105
+ // the memo across `0xAB`/`ab` spellings and slip back through as unfiltered.
106
+ for (const feedId of feedIds)
107
+ entries.set(bareFeedId(feedId), expiresAt);
108
+ }
109
+ /**
110
+ * The subset of `feedIds` this `endpoint` is known to serve — i.e. minus any
111
+ * discovered to be absent within the last {@link MISSING_FEED_MEMO_TTL_MS}
112
+ * (see {@link fetchPriceFeedsUpdateData}). Callers building a
113
+ * `{ updates, feedIds }` payload use this to keep `feedIds` aligned with the
114
+ * feeds the fetch actually returned data for, so `buildPythPriceUpdateCalls`
115
+ * (one moveCall per feed id) never references a feed the accumulator blob
116
+ * doesn't cover.
117
+ *
118
+ * Expired entries are pruned here rather than on a timer: the memo is only
119
+ * ever consulted through this function, so a lazy sweep is both sufficient and
120
+ * free of a dangling interval in a library.
121
+ */
122
+ export function endpointSupportedFeedIds(endpoint, feedIds, apiKey) {
123
+ const key = memoKey(endpoint, apiKey);
124
+ const entries = missingFeedIdsByEndpoint.get(key);
125
+ if (!entries)
126
+ return feedIds;
127
+ const now = Date.now();
128
+ for (const [id, expiresAt] of entries) {
129
+ if (expiresAt <= now)
130
+ entries.delete(id);
131
+ }
132
+ if (entries.size === 0) {
133
+ missingFeedIdsByEndpoint.delete(key);
134
+ return feedIds;
135
+ }
136
+ return feedIds.filter((id) => !entries.has(bareFeedId(id)));
137
+ }
138
+ /**
139
+ * Discovery runs currently in flight, keyed by `(endpoint, credential,
140
+ * requested id set)`. A cold memo plus two concurrent tx-builds asking the
141
+ * same question ran two full independent discoveries — duplicate catalog reads
142
+ * (or duplicate bisection probe trees) on the money path, for one answer. The
143
+ * second caller now joins the first run's promise: each still gets its own
144
+ * survivor data, but they share the one discovery behind it.
145
+ */
146
+ const inFlightDiscoveries = new Map();
147
+ /** Test-only: forget everything learned about which feeds an endpoint lacks. */
148
+ export function __resetMissingFeedCacheForTest() {
149
+ missingFeedIdsByEndpoint.clear();
150
+ inFlightDiscoveries.clear();
151
+ }
152
+ /**
153
+ * One `GET /v2/price_feeds` — the set of feed ids this endpoint serves for
154
+ * THIS credential (verified against the Pro compat endpoint: WTI absent from
155
+ * the catalog AND 404 on latest-price; BTC present AND 200 —
156
+ * entitlement-filtered per key), normalized to bare lowercase hex.
157
+ *
158
+ * Returns `null` when the catalog is unreadable (non-2xx, unparseable, network
159
+ * error) — the catalog is an optimization, {@link bisectMissingFeeds} remains
160
+ * the ground truth derived from the money-path fetch itself. Note that "read
161
+ * fine, served nothing" (`size === 0`) is NOT the same as unreadable: an empty
162
+ * catalog is an entitlement/route fault in its own right, and the caller
163
+ * treats it as one.
164
+ */
165
+ async function readEndpointCatalog(endpoint, opts) {
166
+ try {
167
+ const res = await fetchWithPolicy(joinEndpointPath(endpoint, "v2/price_feeds").toString(), {}, { apiKey: opts?.apiKey, ...opts?.fetch });
168
+ if (!res.ok) {
169
+ void res.body?.cancel().catch(() => { });
170
+ return null;
171
+ }
172
+ const catalog = (await res.json());
173
+ if (!Array.isArray(catalog))
174
+ return null;
175
+ // Catalog ids come WITHOUT the 0x prefix; callers pass either form.
176
+ return new Set(catalog.map((f) => bareFeedId(f.id)));
177
+ }
178
+ catch {
179
+ return null;
180
+ }
181
+ }
182
+ /** Feed ids are compared prefix- and case-insensitively (`0xAB` ≡ `ab`). */
183
+ function bareFeedId(feedId) {
184
+ return feedId.toLowerCase().replace(/^0x/, "");
185
+ }
186
+ async function rawFetch(endpoint, ids, opts) {
187
+ // joinEndpointPath preserves the endpoint's own base path — `new URL`
188
+ // with a leading-slash path would discard it (the Pyth Pro `/hermes`
189
+ // prefix → 404 on EVERY feed); see its doc in update-fetch.ts.
190
+ const url = joinEndpointPath(endpoint, "v2/updates/price/latest");
191
+ ids.forEach((id) => url.searchParams.append("ids[]", id));
192
+ return fetchWithPolicy(url.toString(), {}, { apiKey: opts?.apiKey, ...opts?.fetch });
193
+ }
194
+ /**
195
+ * Bisect `ids` down to the individual ones this endpoint 404s on — the
196
+ * response body isn't readable, so a single id that still 404s IS the unknown
197
+ * one. Data is discarded; this only *reports* (the caller decides whether the
198
+ * verdict is trustworthy enough to memoize). A non-404 (the subset is fine) or
199
+ * a network error stops that branch and contributes nothing.
200
+ *
201
+ * `known404: true` skips the root probe — the caller has already watched this
202
+ * exact batch 404, so re-fetching it would only re-learn a fact in hand (a
203
+ * wasted round trip on the money path during cold discovery). Recursive
204
+ * half-calls always probe: their status is genuinely unknown.
205
+ */
206
+ async function bisectMissingFeeds(endpoint, ids, opts, known404 = false) {
207
+ if (ids.length === 0)
208
+ return new Set();
209
+ if (!known404) {
210
+ let res;
211
+ try {
212
+ res = await rawFetch(endpoint, ids, opts);
213
+ }
214
+ catch {
215
+ return new Set(); // transient/network failure — can't classify
216
+ }
217
+ void res.body?.cancel().catch(() => { });
218
+ if (res.status !== 404)
219
+ return new Set(); // this subset is serveable
220
+ }
221
+ if (ids.length === 1)
222
+ return new Set([ids[0]]);
223
+ const mid = Math.floor(ids.length / 2);
224
+ const [lo, hi] = await Promise.all([
225
+ bisectMissingFeeds(endpoint, ids.slice(0, mid), opts),
226
+ bisectMissingFeeds(endpoint, ids.slice(mid), opts),
227
+ ]);
228
+ return new Set([...lo, ...hi]);
229
+ }
230
+ /**
231
+ * Work out which of `ids` this endpoint lacks and memoize exactly those.
232
+ *
233
+ * Catalog first: one `GET /v2/price_feeds` answers for the whole batch, so a
234
+ * confirmed 404 costs one extra request instead of O(log n) bisection probes.
235
+ * The catalog is also the only *authoritative* source here — it says what the
236
+ * endpoint DOES serve, which is what separates "these two feeds are absent"
237
+ * from "this endpoint is serving nothing to me". Bisection can only observe
238
+ * 404s, and a wrong base path 404s identically to an unknown feed.
239
+ *
240
+ * Hence the guard: a verdict of "every requested id is missing" is only
241
+ * committed when a non-empty catalog vouches for it. Otherwise nothing is
242
+ * written and {@link HermesEndpointRejectedAllFeedsError} is thrown — see its
243
+ * docblock for why silently memoizing that case is worse than failing.
244
+ *
245
+ * Concurrent callers asking the identical question share ONE run (see {@link
246
+ * inFlightDiscoveries}); the latch is released on failure too, so a blip never
247
+ * pins later callers to a stale outcome.
248
+ *
249
+ * @throws HermesEndpointRejectedAllFeedsError on an endpoint-level rejection.
250
+ */
251
+ function discoverMissingFeeds(endpoint, ids, opts, known404 = false) {
252
+ // Same endpoint + credential + requested set ⇒ same answer; anything else
253
+ // is a different question and runs on its own.
254
+ const key = `${memoKey(endpoint, opts?.apiKey)}${ids.map(bareFeedId).sort().join(",")}`;
255
+ const inFlight = inFlightDiscoveries.get(key);
256
+ if (inFlight)
257
+ return inFlight;
258
+ const run = runDiscovery(endpoint, ids, opts, known404).finally(() => {
259
+ inFlightDiscoveries.delete(key);
260
+ });
261
+ inFlightDiscoveries.set(key, run);
262
+ return run;
263
+ }
264
+ async function runDiscovery(endpoint, ids, opts, known404 = false) {
265
+ if (ids.length === 0)
266
+ return;
267
+ if (!known404) {
268
+ let res;
269
+ try {
270
+ res = await rawFetch(endpoint, ids, opts);
271
+ }
272
+ catch {
273
+ return; // transient/network failure — can't classify; leave the memo untouched
274
+ }
275
+ void res.body?.cancel().catch(() => { });
276
+ if (res.status !== 404)
277
+ return; // this batch is serveable — nothing to record
278
+ }
279
+ const catalog = await readEndpointCatalog(endpoint, opts);
280
+ if (catalog !== null && catalog.size === 0) {
281
+ // Read fine, serves nothing: an entitlement/route fault, not N absent feeds.
282
+ throw new HermesEndpointRejectedAllFeedsError(endpoint, ids.length, "empty");
283
+ }
284
+ const missing = catalog !== null
285
+ ? new Set(ids.filter((id) => !catalog.has(bareFeedId(id))))
286
+ : await bisectMissingFeeds(endpoint, ids, opts, true);
287
+ if (missing.size === 0)
288
+ return;
289
+ if (catalog === null && missing.size === ids.length) {
290
+ throw new HermesEndpointRejectedAllFeedsError(endpoint, ids.length, "unreadable");
291
+ }
292
+ recordMissingFeeds(endpoint, missing, opts?.apiKey);
293
+ }
294
+ /**
295
+ * Discovery-only entry for consumers that fetch Hermes THEMSELVES (e.g. a
296
+ * parsed latest-price reader) and just observed a whole-batch 404: resolves
297
+ * which ids the endpoint lacks, memoizes them (see {@link
298
+ * endpointSupportedFeedIds}), fetches NO survivor data. Without this, such a
299
+ * consumer's only way to populate the memo was calling {@link
300
+ * fetchPriceFeedsUpdateData} and discarding its accumulator blob — two full
301
+ * redundant transfers per cold discovery.
302
+ *
303
+ * @throws HermesEndpointRejectedAllFeedsError when the rejection looks
304
+ * endpoint-wide rather than per-feed — the caller's own 404 is then a
305
+ * misconfiguration to surface, not a set of feeds to quietly drop.
306
+ */
307
+ export function probeMissingFeeds(endpoint, ids, opts) {
308
+ return discoverMissingFeeds(endpoint, ids, opts, true);
309
+ }
28
310
  export async function fetchPriceFeedsUpdateData(endpoint, priceIds, opts) {
29
- if (priceIds.length === 0)
311
+ // Skip feeds this endpoint has already told us it doesn't have.
312
+ const ids = endpointSupportedFeedIds(endpoint, priceIds, opts?.apiKey);
313
+ if (ids.length === 0)
30
314
  return [];
31
- const url = new URL("/v2/updates/price/latest", endpoint);
32
- priceIds.forEach((id) => url.searchParams.append("ids[]", id));
33
315
  let res;
34
316
  try {
35
- res = await fetchWithPolicy(url.toString(), {}, { apiKey: opts?.apiKey, ...opts?.fetch });
317
+ res = await rawFetch(endpoint, ids, opts);
36
318
  }
37
319
  catch (err) {
38
- // A retryable status (429/5xx) that never recovered surfaces as a
39
- // FetchPolicyError with `status` set — reformat it into this function's
40
- // own message shape so callers (and the e2e transient-failure detector,
41
- // which keys off "Hermes price fetch failed") see the same text whether
42
- // the failure was retried or not. A network-level exhaustion (no status)
43
- // has no domain-specific reframing to add propagate it as-is.
44
- if (err instanceof FetchPolicyError && err.status !== undefined) {
45
- const body = err.bodySnippet ? ` ${err.bodySnippet}` : "";
46
- throw new Error(`Hermes price fetch failed: ${err.status}${body} (retries exhausted after ${err.attempts} attempts)`, { cause: err });
320
+ rethrowExhaustedFetch(err, (e) => `Hermes price fetch failed: ${e.status}${e.bodySnippet ? ` ${e.bodySnippet}` : ""}`);
321
+ }
322
+ if (!res.ok) {
323
+ // Drain the body ONCE, here. A `Response` body can only be read once, and
324
+ // the 404 branch below finishes with it before the throw is reached — so
325
+ // reading it inside the throw surfaced `TypeError: Body is unusable`
326
+ // instead of this function's documented message whenever a 404 fell
327
+ // through. Reading up front also releases the connection on every path.
328
+ const body = await res.text().catch(() => "");
329
+ // Pyth 404s the ENTIRE batch if ANY id is unknown to the endpoint (a Core
330
+ // feed absent from the Pyth Pro compat endpoint). This is on the money
331
+ // path of every order/position/WLP tx-build, so instead of failing the
332
+ // whole refresh: discover the unknown ids (catalog, else bisection — the
333
+ // body isn't reliably delivered), memoize them, and re-fetch the survivors
334
+ // as ONE clean batch (a single combined accumulator blob, which
335
+ // buildPythPriceUpdateCalls requires). A genuinely-absent ticker just
336
+ // isn't in the payload — its on-chain aggregate abstains/aborts, which is
337
+ // correct. Steady state: once discovered, survivors are filtered up front
338
+ // and this never runs. A rejection that looks endpoint-wide instead of
339
+ // per-feed throws out of here (HermesEndpointRejectedAllFeedsError).
340
+ if (res.status === 404) {
341
+ // known404: this exact batch just 404'd above — skip the root re-probe.
342
+ await discoverMissingFeeds(endpoint, ids, opts, true);
343
+ const survivors = endpointSupportedFeedIds(endpoint, ids, opts?.apiKey);
344
+ if (survivors.length === 0)
345
+ return [];
346
+ // survivors < ids ⇒ we removed the offender(s); re-fetch cleanly. Equal
347
+ // ⇒ nothing was recorded (a 404 that wasn't a missing-feed rejection) —
348
+ // surface it rather than loop.
349
+ if (survivors.length < ids.length) {
350
+ return fetchPriceFeedsUpdateData(endpoint, survivors, opts);
351
+ }
47
352
  }
48
- throw err;
353
+ throw new Error(`Hermes price fetch failed: ${res.status}${body ? ` ${body}` : ""}`);
49
354
  }
50
- if (!res.ok)
51
- throw new Error(`Hermes price fetch failed: ${res.status} ${await res.text()}`);
52
355
  const json = (await res.json());
53
356
  const data = json.binary?.data;
54
357
  if (!Array.isArray(data) || data.length === 0) {
@@ -278,9 +581,18 @@ export async function buildPythPriceUpdateCalls(tx, host, updates, feedIds, opts
278
581
  }
279
582
  /** All-in-one: fetch from Hermes, append update calls. Returns PriceInfoObject IDs. */
280
583
  export async function updatePythPrices(tx, host, feedIds, opts) {
281
- const updates = await fetchPriceFeedsUpdateData(host.pyth.hermes_endpoint, feedIds, {
584
+ // `host.pyth` is the Pyth Core infra (fixed per network) plus the caller's
585
+ // api_key/fetch — endpoint, credential and policy all come from it.
586
+ const endpoint = host.pyth.hermes_endpoint;
587
+ const updates = await fetchPriceFeedsUpdateData(endpoint, feedIds, {
282
588
  apiKey: host.pyth.api_key,
283
589
  fetch: host.pyth.fetch,
284
590
  });
285
- return buildPythPriceUpdateCalls(tx, host, updates, feedIds, opts);
591
+ // Align feedIds with the feeds the endpoint actually served — the fetch drops
592
+ // (and memoizes) any it lacks, and `buildPythPriceUpdateCalls` emits one
593
+ // update call per feedId, so a dropped feed would reference a PriceInfoObject
594
+ // the accumulator blob doesn't cover (invalid PTB / on-chain abort). Mirrors
595
+ // `PythCoreRule.fetchUpdateData`.
596
+ const servedFeedIds = endpointSupportedFeedIds(endpoint, feedIds, host.pyth.api_key);
597
+ return buildPythPriceUpdateCalls(tx, host, updates, servedFeedIds, opts);
286
598
  }
@@ -3,13 +3,17 @@
3
3
  * `PriceUpdateRule` implementation. `refreshOraclePrices` (`aggregate.ts`) is
4
4
  * the only production caller; this is the one place `OracleSource` values are
5
5
  * wired to a rule instance. Selection is driven purely by the value passed in
6
- * (ultimately `OracleHost.oracleSource`, a client create option) — never by a
7
- * config JSON `enabled` flag and never by `process.env`.
6
+ * (ultimately `OracleHost.oracleSource`, the `oracleSource` client create
7
+ * option) — never by a config JSON `enabled` flag and never by `process.env`.
8
8
  *
9
- * Both sources are registered: `pyth_rule` (`PythCoreRule`, Hermes VAA) and
10
- * `pyth_lazer_rule` (`PythLazerRule`, Lazer signed updates). Resolving a
11
- * source with no registered rule throws a clear `OracleSourceNotImplemented`
12
- * error instead of silently falling back to Pyth Core.
9
+ * Each source is self-contained: it owns its own infra + config and does NOT
10
+ * back-stop any other source. Both are registered: `pyth_rule` (`PythCoreRule`,
11
+ * Hermes VAA) and `pyth_lazer_rule` (`PythLazerRule`, Lazer signed updates).
12
+ * Resolving a source with no registered rule throws a clear
13
+ * `OracleSourceNotImplemented` error. There is deliberately no cross-source
14
+ * fallback and no client-creation config guard: selecting a source whose feeds
15
+ * are absent is not an error at init — it surfaces at tx-build time for the
16
+ * specific tickers that source can't serve (see `refreshOraclePrices`).
13
17
  */
14
18
  import type { OracleSource, PriceUpdateRule } from "./price-update-rule.ts";
15
19
  /**
@@ -3,13 +3,17 @@
3
3
  * `PriceUpdateRule` implementation. `refreshOraclePrices` (`aggregate.ts`) is
4
4
  * the only production caller; this is the one place `OracleSource` values are
5
5
  * wired to a rule instance. Selection is driven purely by the value passed in
6
- * (ultimately `OracleHost.oracleSource`, a client create option) — never by a
7
- * config JSON `enabled` flag and never by `process.env`.
6
+ * (ultimately `OracleHost.oracleSource`, the `oracleSource` client create
7
+ * option) — never by a config JSON `enabled` flag and never by `process.env`.
8
8
  *
9
- * Both sources are registered: `pyth_rule` (`PythCoreRule`, Hermes VAA) and
10
- * `pyth_lazer_rule` (`PythLazerRule`, Lazer signed updates). Resolving a
11
- * source with no registered rule throws a clear `OracleSourceNotImplemented`
12
- * error instead of silently falling back to Pyth Core.
9
+ * Each source is self-contained: it owns its own infra + config and does NOT
10
+ * back-stop any other source. Both are registered: `pyth_rule` (`PythCoreRule`,
11
+ * Hermes VAA) and `pyth_lazer_rule` (`PythLazerRule`, Lazer signed updates).
12
+ * Resolving a source with no registered rule throws a clear
13
+ * `OracleSourceNotImplemented` error. There is deliberately no cross-source
14
+ * fallback and no client-creation config guard: selecting a source whose feeds
15
+ * are absent is not an error at init — it surfaces at tx-build time for the
16
+ * specific tickers that source can't serve (see `refreshOraclePrices`).
13
17
  */
14
18
  import { PythCoreRule } from "./rules/pyth-core-rule.js";
15
19
  import { PythLazerRule } from "./rules/pyth-lazer-rule.js";
@@ -7,7 +7,7 @@
7
7
  * changes vs `../pyth.ts` / `./pyth-rule.ts`.
8
8
  */
9
9
  import { assertRuleUpdateData, } from "../price-update-rule.js";
10
- import { buildPythPriceUpdateCalls, fetchPriceFeedsUpdateData } from "../pyth.js";
10
+ import { buildPythPriceUpdateCalls, endpointSupportedFeedIds, fetchPriceFeedsUpdateData, } from "../pyth.js";
11
11
  /**
12
12
  * Shape check ONLY — the `kind` discriminant is checked separately by the
13
13
  * caller before this runs, since a same-shaped payload from a different rule
@@ -33,12 +33,27 @@ export const PythCoreRule = {
33
33
  async fetchUpdateData(host, tickers) {
34
34
  if (tickers.length === 0)
35
35
  return null;
36
+ // `host.pyth` is this source's own infra — the fixed per-network Core
37
+ // Pyth block plus the caller's api_key/fetch. Endpoint, credential and
38
+ // retry/timeout policy all come from it.
39
+ const endpoint = host.pyth.hermes_endpoint;
36
40
  const feedIds = tickers.map((ticker) => host.getPythFeed(ticker).feed_id);
37
- const updates = await fetchPriceFeedsUpdateData(host.pyth.hermes_endpoint, feedIds, {
41
+ const updates = await fetchPriceFeedsUpdateData(endpoint, feedIds, {
38
42
  apiKey: host.pyth.api_key,
39
43
  fetch: host.pyth.fetch,
40
44
  });
41
- return { kind: "pyth_rule", payload: { updates, feedIds } };
45
+ // `updates` covers only the feeds this endpoint actually served — the fetch
46
+ // drops (and memoizes) any it lacks, e.g. Core feeds absent from Pyth Pro
47
+ // (WTIUSD/BRENTUSD). Align `feedIds` with them: buildPythPriceUpdateCalls
48
+ // emits one moveCall per feedId and must not reference a feed the
49
+ // accumulator blob doesn't cover.
50
+ return {
51
+ kind: "pyth_rule",
52
+ payload: {
53
+ updates,
54
+ feedIds: endpointSupportedFeedIds(endpoint, feedIds, host.pyth.api_key),
55
+ },
56
+ };
42
57
  },
43
58
  /**
44
59
  * Subsets a (typically whole-universe) payload from {@link fetchUpdateData}
@@ -3,7 +3,7 @@
3
3
  * updates, plus `feedLazerRule`, the collector-feed leg `aggregateTicker`
4
4
  * appends per lazer-routed ticker. Fetches one `leEcdsa` payload for all
5
5
  * requested integer feed ids from the Lazer HTTP API (Bearer-authenticated
6
- * via `config.pyth.api_key`), verifies it ONCE on-chain via
6
+ * via the `pythApiKey` create option), verifies it ONCE on-chain via
7
7
  * `pyth_lazer::parse_and_verify_le_ecdsa_update`, and hands the resulting
8
8
  * `Update` PTB value back through a `RuleUpdateHandle` for the feed calls.
9
9
  */
@@ -19,11 +19,11 @@ export interface PythLazerUpdatePayload {
19
19
  }
20
20
  /**
21
21
  * Thrown by {@link PythLazerRule.fetchUpdateData} when `pyth_lazer_rule` is
22
- * deployed in config but no `pyth.api_key` is set the Lazer HTTP API
23
- * requires a Bearer token and the SDK never reads `process.env` to find one.
24
- * `instanceof`-able (mirrors `OracleFeeSourceUnavailableError` in `pyth.ts`)
25
- * so a consumer can branch on the failure type directly instead of
26
- * string-matching `error.message`.
22
+ * deployed in config but no `pythApiKey` was supplied at client init the
23
+ * Lazer HTTP API requires a Bearer token and the SDK never reads
24
+ * `process.env` to find one. `instanceof`-able (mirrors
25
+ * `OracleFeeSourceUnavailableError` in `pyth.ts`) so a consumer can branch on
26
+ * the failure type directly instead of string-matching `error.message`.
27
27
  */
28
28
  export declare class LazerApiKeyMissingError extends Error {
29
29
  constructor();
@@ -3,14 +3,14 @@
3
3
  * updates, plus `feedLazerRule`, the collector-feed leg `aggregateTicker`
4
4
  * appends per lazer-routed ticker. Fetches one `leEcdsa` payload for all
5
5
  * requested integer feed ids from the Lazer HTTP API (Bearer-authenticated
6
- * via `config.pyth.api_key`), verifies it ONCE on-chain via
6
+ * via the `pythApiKey` create option), verifies it ONCE on-chain via
7
7
  * `pyth_lazer::parse_and_verify_le_ecdsa_update`, and hands the resulting
8
8
  * `Update` PTB value back through a `RuleUpdateHandle` for the feed calls.
9
9
  */
10
10
  import { fromHex } from "@mysten/bcs";
11
11
  import { LAZER_DEFAULTS } from "../config.js";
12
12
  import { assertRuleUpdateData, } from "../price-update-rule.js";
13
- import { FetchPolicyError, fetchWithPolicy } from "../update-fetch.js";
13
+ import { fetchWithPolicy, joinEndpointPath, rethrowExhaustedFetch } from "../update-fetch.js";
14
14
  /**
15
15
  * Signed-update request pins, mirroring what the on-chain rule consumes:
16
16
  * - `properties` — `price` + `exponent` are REQUIRED by
@@ -18,10 +18,17 @@ import { FetchPolicyError, fetchWithPolicy } from "../update-fetch.js";
18
18
  * `confidence` is optional on-chain but requested so the rule's
19
19
  * fail-closed confidence gate actually engages (a payload without
20
20
  * confidence passes the gate unchecked).
21
- * - `channel` — `real_time`: the deployed rule binds the v1 Lazer API, whose
22
- * `channel::from_u8` aborts on the 1000ms fixed-rate channel; real_time /
23
- * 50ms / 200ms are the safe subscriptions, and for an on-demand pull
24
- * real_time is the freshest.
21
+ * - `channel` — `fixed_rate@200ms`, NOT `real_time`: Lazer rejects a request
22
+ * whose channel is faster than ANY requested feed's `min_channel`, and it
23
+ * rejects the WHOLE batch (`400 Feeds do not support channel …`). Only the
24
+ * majors (BTC/ETH/SOL/USDC/DOGE/XRP/BNB/HYPE + EUR/JPY FX) publish
25
+ * `real_time`; the other 19 of the 29 configured feeds — including SUIUSD
26
+ * and every xStock — are `min_channel: fixed_rate@200ms` (Lazer symbol
27
+ * registry, verified 2026-07-22: the same 29-feed batch 400s at
28
+ * `real_time`/`50ms` and serves 200 with the leEcdsa blob at `200ms`).
29
+ * 200ms is the fastest channel every configured feed supports, and the
30
+ * deployed rule accepts it: the v1 on-chain `channel::from_u8` aborts only
31
+ * on the 1000ms fixed-rate channel (real_time / 50ms / 200ms are safe).
25
32
  * - `formats: leEcdsa` + `jsonBinaryEncoding: hex` — the Sui verifier takes
26
33
  * the `leEcdsa` framing; hex matches `fromHex` below.
27
34
  */
@@ -29,7 +36,7 @@ const LAZER_LATEST_PRICE_REQUEST = {
29
36
  properties: ["price", "exponent", "confidence"],
30
37
  formats: ["leEcdsa"],
31
38
  jsonBinaryEncoding: "hex",
32
- channel: "real_time",
39
+ channel: "fixed_rate@200ms",
33
40
  };
34
41
  /**
35
42
  * Shape check ONLY — the `kind` discriminant is checked separately by the
@@ -44,16 +51,16 @@ function isPythLazerUpdatePayloadShape(payload) {
44
51
  }
45
52
  /**
46
53
  * Thrown by {@link PythLazerRule.fetchUpdateData} when `pyth_lazer_rule` is
47
- * deployed in config but no `pyth.api_key` is set the Lazer HTTP API
48
- * requires a Bearer token and the SDK never reads `process.env` to find one.
49
- * `instanceof`-able (mirrors `OracleFeeSourceUnavailableError` in `pyth.ts`)
50
- * so a consumer can branch on the failure type directly instead of
51
- * string-matching `error.message`.
54
+ * deployed in config but no `pythApiKey` was supplied at client init the
55
+ * Lazer HTTP API requires a Bearer token and the SDK never reads
56
+ * `process.env` to find one. `instanceof`-able (mirrors
57
+ * `OracleFeeSourceUnavailableError` in `pyth.ts`) so a consumer can branch on
58
+ * the failure type directly instead of string-matching `error.message`.
52
59
  */
53
60
  export class LazerApiKeyMissingError extends Error {
54
61
  constructor() {
55
62
  super("LazerApiKeyMissing: pyth_lazer_rule requires a Pyth Lazer access token — " +
56
- "set `pyth.api_key` in the client config (the SDK never reads process.env)");
63
+ "pass `pythApiKey` when creating the client (the SDK never reads process.env)");
57
64
  this.name = "LazerApiKeyMissingError";
58
65
  }
59
66
  }
@@ -72,7 +79,11 @@ function requireLazerPackage(host) {
72
79
  * both oracle sources fail the same way under upstream degradation.
73
80
  */
74
81
  async function fetchLazerSignedUpdate(endpoint, apiKey, feedIds, fetchOpts) {
75
- const url = new URL("/v1/latest_price", endpoint);
82
+ // joinEndpointPath preserves any base path on the endpoint — the same
83
+ // leading-slash `new URL` footgun that 404'd every feed on the Pyth Pro
84
+ // Hermes endpoint (see update-fetch.ts). Defensive here: the default
85
+ // Lazer endpoint has no base path, but a config override may.
86
+ const url = joinEndpointPath(endpoint, "v1/latest_price");
76
87
  let res;
77
88
  try {
78
89
  res = await fetchWithPolicy(url.toString(), {
@@ -82,15 +93,7 @@ async function fetchLazerSignedUpdate(endpoint, apiKey, feedIds, fetchOpts) {
82
93
  }, { apiKey, ...fetchOpts });
83
94
  }
84
95
  catch (err) {
85
- // Mirrors fetchPriceFeedsUpdateData's reframing: a retryable status that
86
- // never recovered carries `status` on the FetchPolicyError — reformat
87
- // into this function's own message shape; a network-level exhaustion
88
- // (no status) propagates as-is.
89
- if (err instanceof FetchPolicyError && err.status !== undefined) {
90
- const body = err.bodySnippet ? ` ${err.bodySnippet}` : "";
91
- throw new Error(`Lazer price fetch failed: ${err.status}${body} (retries exhausted after ${err.attempts} attempts)`, { cause: err });
92
- }
93
- throw err;
96
+ rethrowExhaustedFetch(err, (e) => `Lazer price fetch failed: ${e.status}${e.bodySnippet ? ` ${e.bodySnippet}` : ""}`);
94
97
  }
95
98
  if (!res.ok)
96
99
  throw new Error(`Lazer price fetch failed: ${res.status} ${await res.text()}`);
@@ -13,6 +13,11 @@ export function feedPythRule(tx, host, collector, priceInfoObjectId) {
13
13
  arguments: {
14
14
  collector,
15
15
  config: tx.object(host.config.packages.pyth_rule.config),
16
+ // The deployed pyth_rule package is compiled against the Core pyth
17
+ // dependency, so its `&PythState` parameter is the Core-package-qualified
18
+ // type and `host.pyth` (the fixed per-network Core infra) is always the
19
+ // right state to pass. The config's price_info_object entries are Core
20
+ // objects to match.
16
21
  pythState: tx.object(host.pyth.state_id),
17
22
  pythPriceInfo: tx.object(priceInfoObjectId),
18
23
  },