@waterx/sdk 4.1.0 → 4.3.0

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 (66) hide show
  1. package/README.md +22 -17
  2. package/dist/cjs/src/account/config.d.ts +3 -2
  3. package/dist/cjs/src/generated/waterx_rule/waterx_rule.d.ts +7 -134
  4. package/dist/cjs/src/generated/waterx_rule/waterx_rule.js +8 -154
  5. package/dist/cjs/src/oracle/aggregate.d.ts +21 -21
  6. package/dist/cjs/src/oracle/aggregate.js +118 -69
  7. package/dist/cjs/src/oracle/config.d.ts +38 -72
  8. package/dist/cjs/src/oracle/config.js +0 -56
  9. package/dist/cjs/src/oracle/host.d.ts +24 -20
  10. package/dist/cjs/src/oracle/index.d.ts +5 -3
  11. package/dist/cjs/src/oracle/index.js +31 -8
  12. package/dist/cjs/src/oracle/price-update-rule.d.ts +13 -6
  13. package/dist/cjs/src/oracle/price-update-rule.js +3 -3
  14. package/dist/cjs/src/oracle/pyth.d.ts +44 -0
  15. package/dist/cjs/src/oracle/pyth.js +48 -6
  16. package/dist/cjs/src/oracle/read-plane.d.ts +70 -0
  17. package/dist/cjs/src/oracle/read-plane.js +78 -0
  18. package/dist/cjs/src/oracle/rule-registry.d.ts +6 -4
  19. package/dist/cjs/src/oracle/rule-registry.js +6 -4
  20. package/dist/cjs/src/oracle/rules/pyth-core-rule.js +4 -4
  21. package/dist/cjs/src/oracle/rules/pyth-lazer-rule.d.ts +21 -0
  22. package/dist/cjs/src/oracle/rules/pyth-lazer-rule.js +29 -4
  23. package/dist/cjs/src/oracle/rules/pyth-rule.js +5 -4
  24. package/dist/cjs/src/oracle/rules/waterx-rule.d.ts +42 -5
  25. package/dist/cjs/src/oracle/rules/waterx-rule.js +57 -17
  26. package/dist/cjs/src/perp/client.d.ts +40 -27
  27. package/dist/cjs/src/perp/client.js +28 -20
  28. package/dist/cjs/src/perp/config.d.ts +6 -6
  29. package/dist/cjs/src/perp/config.js +12 -12
  30. package/dist/cjs/src/perp/index.d.ts +3 -3
  31. package/dist/cjs/src/perp/index.js +7 -4
  32. package/dist/cjs/src/unified-client.d.ts +14 -9
  33. package/dist/cjs/src/unified-client.js +2 -2
  34. package/dist/src/account/config.d.ts +3 -2
  35. package/dist/src/generated/waterx_rule/waterx_rule.d.ts +7 -134
  36. package/dist/src/generated/waterx_rule/waterx_rule.js +7 -143
  37. package/dist/src/oracle/aggregate.d.ts +21 -21
  38. package/dist/src/oracle/aggregate.js +118 -69
  39. package/dist/src/oracle/config.d.ts +38 -72
  40. package/dist/src/oracle/config.js +1 -55
  41. package/dist/src/oracle/host.d.ts +24 -20
  42. package/dist/src/oracle/index.d.ts +5 -3
  43. package/dist/src/oracle/index.js +25 -10
  44. package/dist/src/oracle/price-update-rule.d.ts +13 -6
  45. package/dist/src/oracle/price-update-rule.js +3 -3
  46. package/dist/src/oracle/pyth.d.ts +44 -0
  47. package/dist/src/oracle/pyth.js +45 -5
  48. package/dist/src/oracle/read-plane.d.ts +70 -0
  49. package/dist/src/oracle/read-plane.js +74 -0
  50. package/dist/src/oracle/rule-registry.d.ts +6 -4
  51. package/dist/src/oracle/rule-registry.js +6 -4
  52. package/dist/src/oracle/rules/pyth-core-rule.js +5 -5
  53. package/dist/src/oracle/rules/pyth-lazer-rule.d.ts +21 -0
  54. package/dist/src/oracle/rules/pyth-lazer-rule.js +28 -3
  55. package/dist/src/oracle/rules/pyth-rule.js +5 -4
  56. package/dist/src/oracle/rules/waterx-rule.d.ts +42 -5
  57. package/dist/src/oracle/rules/waterx-rule.js +53 -14
  58. package/dist/src/perp/client.d.ts +40 -27
  59. package/dist/src/perp/client.js +29 -21
  60. package/dist/src/perp/config.d.ts +6 -6
  61. package/dist/src/perp/config.js +11 -9
  62. package/dist/src/perp/index.d.ts +3 -3
  63. package/dist/src/perp/index.js +2 -2
  64. package/dist/src/unified-client.d.ts +14 -9
  65. package/dist/src/unified-client.js +2 -2
  66. package/package.json +1 -1
@@ -19,7 +19,8 @@
19
19
  *
20
20
  * `refreshOraclePrices` additionally routes the on-chain price *update* leg
21
21
  * (the fetch + verify/push step, before any of the above feeding) through the
22
- * `PriceUpdateRule` selected by `host.oracleSource` — see `rule-registry.ts`.
22
+ * `PriceUpdateRule` of EVERY source in the `host.oracleSources` fed set — see
23
+ * `rule-registry.ts`.
23
24
  */
24
25
  import { aggregate as aggregateCall, newCollector } from "../generated/waterx_oracle/oracle.js";
25
26
  import { OracleFeeSourceUnavailableError } from "./pyth.js";
@@ -115,7 +116,13 @@ export function aggregateTicker(tx, host, args) {
115
116
  // waterx_rule::collect_batch_latest verifies the batch signature and feeds
116
117
  // this collector's symbol from the batch. If the ticker's aggregator does
117
118
  // not (yet) weight `WaterxRule`, the contribution is silently dropped
118
- // on-chain — feeding ahead of the weight migration is harmless.
119
+ // on-chain — feeding ahead of the weight migration is safe for THIS tx.
120
+ // CAVEAT (unlike lazer): the feed call records a per-symbol signed-
121
+ // timestamp high-water mark REGARDLESS of weights, and a replayed
122
+ // timestamp ABORTS (`EReplayedSignature`, audit F-014) — so two PTBs
123
+ // carrying the same envelope for the same symbol cannot both land; the
124
+ // second aborts even where waterx is unweighted. See WaterxRule's module
125
+ // header.
119
126
  feedWaterxRule(tx, host, collector, args.waterxEnvelope);
120
127
  fed = true;
121
128
  }
@@ -167,27 +174,26 @@ export function aggregateTickerWithConstant(tx, host, args) {
167
174
  * entry, Lazer if the lazer update leg served it — see below — Supra when
168
175
  * enabled, Constant when it's a constant ticker).
169
176
  *
170
- * Before that, the on-chain price *update* leg is routed by `host.oracleSource`
171
- * (see `rule-registry.ts`): the ONE selected source serves every ticker in its
172
- * `supportedTickers(host)`. There is **no cross-source fallback** — a requested
173
- * ticker the selected source does not serve, and that is not a constant-only
174
- * ticker (which needs no price-update leg), fails the build immediately with a
175
- * clear error naming the ticker and source. That is the deliberate "fail the
176
- * tx-build, don't silently reroute" contract: a wrong-but-present feed id is
177
- * NOT validated here (it surfaces on-chain at dry-run); a MISSING feed for the
178
- * selected source is caught here. When the selected source's feed exists but is
179
- * wrong, this function does nothing special — the on-chain aggregate aborts at
180
- * dry-run, which is correct.
177
+ * Before that, the on-chain price *update* leg is routed by the
178
+ * `host.oracleSources` fed set (see `rule-registry.ts`): EVERY listed source
179
+ * updates the tickers its own `supportedTickers(host)` serves, all in this one
180
+ * PTB. There is **no cross-source fallback** a requested ticker NO listed
181
+ * source serves, and that is not a constant-only ticker (which needs no
182
+ * price-update leg), fails the build immediately with a clear error naming
183
+ * the ticker and the list. That is the deliberate "fail the tx-build, don't
184
+ * silently reroute" contract: a wrong-but-present feed id is NOT validated
185
+ * here (it surfaces on-chain at dry-run); a ticker MISSING from every listed
186
+ * source's feeds is caught here.
181
187
  *
182
- * The selected source's fetch + build runs against its own infra, guaranteeing
183
- * per-rule PTB atomicity. A fee-source pre-check runs first (the source's
184
- * `requiresFeeSource`) BEFORE any off-chain fetch or PTB mutation — so a
185
- * fee-charging source with no `opts.feeSource` throws
188
+ * Each source's fetch + build runs against its own infra, guaranteeing
189
+ * per-rule PTB atomicity. A fee-source pre-check runs first (any listed
190
+ * source's `requiresFeeSource`) BEFORE any off-chain fetch or PTB mutation —
191
+ * so a fee-charging source with no `opts.feeSource` throws
186
192
  * `OracleFeeSourceUnavailable` with zero wasted network calls and zero stray
187
- * moveCalls. Only once that check passes does the off-chain fetch run and
188
- * complete before any PTB mutation; on-chain reads inside `buildUpdateCalls`
189
- * can still fail mid-append for other reasons — callers discard the tx on any
190
- * throw.
193
+ * moveCalls. Only once that check passes do the off-chain fetches run — in
194
+ * parallel across sources — and ALL settle before the first PTB mutation;
195
+ * on-chain reads inside `buildUpdateCalls` can still fail mid-append for
196
+ * other reasons — callers discard the tx on any throw.
191
197
  *
192
198
  * **Collector-feed leg is rule-aware:** a lazer-served group's
193
199
  * `buildUpdateCalls` returns the verified `Update` PTB value
@@ -208,78 +214,121 @@ export function aggregateTickerWithConstant(tx, host, args) {
208
214
  export async function refreshOraclePrices(tx, host, tickers, opts = {}) {
209
215
  if (tickers.length === 0)
210
216
  return;
217
+ // Dedupe the caller's list (order-preserving): a repeated ticker would
218
+ // otherwise aggregate TWICE in this one PTB — wasted gas for every rule,
219
+ // and a hard ABORT under waterx: the second `collect_batch_latest` carries
220
+ // the same envelope, and the on-chain per-symbol replay guard rejects an
221
+ // already-accepted signed timestamp (`EReplayedSignature`, F-014) even
222
+ // inside a single transaction.
223
+ tickers = [...new Set(tickers)];
211
224
  // price_info_object lookup for every ticker with a pyth_rule.feeds entry —
212
225
  // needed by aggregateTicker's (unchanged) Pyth feed step below regardless of
213
226
  // which rule performed the on-chain update for that ticker.
214
227
  const pythTickers = tickers.filter((t) => host.config.packages.pyth_rule?.feeds?.[t] !== undefined);
215
228
  const priceInfoByTicker = new Map();
216
229
  pythTickers.forEach((t) => priceInfoByTicker.set(t, host.getPythFeed(t).price_info_object));
217
- // ONE source, no fallback. The selected source serves the tickers in its
218
- // `supportedTickers(host)`; `source` is tracked alongside the group (rather
219
- // than read back off `rule.kind`, typed as the broader PriceUpdateRuleKind)
220
- // so the provider lookup below has an OracleSource to key on without a cast.
221
- const selectedRule = resolveOracleRule(host.oracleSource, opts.ruleOverrides);
222
- const selectedSupported = new Set(selectedRule.supportedTickers(host));
223
- // Fail the tx-build (NOT client init, NOT a silent reroute) when the selected
224
- // source has no feed for a requested ticker that actually needs a price
230
+ // The fed set is a LIST (`host.oracleSources`, normalized + deduped at
231
+ // client creation): ONE build carries every listed source's data, and the
232
+ // chain's per-ticker weight tables decide which contributions count
233
+ // feeding an unweighted rule's PRICE is dropped on-chain, while starving a
234
+ // weighted one aborts. That asymmetry is what makes weight migrations
235
+ // safe: flip weights per ticker at any time while the fed set stays a
236
+ // superset of every ticker's weighted set. (One caveat: waterx's feed call
237
+ // burns a per-symbol signed-timestamp high-water mark regardless of
238
+ // weights — see aggregateTicker's waterx branch.) Still NO fallback
239
+ // BETWEEN sources: each group serves only the tickers its own feeds list.
240
+ // Zero-ticker groups are dropped here so everything downstream (fee check,
241
+ // fetch fan-out, update-leg build) can assume every group has work.
242
+ const groups = host.oracleSources
243
+ .map((source) => {
244
+ const rule = resolveOracleRule(source, opts.ruleOverrides);
245
+ const supported = new Set(rule.supportedTickers(host));
246
+ return { source, rule, tickers: tickers.filter((t) => supported.has(t)) };
247
+ })
248
+ .filter((group) => group.tickers.length > 0);
249
+ // Fail the tx-build (NOT client init, NOT a silent reroute) when NO listed
250
+ // source has a feed for a requested ticker that actually needs a price
225
251
  // update. Only a CONSTANT-ONLY ticker is exempt — priced entirely by
226
252
  // `constant_rule`, it needs no update leg from any source. A DUAL-FEED ticker
227
253
  // (constant AND pyth) still needs its Pyth leg refreshed, so `isConstantTicker`
228
- // alone must NOT exempt it: under a source that can't serve it, with no
229
- // fallback, feeding an unrefreshed Pyth leg would price it stale (or abort on a
230
- // missing weighted source). `priceInfoByTicker.has(t)` ⇔ the ticker has a
231
- // `pyth_rule.feeds` entry, so `constant && !hasPyth` is exactly constant-only.
232
- // This catches a MISSING feed; a present-but-WRONG feed id is deliberately not
233
- // validated here (it aborts on-chain at dry-run).
254
+ // alone must NOT exempt it: with no source able to serve it, feeding an
255
+ // unrefreshed Pyth leg would price it stale (or abort on a missing weighted
256
+ // source). `priceInfoByTicker.has(t)` ⇔ the ticker has a `pyth_rule.feeds`
257
+ // entry, so `constant && !hasPyth` is exactly constant-only. This catches a
258
+ // MISSING feed; a present-but-WRONG feed id is deliberately not validated
259
+ // here (it aborts on-chain at dry-run).
260
+ const covered = new Set(groups.flatMap((group) => group.tickers));
234
261
  const isConstantOnly = (t) => host.isConstantTicker(t) && !priceInfoByTicker.has(t);
235
- const unservable = tickers.filter((t) => !selectedSupported.has(t) && !isConstantOnly(t));
262
+ const unservable = tickers.filter((t) => !covered.has(t) && !isConstantOnly(t));
236
263
  if (unservable.length > 0) {
237
- throw new Error(`oracleSource '${host.oracleSource}' has no feed configured for ticker(s): ` +
264
+ const sources = host.oracleSources.join(", ");
265
+ throw new Error(`oracleSource [${sources}] has no feed configured for ticker(s): ` +
238
266
  `${unservable.join(", ")}. Sources are self-contained with no fallback — add ` +
239
- `${host.oracleSource} feeds for them, or select a source that serves them.`);
267
+ `feeds for them under a listed source, or list a source that serves them.`);
240
268
  }
241
- const selectedGroup = tickers.filter((t) => selectedSupported.has(t));
242
- // Fee-source pre-check, hoisted ABOVE the off-chain fetch and PTB build below.
243
- // It consults only `rule.requiresFeeSource` known before any fetch or PTB
244
- // mutation — so a fee-charging source with no `feeSource` throws with ZERO
245
- // wasted network calls and zero PTB commands, rather than waiting for
246
- // `buildPythPriceUpdateCalls`'s own per-call guard to fire after the off-chain
247
- // fetch already ran.
248
- if (selectedGroup.length > 0 && !opts.feeSource && selectedRule.requiresFeeSource) {
269
+ // Fee-source pre-check, hoisted ABOVE the off-chain fetches and PTB build
270
+ // below. It consults only `rule.requiresFeeSource` known before any fetch
271
+ // or PTB mutationso a fee-charging source (Pyth Core) in the fed set with
272
+ // no `feeSource` throws with ZERO wasted network calls and zero PTB
273
+ // commands, rather than waiting for `buildPythPriceUpdateCalls`'s own
274
+ // per-call guard to fire after the off-chain fetches already ran.
275
+ if (!opts.feeSource && groups.some((group) => group.rule.requiresFeeSource)) {
249
276
  throw new OracleFeeSourceUnavailableError();
250
277
  }
251
- // Resolve + build the selected source's update leg. The off-chain fetch
252
- // settles before the first PTB mutation, so a fetch failure never strands
253
- // moveCalls in a caller-owned tx. Map each lazer-served ticker to the one
254
- // verified `Update` PTB value for the collector-feed leg below.
278
+ // Phase 1 resolve every group's update data IN PARALLEL: the per-source
279
+ // fetches (Hermes VAA / Lazer POST / quote-center GET) are independent
280
+ // network calls on the tx-build money path, so a multi-source fed set must
281
+ // not pay one RTT per source sequentially. ALL fetches settle before the
282
+ // first PTB mutation below, so a fetch failure never strands moveCalls in a
283
+ // caller-owned tx — and a failure in ANY group fails the whole build (a
284
+ // listed source is load-bearing; silently building without it would starve
285
+ // its weighted tickers on-chain).
286
+ const dataByGroup = await Promise.all(groups.map((group) => resolveGroupUpdateData(host, group, opts.updateDataProvider)));
287
+ // Phase 2 — build each group's update leg sequentially, in list order, so
288
+ // PTB command order stays deterministic. The carry step below is an
289
+ // exhaustive switch over the group's rule kind: a future source whose feed
290
+ // leg needs per-ticker data from its update leg must decide its carry here
291
+ // — falling through silently would starve its weighted tickers on-chain.
255
292
  const lazerUpdateByTicker = new Map();
256
293
  // Signed batch envelope per waterx-served ticker. Unlike Lazer's shared PTB
257
294
  // handle, waterx's verify+feed is bundled into `collect_batch_latest` in the
258
295
  // per-ticker feed leg, so its `buildUpdateCalls` emits nothing and the
259
296
  // envelope is carried straight from the group's fetched data.
260
297
  const waterxEnvelopeByTicker = new Map();
261
- if (selectedGroup.length > 0) {
262
- const group = { source: host.oracleSource, rule: selectedRule, tickers: selectedGroup };
263
- const data = await resolveGroupUpdateData(host, group, opts.updateDataProvider);
264
- const handle = (await selectedRule.buildUpdateCalls(tx, host, data, {
298
+ for (const [i, group] of groups.entries()) {
299
+ const data = dataByGroup[i] ?? null;
300
+ const handle = (await group.rule.buildUpdateCalls(tx, host, data, {
265
301
  cache: opts.cache,
266
302
  feeSource: opts.feeSource,
267
303
  })) ?? undefined;
268
- // Route by the handle's kind discriminant — the one site the tag exists to
269
- // protect: a future non-lazer handle must never be silently fed into
270
- // pyth_lazer_rule::feed.
271
- if (handle?.kind === "pyth_lazer_rule") {
272
- for (const ticker of selectedGroup)
273
- lazerUpdateByTicker.set(ticker, handle.update);
274
- }
275
- // waterx_rule emits no shared handle (verify+feed is bundled into the
276
- // per-ticker `collect_batch_latest`), so the envelope is carried straight
277
- // from this group's fetched data to the feed leg below.
278
- if (selectedRule.kind === "waterx_rule") {
279
- const envelope = waterxEnvelopeOf(data);
280
- if (envelope) {
281
- for (const ticker of selectedGroup)
282
- waterxEnvelopeByTicker.set(ticker, envelope);
304
+ switch (group.rule.kind) {
305
+ case "pyth_rule":
306
+ // Core's update leg wrote the PriceInfoObjects in place — the feed
307
+ // leg reads them by id (`priceInfoByTicker`), nothing to carry.
308
+ break;
309
+ case "pyth_lazer_rule":
310
+ // Route by the handle's kind discriminant — the tag exists so a
311
+ // non-lazer handle can never be silently fed into
312
+ // pyth_lazer_rule::feed.
313
+ if (handle?.kind === "pyth_lazer_rule") {
314
+ for (const ticker of group.tickers)
315
+ lazerUpdateByTicker.set(ticker, handle.update);
316
+ }
317
+ break;
318
+ case "waterx_rule": {
319
+ // waterx_rule emits no shared handle (verify+feed is bundled into the
320
+ // per-ticker `collect_batch_latest`), so the envelope is carried
321
+ // straight from this group's fetched data to the feed leg below.
322
+ const envelope = waterxEnvelopeOf(data);
323
+ if (envelope) {
324
+ for (const ticker of group.tickers)
325
+ waterxEnvelopeByTicker.set(ticker, envelope);
326
+ }
327
+ break;
328
+ }
329
+ default: {
330
+ const exhausted = group.rule.kind;
331
+ throw new Error(`refreshOraclePrices: unhandled rule kind '${String(exhausted)}'`);
283
332
  }
284
333
  }
285
334
  }
@@ -11,7 +11,6 @@
11
11
  */
12
12
  import type { BasePackageEntry } from "../account/config.ts";
13
13
  import type { BaseLineConfig } from "../base-client.ts";
14
- import type { Network } from "../constants.ts";
15
14
  import type { FetchPolicy } from "./update-fetch.ts";
16
15
  export interface PythRulePackage extends BasePackageEntry {
17
16
  config: string;
@@ -31,7 +30,7 @@ export interface PythSponsorRulePackage extends BasePackageEntry {
31
30
  *
32
31
  * `enabled` mirrors the JSON field verbatim but MUST NOT be read for routing —
33
32
  * which rule prices a ticker is decided solely by the client's `oracleSource`
34
- * create option (see `OracleHost.oracleSource`), never by this flag or any
33
+ * create option (see `OracleHost.oracleSources`), never by this flag or any
35
34
  * other config value.
36
35
  */
37
36
  export interface PythLazerRulePackage extends BasePackageEntry {
@@ -99,11 +98,12 @@ export interface WaterxRuleFeedEntry {
99
98
  * Read by `WaterxRule` (`rules/waterx-rule.ts`): `feeds` for ticker support,
100
99
  * `config`/`enclave_config`/`enclave` for the `collect_batch_latest` call,
101
100
  * `published_at` for the package address. The off-chain signed price is pulled
102
- * from the quote-center (endpoint from {@link WATERX_DEFAULTS}), not this JSON.
101
+ * from the quote-center (endpoint from the rule-owned `WATERX_INFRA` table in
102
+ * `rules/waterx-rule.ts`), not this JSON.
103
103
  *
104
104
  * `enabled` mirrors the JSON field verbatim but MUST NOT be read for routing —
105
105
  * which rule prices a ticker is decided solely by the client's `oracleSource`
106
- * create option (see `OracleHost.oracleSource`), mirroring `pyth_lazer_rule`.
106
+ * create option (see `OracleHost.oracleSources`), mirroring `pyth_lazer_rule`.
107
107
  */
108
108
  export interface WaterxRulePackage extends BasePackageEntry {
109
109
  /** Shared `waterx_rule::Config` (per-symbol on-chain feed_config). */
@@ -137,17 +137,6 @@ export interface OraclePackages {
137
137
  waterx_rule?: WaterxRulePackage;
138
138
  waterx_oracle: WaterxOraclePackage;
139
139
  }
140
- /**
141
- * Resolved Pyth Core infra as it lives on `client.pyth` — NOT a config-JSON
142
- * shape. `state_id` / `wormhole_state_id` / `hermes_endpoint` come verbatim
143
- * from the fixed per-network constant ({@link PYTH_DEFAULTS}); `api_key` /
144
- * `fetch` are layered on from the caller's `pythApiKey` / `pythFetch` create
145
- * options. None of it is sourced from the canonical `waterx-config` JSON — the
146
- * SDK never reads a `pyth` block there (a Bearer secret has no place in a
147
- * public CDN document). The infra is the same for every `oracleSource`; the
148
- * `pyth_lazer_rule` source reads only the `api_key` / `fetch` from here and
149
- * gets its on-chain infra from {@link LAZER_DEFAULTS} + config instead.
150
- */
151
140
  /**
152
141
  * The caller-tunable subset of `fetchWithPolicy`'s policy exposed on the
153
142
  * `pythFetch` create option and `client.pyth.fetch` — the retry/timeout budget
@@ -160,18 +149,24 @@ export type PythFetchPolicy = {
160
149
  timeoutMs?: number;
161
150
  retries?: number;
162
151
  };
163
- export interface PythInfraConfig {
164
- state_id: string;
165
- wormhole_state_id: string;
166
- hermes_endpoint: string;
152
+ /**
153
+ * `client.pyth` — ONLY the caller-supplied Pyth credential + fetch policy,
154
+ * shared by the Pyth-family rules (`pyth_rule`, `pyth_lazer_rule`). It carries
155
+ * NO endpoints and NO on-chain object ids: every oracle source owns its own
156
+ * infra, co-located with its rule (`PYTH_CORE_INFRA` in `oracle/pyth.ts`;
157
+ * the Lazer constants inside `rules/pyth-lazer-rule.ts`). A non-Pyth source
158
+ * never reads this slice.
159
+ * Nothing here is sourced from the canonical `waterx-config` JSON — a Bearer
160
+ * secret has no place in a public CDN document.
161
+ */
162
+ export interface PythAccessConfig {
167
163
  /**
168
- * Pyth Pro / Lazer access token (`Authorization: Bearer …`) for
164
+ * Pyth access token (`Authorization: Bearer …`). Required by
169
165
  * `PythLazerRule`'s signed-update fetch — Lazer is auth-first, so there is
170
- * no keyless default. Optional: Pyth-Core-only deployments never need it.
171
- * Supplied via the `pythApiKey` create option (the SDK never reads
172
- * `process.env` or the config JSON). Absent when a lazer-routed fetch runs →
173
- * `LazerApiKeyMissing` is thrown at fetch time. As of the Pyth Pro
174
- * migration (post-2026-08-18, per
166
+ * no keyless default; absent when a lazer-routed fetch runs
167
+ * `LazerApiKeyMissing` is thrown at fetch time. Supplied via the
168
+ * `pythApiKey` create option (the SDK never reads `process.env` or the
169
+ * config JSON). As of the Pyth Pro migration (post-2026-08-18, per
175
170
  * https://docs.pyth.network/price-feeds/core/upgrade) this is ALSO required
176
171
  * for `pyth_rule`'s Hermes fetch (`fetchPriceFeedsUpdateData`) — see
177
172
  * `fetch` below.
@@ -187,66 +182,37 @@ export interface PythInfraConfig {
187
182
  */
188
183
  fetch?: PythFetchPolicy;
189
184
  }
190
- export declare const PYTH_DEFAULTS: Record<Network, PythInfraConfig>;
191
- /**
192
- * Pyth Lazer (Pyth Pro) external infra the `PythLazerRule` needs, by network.
193
- * Mirrors {@link PYTH_DEFAULTS}: per-network constants for infrastructure Pyth
194
- * operates (not part of the `waterx-config` JSON). A fuller `PYTH_INFRA`
195
- * restructure is deferred — this stays a minimal map until then.
196
- *
197
- * - `endpoint` — Lazer HTTP API base; signed updates come from
198
- * `POST /v1/latest_price` (Bearer-authenticated). The service is
199
- * network-agnostic (one signed payload verifies on any chain that trusts the
200
- * Lazer signers), so both networks share the production host.
201
- * - `verifier_package` — the Sui package carrying
202
- * `pyth_lazer::parse_and_verify_le_ecdsa_update`. Per-network: testnet is
203
- * still the original v1 publish; mainnet is the v2-upgraded package (which
204
- * still exposes the v1 entry `pyth_lazer_rule` binds). Values mirror the
205
- * contract repo's `pyth_lazer_rule/Move.toml` published-at pins.
206
- */
207
- export declare const LAZER_DEFAULTS: Record<Network, {
208
- endpoint: string;
209
- verifier_package: string;
210
- }>;
211
185
  /**
212
- * Resolved WaterX quote-center infra for `WaterxRule` — the network default
213
- * from {@link WATERX_DEFAULTS}, with both fields overridable at client init
214
- * (`waterxEndpoint` / `waterxFetch`).
186
+ * `client.waterx` ONLY the caller-supplied quote-center overrides for
187
+ * `WaterxRule`, mirroring {@link PythAccessConfig}: no resolved infra lives on
188
+ * the client. When a field is unset the rule resolves it against its OWN
189
+ * per-network table (`WATERX_INFRA` in `rules/waterx-rule.ts`) — no other
190
+ * source's endpoint or policy is ever consulted.
215
191
  *
216
- * The override exists because this is the one oracle source a BROWSER fetches
217
- * itself: the rule pulls the signed envelope from the page, so it is subject to
218
- * the quote-center deployment's CORS allowlist. A front end whose origin is not
219
- * on that list — or one that must route egress through its own backend — points
220
- * `endpoint` at a same-origin proxy (or supplies `fetch.fetchImpl`) instead of
221
- * being locked to the hardcoded host.
192
+ * The endpoint override exists because this is the one oracle source a BROWSER
193
+ * fetches itself: the rule pulls the signed envelope from the page, so it is
194
+ * subject to the quote-center deployment's CORS allowlist. A front end whose
195
+ * origin is not on that list — or one that must route egress through its own
196
+ * backend — points `endpoint` at a same-origin proxy (or supplies
197
+ * `fetch.fetchImpl`) instead of being locked to the default host.
222
198
  */
223
- export interface WaterxInfraConfig {
199
+ export interface WaterxAccessConfig {
224
200
  /**
225
- * Quote-center base URL. A base PATH is preserved — the rule appends via
226
- * `joinEndpointPath`, so `https://app.example/api/quote-center` resolves to
201
+ * Quote-center base URL override (`waterxEndpoint` create option). A base
202
+ * PATH is preserved — the rule appends via `joinEndpointPath`, so
203
+ * `https://app.example/api/quote-center` resolves to
227
204
  * `…/api/quote-center/v1/quotes/update` and a proxy route is not rewritten
228
205
  * away. A trailing slash is trimmed.
229
206
  */
230
- endpoint: string;
207
+ endpoint?: string;
231
208
  /**
232
209
  * Retry/timeout policy (and `fetchImpl`) for the quote-center fetch — see
233
210
  * `fetchWithPolicy` (`./update-fetch.ts`). Supplied via the `waterxFetch`
234
- * create option. When unset the rule falls back to the shared `pyth.fetch`
235
- * policy, then to `fetchWithPolicy`'s defaults (15s timeout, 2 retries).
211
+ * create option. Falls back to `fetchWithPolicy`'s built-in defaults (15s
212
+ * timeout, 2 retries) when unset never to another source's policy.
236
213
  */
237
214
  fetch?: FetchPolicy;
238
215
  }
239
- /**
240
- * WaterX quote-center base URL by network — the first-party TEE-signed price
241
- * hub `WaterxRule` pulls from (`GET /v1/quotes/update?symbols=…`). Mirrors
242
- * {@link LAZER_DEFAULTS}: infra WaterX operates, not part of the `waterx-config`
243
- * JSON. Public read (no auth), so there is no api_key. `endpoint` has no
244
- * trailing slash — the rule appends the path.
245
- *
246
- * These are DEFAULTS, not a hard pin: a consumer overrides them per client via
247
- * `waterxEndpoint` / `waterxFetch` (resolved onto `client.waterx`).
248
- */
249
- export declare const WATERX_DEFAULTS: Record<Network, WaterxInfraConfig>;
250
216
  /**
251
217
  * The narrow config shape the oracle/refresh code needs. `WaterXConfig`
252
218
  * (the perp line's full config) is assignable to this, so `PerpClient` satisfies
@@ -9,58 +9,4 @@
9
9
  * depends on this shared schema instead of the perp line's full config —
10
10
  * mirrors the earlier account-config hoist.
11
11
  */
12
- export const PYTH_DEFAULTS = {
13
- MAINNET: {
14
- state_id: "0x1f9310238ee9298fb703c3419030b35b22bb1cc37113e3bb5007c99aec79e5b8",
15
- wormhole_state_id: "0xaeab97f96cf9877fee2883315d459552b2b921edc16d7ceac6eab944dd88919c",
16
- hermes_endpoint: "https://hermes.pyth.network",
17
- },
18
- TESTNET: {
19
- state_id: "0x243759059f4c3111179da5878c12f68d612c21a8d54d85edc86164bb18be1c7c",
20
- wormhole_state_id: "0x31358d198147da50db32eda2562951d53973a0c0ad5ed738e9b17d88b213d790",
21
- hermes_endpoint: "https://hermes-beta.pyth.network",
22
- },
23
- };
24
- // ============================================================================
25
- // Pyth Lazer — external infra, defaults by network
26
- // ============================================================================
27
- /**
28
- * Pyth Lazer (Pyth Pro) external infra the `PythLazerRule` needs, by network.
29
- * Mirrors {@link PYTH_DEFAULTS}: per-network constants for infrastructure Pyth
30
- * operates (not part of the `waterx-config` JSON). A fuller `PYTH_INFRA`
31
- * restructure is deferred — this stays a minimal map until then.
32
- *
33
- * - `endpoint` — Lazer HTTP API base; signed updates come from
34
- * `POST /v1/latest_price` (Bearer-authenticated). The service is
35
- * network-agnostic (one signed payload verifies on any chain that trusts the
36
- * Lazer signers), so both networks share the production host.
37
- * - `verifier_package` — the Sui package carrying
38
- * `pyth_lazer::parse_and_verify_le_ecdsa_update`. Per-network: testnet is
39
- * still the original v1 publish; mainnet is the v2-upgraded package (which
40
- * still exposes the v1 entry `pyth_lazer_rule` binds). Values mirror the
41
- * contract repo's `pyth_lazer_rule/Move.toml` published-at pins.
42
- */
43
- export const LAZER_DEFAULTS = {
44
- MAINNET: {
45
- endpoint: "https://pyth-lazer.dourolabs.app",
46
- verifier_package: "0xefbfd064480777699fd9c557a5804d72ace7bc82661fdc8d1f1a44ea6d92ee10",
47
- },
48
- TESTNET: {
49
- endpoint: "https://pyth-lazer.dourolabs.app",
50
- verifier_package: "0xf5bd2141967507050a91b58de3d95e77c432cd90d1799ee46effc27430a68c21",
51
- },
52
- };
53
- /**
54
- * WaterX quote-center base URL by network — the first-party TEE-signed price
55
- * hub `WaterxRule` pulls from (`GET /v1/quotes/update?symbols=…`). Mirrors
56
- * {@link LAZER_DEFAULTS}: infra WaterX operates, not part of the `waterx-config`
57
- * JSON. Public read (no auth), so there is no api_key. `endpoint` has no
58
- * trailing slash — the rule appends the path.
59
- *
60
- * These are DEFAULTS, not a hard pin: a consumer overrides them per client via
61
- * `waterxEndpoint` / `waterxFetch` (resolved onto `client.waterx`).
62
- */
63
- export const WATERX_DEFAULTS = {
64
- MAINNET: { endpoint: "https://quote-center.waterx.app" },
65
- TESTNET: { endpoint: "https://quote-center-staging.waterx.app" },
66
- };
12
+ export {};
@@ -9,37 +9,41 @@
9
9
  */
10
10
  import type { SuiGrpcClient } from "@mysten/sui/grpc";
11
11
  import type { Network } from "../constants.ts";
12
- import type { OracleConfig, PythInfraConfig, WaterxInfraConfig } from "./config.ts";
12
+ import type { OracleConfig, PythAccessConfig, WaterxAccessConfig } from "./config.ts";
13
13
  import type { OracleSource } from "./price-update-rule.ts";
14
14
  export interface OracleHost {
15
- /** Sui network this client targets — selects per-network external-infra defaults (e.g. `LAZER_DEFAULTS`). */
15
+ /** Sui network this client targets — each rule keys its OWN infra table by it (`PYTH_CORE_INFRA`, `LAZER_INFRA`). */
16
16
  readonly network: Network;
17
17
  /** Oracle slice of the canonical `waterx-config` JSON (rule packages + per-ticker feeds). */
18
18
  readonly config: OracleConfig;
19
- /** External Pyth/Wormhole/Hermes infra fixed per `(network, generation)`; api_key/fetch layered from create options. */
20
- readonly pyth: PythInfraConfig;
19
+ /** Caller-supplied Pyth credential + fetch policy (create options) NO endpoints, NO object ids. */
20
+ readonly pyth: PythAccessConfig;
21
21
  /**
22
- * WaterX quote-center infra for `WaterxRule` — endpoint + fetch policy,
23
- * resolved from the `waterxEndpoint` / `waterxFetch` create options.
24
- *
25
- * OPTIONAL so an existing host stays a valid `OracleHost`: when absent the
26
- * rule falls back to `WATERX_DEFAULTS[network]`. This is the hook a browser
27
- * consumer uses to route the quote-center fetch through a same-origin proxy
28
- * (`endpoint`) or its own transport (`fetch.fetchImpl`) — that request is
29
- * made from the page, so it is bound by the quote-center's CORS allowlist.
22
+ * Caller-supplied WaterX quote-center overrides for `WaterxRule`
23
+ * (`waterxEndpoint` / `waterxFetch` create options) — access-only, mirroring
24
+ * `pyth` above. OPTIONAL so an existing host stays a valid `OracleHost`;
25
+ * unset fields resolve against the rule's own `WATERX_INFRA[network]` table.
26
+ * This is the hook a browser consumer uses to route the quote-center fetch
27
+ * through a same-origin proxy (`endpoint`) or its own transport
28
+ * (`fetch.fetchImpl`) — that request is made from the page, so it is bound
29
+ * by the quote-center's CORS allowlist.
30
30
  */
31
- readonly waterx?: WaterxInfraConfig;
31
+ readonly waterx?: WaterxAccessConfig;
32
32
  /** gRPC client for the on-chain reads the Pyth update path needs. */
33
33
  readonly grpcClient: SuiGrpcClient;
34
34
  /**
35
- * Client-selected oracle rule source for `refreshOraclePrices`'s on-chain
36
- * update leg resolved at client creation from the `oracleSource` create
37
- * option (default `'pyth_rule'`). Routing is driven by this value ALONE:
38
- * never by a config JSON `enabled` flag (e.g. a future `pyth_lazer_rule.enabled`)
39
- * and never by `process.env` the SDK never reads it; consumers (BE/FE) wire
40
- * this option from their own env var.
35
+ * The FED SET for `refreshOraclePrices`'s update legs — the REQUIRED
36
+ * `oracleSource` create option normalized to a non-empty, deduped list.
37
+ * Every listed source's data is fetched and fed in one build; the chain's
38
+ * per-ticker weight tables decide which contributions count (feeding an
39
+ * unweighted rule is dropped on-chain; starving a weighted one aborts), so
40
+ * during weight migrations the list stays a SUPERSET of every ticker's
41
+ * weighted set. Routing is driven by this value ALONE: never by a config
42
+ * JSON `enabled` flag and never by `process.env` — the SDK never reads it;
43
+ * consumers (BE/FE) wire this option from their own env var
44
+ * (`ORACLE_SOURCE`, comma-separated).
41
45
  */
42
- readonly oracleSource: OracleSource;
46
+ readonly oracleSources: readonly OracleSource[];
43
47
  /** True when `ticker` is priced by `constant_rule`. */
44
48
  isConstantTicker(ticker: string): boolean;
45
49
  /** The `supra_rule` config when deployed, enabled, and fully wired; else `undefined`. */
@@ -18,15 +18,17 @@
18
18
  export type { OracleHost } from "./host.ts";
19
19
  export { FetchPolicyError, fetchWithPolicy, joinEndpointPath } from "./update-fetch.ts";
20
20
  export type { FetchPolicy } from "./update-fetch.ts";
21
- export { PythCache, fetchPriceFeedsUpdateData, endpointSupportedFeedIds, probeMissingFeeds, buildPythPriceUpdateCalls, updatePythPrices, HermesEndpointRejectedAllFeedsError, MISSING_FEED_MEMO_TTL_MS, OracleFeeSourceUnavailableError, } from "./pyth.ts";
21
+ export { PythCache, fetchPriceFeedsUpdateData, endpointSupportedFeedIds, probeMissingFeeds, buildPythPriceUpdateCalls, pythCoreHermesEndpoint, pythProHermesEndpoint, PYTH_PRO_HERMES_ENDPOINT, updatePythPrices, HermesEndpointRejectedAllFeedsError, MISSING_FEED_MEMO_TTL_MS, OracleFeeSourceUnavailableError, } from "./pyth.ts";
22
22
  export type { OracleFeeSource } from "./pyth.ts";
23
23
  export type { PriceUpdateRule, PriceUpdateRuleKind, RuleUpdateData, RuleUpdateHandle, BuildUpdateOpts, OracleSource, UpdateDataProvider, } from "./price-update-rule.ts";
24
+ export { resolveOracleReadPlan, resolveHermesReadEndpoint } from "./read-plane.ts";
25
+ export type { OracleReadPlan } from "./read-plane.ts";
24
26
  export { PythCoreRule } from "./rules/pyth-core-rule.ts";
25
27
  export type { PythCoreUpdatePayload } from "./rules/pyth-core-rule.ts";
26
28
  export { PythLazerRule, LazerApiKeyMissingError } from "./rules/pyth-lazer-rule.ts";
27
29
  export type { PythLazerUpdatePayload } from "./rules/pyth-lazer-rule.ts";
28
- export { WaterxRule, parseSignedEnvelope } from "./rules/waterx-rule.ts";
30
+ export { WaterxRule, parseSignedEnvelope, BATCH_PRICE_INTENT, WATERX_INFRA, waterxQuoteCenterEndpoint, } from "./rules/waterx-rule.ts";
29
31
  export type { WaterxUpdatePayload, WaterxSignedEnvelope, WaterxBatchItem, } from "./rules/waterx-rule.ts";
30
- export { OracleSourceNotImplementedError } from "./rule-registry.ts";
32
+ export { OracleSourceNotImplementedError, resolveOracleRule } from "./rule-registry.ts";
31
33
  export { aggregateTicker, aggregateTickerWithPyth, aggregateTickerWithConstant, refreshOraclePrices, } from "./aggregate.ts";
32
34
  export { openPythSponsorFund, reimbursePythSponsor } from "./rules/sponsor.ts";
@@ -33,24 +33,39 @@ export { FetchPolicyError, fetchWithPolicy, joinEndpointPath } from "./update-fe
33
33
  // branch on the fee-source failure directly, and a consumer of
34
34
  // `fetchPriceFeedsUpdateData` / `probeMissingFeeds` can tell a misconfigured
35
35
  // or unentitled endpoint apart from feeds that endpoint genuinely lacks.
36
- export { PythCache, fetchPriceFeedsUpdateData, endpointSupportedFeedIds, probeMissingFeeds, buildPythPriceUpdateCalls, updatePythPrices, HermesEndpointRejectedAllFeedsError, MISSING_FEED_MEMO_TTL_MS, OracleFeeSourceUnavailableError, } from "./pyth.js";
36
+ export { PythCache, fetchPriceFeedsUpdateData, endpointSupportedFeedIds, probeMissingFeeds, buildPythPriceUpdateCalls,
37
+ // The pyth read-plane endpoint accessors — Core (keyless, per network) and
38
+ // Pro (the documented fixed base; auth via the caller's Bearer key). There
39
+ // is no client-level endpoint field: consumers pick via
40
+ // `resolveHermesReadEndpoint` (pyth_rule listed → Core, else override ??
41
+ // Pro) — never a hand-rolled branch, never a cross-source fallback.
42
+ pythCoreHermesEndpoint, pythProHermesEndpoint, PYTH_PRO_HERMES_ENDPOINT, updatePythPrices, HermesEndpointRejectedAllFeedsError, MISSING_FEED_MEMO_TTL_MS, OracleFeeSourceUnavailableError, } from "./pyth.js";
43
+ // Per-source READ-plane resolution — which tickers a source can price
44
+ // off-chain and with which ids (`resolveOracleReadPlan`), and which
45
+ // Hermes-compatible base the hermes plans execute against
46
+ // (`resolveHermesReadEndpoint`: pyth_rule listed → Core, else override ??
47
+ // the documented Pyth Pro base). The one place the "lazer reads through
48
+ // `pyth_rule.feeds` hex ids" invariant lives; consumers resolve through
49
+ // this instead of hardcoding namespace sharing or endpoint branching.
50
+ export { resolveOracleReadPlan, resolveHermesReadEndpoint } from "./read-plane.js";
37
51
  // Pyth Core rule (PriceUpdateRule wrapper over the Pyth source above)
38
52
  export { PythCoreRule } from "./rules/pyth-core-rule.js";
39
53
  // Pyth Lazer rule (signed-update generation; `feedLazerRule` stays internal to `aggregate.ts`)
40
54
  // `LazerApiKeyMissingError` is re-exported (not just the type) for the same
41
55
  // `instanceof` reason as `OracleFeeSourceUnavailableError` above.
42
56
  export { PythLazerRule, LazerApiKeyMissingError } from "./rules/pyth-lazer-rule.js";
57
+ // `WATERX_INFRA` / `waterxQuoteCenterEndpoint` are the source's own infra table +
58
+ // read-plane accessor (mirrors `pythCoreHermesEndpoint`).
43
59
  // WaterX quote-center rule (first-party ed25519 signed batches; `feedWaterxRule`
44
60
  // stays internal to `aggregate.ts`).
45
- export { WaterxRule, parseSignedEnvelope } from "./rules/waterx-rule.js";
46
- // `resolveOracleRule` (rule-registry.ts) is NOT re-exported hereit has one
47
- // production consumer (`refreshOraclePrices` below) and no external caller;
48
- // per repo convention ("no unused exports") it stays module-internal. Tests
49
- // import it directly from `./rule-registry.ts`. `OracleSourceNotImplementedError`
50
- // IS re-exported (the same `instanceof` reason as `OracleFeeSourceUnavailableError`
51
- // above) — a consumer of `refreshOraclePrices` can catch it without importing
52
- // `resolveOracleRule` itself.
53
- export { OracleSourceNotImplementedError } from "./rule-registry.js";
61
+ export { WaterxRule, parseSignedEnvelope, BATCH_PRICE_INTENT, WATERX_INFRA, waterxQuoteCenterEndpoint, } from "./rules/waterx-rule.js";
62
+ // `resolveOracleRule` is the ONE source→rule registry exported so external
63
+ // consumers (e.g. a BE prefetch cache that keys per source and needs each
64
+ // source's `supportedTickers`/`fetchUpdateData`) resolve through it instead of
65
+ // hand-mirroring the map and drifting. `OracleSourceNotImplementedError` is
66
+ // its `instanceof`-able failure (same reason as `OracleFeeSourceUnavailableError`
67
+ // above).
68
+ export { OracleSourceNotImplementedError, resolveOracleRule } from "./rule-registry.js";
54
69
  // Aggregation orchestrator
55
70
  export { aggregateTicker, aggregateTickerWithPyth, aggregateTickerWithConstant, refreshOraclePrices, } from "./aggregate.js";
56
71
  // Sponsor rule (fund open / reimburse + witness attach)