@waterx/sdk 3.1.1 → 4.0.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 (72) hide show
  1. package/README.md +36 -0
  2. package/dist/cjs/src/oracle/aggregate.d.ts +98 -17
  3. package/dist/cjs/src/oracle/aggregate.js +191 -21
  4. package/dist/cjs/src/oracle/config.d.ts +103 -0
  5. package/dist/cjs/src/oracle/config.js +64 -1
  6. package/dist/cjs/src/oracle/host.d.ts +13 -0
  7. package/dist/cjs/src/oracle/index.d.ts +19 -5
  8. package/dist/cjs/src/oracle/index.js +40 -6
  9. package/dist/cjs/src/oracle/price-update-rule.d.ts +180 -0
  10. package/dist/cjs/src/oracle/price-update-rule.js +56 -0
  11. package/dist/cjs/src/oracle/pyth.d.ts +80 -11
  12. package/dist/cjs/src/oracle/pyth.js +84 -17
  13. package/dist/cjs/src/oracle/rule-registry.d.ts +37 -0
  14. package/dist/cjs/src/oracle/rule-registry.js +61 -0
  15. package/dist/cjs/src/oracle/rules/pyth-core-rule.d.ts +15 -0
  16. package/dist/cjs/src/oracle/rules/pyth-core-rule.js +84 -0
  17. package/dist/cjs/src/oracle/rules/pyth-lazer-rule.d.ts +41 -0
  18. package/dist/cjs/src/oracle/rules/pyth-lazer-rule.js +194 -0
  19. package/dist/cjs/src/oracle/rules/sponsor.d.ts +11 -7
  20. package/dist/cjs/src/oracle/rules/sponsor.js +11 -7
  21. package/dist/cjs/src/oracle/update-fetch.d.ts +85 -0
  22. package/dist/cjs/src/oracle/update-fetch.js +228 -0
  23. package/dist/cjs/src/perp/client.d.ts +22 -1
  24. package/dist/cjs/src/perp/client.js +11 -2
  25. package/dist/cjs/src/perp/config.d.ts +9 -4
  26. package/dist/cjs/src/perp/config.js +82 -18
  27. package/dist/cjs/src/perp/index.d.ts +4 -3
  28. package/dist/cjs/src/perp/index.js +8 -3
  29. package/dist/cjs/src/perp/tx-builders/common.d.ts +52 -15
  30. package/dist/cjs/src/perp/tx-builders/common.js +39 -6
  31. package/dist/cjs/src/perp/tx-builders/wlp.d.ts +11 -3
  32. package/dist/cjs/src/perp/tx-builders/wlp.js +29 -3
  33. package/dist/cjs/src/perp/tx-builders.d.ts +3 -3
  34. package/dist/cjs/src/perp/tx-builders.js +3 -3
  35. package/dist/cjs/src/unified-client.d.ts +17 -0
  36. package/dist/cjs/src/unified-client.js +2 -0
  37. package/dist/src/oracle/aggregate.d.ts +98 -17
  38. package/dist/src/oracle/aggregate.js +192 -22
  39. package/dist/src/oracle/config.d.ts +103 -0
  40. package/dist/src/oracle/config.js +63 -0
  41. package/dist/src/oracle/host.d.ts +13 -0
  42. package/dist/src/oracle/index.d.ts +19 -5
  43. package/dist/src/oracle/index.js +34 -6
  44. package/dist/src/oracle/price-update-rule.d.ts +180 -0
  45. package/dist/src/oracle/price-update-rule.js +53 -0
  46. package/dist/src/oracle/pyth.d.ts +80 -11
  47. package/dist/src/oracle/pyth.js +82 -16
  48. package/dist/src/oracle/rule-registry.d.ts +37 -0
  49. package/dist/src/oracle/rule-registry.js +56 -0
  50. package/dist/src/oracle/rules/pyth-core-rule.d.ts +15 -0
  51. package/dist/src/oracle/rules/pyth-core-rule.js +81 -0
  52. package/dist/src/oracle/rules/pyth-lazer-rule.d.ts +41 -0
  53. package/dist/src/oracle/rules/pyth-lazer-rule.js +189 -0
  54. package/dist/src/oracle/rules/sponsor.d.ts +11 -7
  55. package/dist/src/oracle/rules/sponsor.js +11 -7
  56. package/dist/src/oracle/update-fetch.d.ts +85 -0
  57. package/dist/src/oracle/update-fetch.js +223 -0
  58. package/dist/src/perp/client.d.ts +22 -1
  59. package/dist/src/perp/client.js +12 -3
  60. package/dist/src/perp/config.d.ts +9 -4
  61. package/dist/src/perp/config.js +81 -18
  62. package/dist/src/perp/index.d.ts +4 -3
  63. package/dist/src/perp/index.js +2 -2
  64. package/dist/src/perp/tx-builders/common.d.ts +52 -15
  65. package/dist/src/perp/tx-builders/common.js +39 -6
  66. package/dist/src/perp/tx-builders/wlp.d.ts +11 -3
  67. package/dist/src/perp/tx-builders/wlp.js +29 -3
  68. package/dist/src/perp/tx-builders.d.ts +3 -3
  69. package/dist/src/perp/tx-builders.js +3 -3
  70. package/dist/src/unified-client.d.ts +17 -0
  71. package/dist/src/unified-client.js +2 -0
  72. package/package.json +1 -1
package/README.md CHANGED
@@ -99,6 +99,42 @@ const predict = await PredictClient.create("TESTNET", { waterxConfigUrl }); // o
99
99
 
100
100
  Read-only queries use gRPC `simulateTransaction` (no signer) — the `getX` view helpers, e.g. `await perp.simulate(tx)` or `getMarketData(perp, …)`.
101
101
 
102
+ ## Oracle sources & the Pyth Pro migration
103
+
104
+ Two independent client create options control oracle behavior. The SDK **never reads `process.env`** — each consumer wires them from its own env vars, so every environment runs the **same SDK version** and differs only by env:
105
+
106
+ | Option | Values | What it flips |
107
+ |--------|--------|---------------|
108
+ | `oracleSource` | `'pyth_rule'` (default) \| `'pyth_lazer_rule'` | Which `PriceUpdateRule` `refreshOraclePrices` uses for the on-chain price-update leg. |
109
+ | `pythGeneration` | `'core'` (default) \| `'pro'` | Which Pyth infra constants feed `client.pyth` when the config JSON has no explicit `pyth` block: `PYTH_DEFAULTS` (original contracts, keyless `hermes.pyth.network`) or `PYTH_PRO_DEFAULTS` (post-2026-08-18 Pro-compatible contracts + the Hermes-compatible `https://pyth.dourolabs.app/hermes`, auth-first). |
110
+
111
+ They are orthogonal: `pythGeneration` moves the Pyth **Core** state ids + endpoint; `oracleSource` picks the **rule** (Core VAA vs Lazer signed updates). An explicit `pyth` block in the config JSON always overrides the generation constants wholesale.
112
+
113
+ ```ts
114
+ // Per-environment wiring — the consumer owns the env vars, not the SDK:
115
+ const perp = await PerpClient.create(network, {
116
+ waterxConfigUrl,
117
+ oracleSource: process.env.ORACLE_SOURCE as OracleSource | undefined, // e.g. staging: pyth_lazer_rule
118
+ pythGeneration: process.env.PYTH_GENERATION as PythGeneration | undefined, // e.g. staging: pro
119
+ });
120
+ // After the 2026-08-18 cutover, Pro-generation Hermes requires a key:
121
+ perp.pyth = { ...perp.pyth, api_key: process.env.PYTH_API_KEY };
122
+ ```
123
+
124
+ This is the staging-Pro / prod-Core rollout pattern: staging sets `ORACLE_SOURCE=pyth_lazer_rule` and/or `PYTH_GENERATION=pro` while production leaves both unset (Core defaults) — flipping an environment is an env-var change, never an SDK release. After August 18, 2026 (the Core-upgrade cutover — see https://docs.pyth.network/price-feeds/core/upgrade), consumers set `pythGeneration: 'pro'` + `pyth.api_key`.
125
+
126
+ ### Adding an oracle source (runbook)
127
+
128
+ Every rule generation plugs in the same way — routing is driven **only** by the client's `oracleSource` option (never a config `enabled` flag, never `process.env`):
129
+
130
+ 1. **Implement `PriceUpdateRule`** in `src/oracle/rules/<name>-rule.ts` — all port fields (`src/oracle/price-update-rule.ts`): `kind`, `requiresFeeSource` (`true` iff the on-chain verify draws a per-update fee — gates the fail-fast fee-source check), `supportedTickers`, `fetchUpdateData`, `narrowUpdateData` (subset a cached whole-universe payload to one build's tickers — a divisible payload returns a per-feed subset, an indivisible one returns itself whole iff fully covered; uncovered ticker → `null` miss), `buildUpdateCalls`.
131
+ 2. **Register it** in `src/oracle/rule-registry.ts` (`DEFAULT_RULES`) under a new `OracleSource` value (added to the union in `price-update-rule.ts`).
132
+ 3. **Publish the on-chain rule package** — its config entry (package ids, per-ticker `feeds`) arrives via the normal `waterx-config` deploy pipeline; type it in `OraclePackages` (`src/oracle/config.ts`).
133
+ 4. **Add SDK infra constants** if the source needs external infra that is not part of the config JSON (API endpoints, verifier packages, state objects) — a per-network map in `src/oracle/config.ts`, mirroring `LAZER_DEFAULTS` / `PYTH_PRO_DEFAULTS`.
134
+ 5. **Consumers flip `ORACLE_SOURCE`** per environment — no consumer code change, no SDK re-release.
135
+
136
+ The in-house `waterx_rule` (ed25519 enclave-signed CEX prices) follows exactly this path when it lands.
137
+
102
138
  ## Recipes & full surface
103
139
 
104
140
  To avoid doc drift, per-action usage lives in maintained, lint-checked code rather than this README:
@@ -2,40 +2,58 @@
2
2
  * Oracle aggregation — the orchestrator that composes rules into the shared
3
3
  * `Oracle`. This is the ONE file that knows about every rule: it builds a
4
4
  * `PriceCollector`, feeds whichever rules a ticker is configured for
5
- * (Pyth / Supra / Constant), then `aggregate`s.
5
+ * (Pyth / Lazer / Supra / Constant), then `aggregate`s.
6
6
  *
7
7
  * Per ticker:
8
8
  * collector = oracle::new_collector(ticker)
9
9
  * [pyth_rule::feed] when the ticker has a pyth_rule.feeds entry
10
+ * [pyth_lazer_rule::feed] when the update leg produced a verified lazer Update
10
11
  * [supra_rule::feed] when supra is enabled + wired
11
12
  * [constant_rule::feed] when the ticker is a constant ticker
12
13
  * oracle::aggregate(oracle, collector)
13
14
  *
14
- * The fed rule set must match the on-chain weighted set for the ticker —
15
+ * The fed rule set must cover the on-chain weighted set for the ticker —
15
16
  * `aggregator::remove_outliers` aborts `EMissingPriceSource` if a weighted rule
16
- * is missing from the collector.
17
+ * is missing from the collector (an abstaining feed call counts as present;
18
+ * a fed-but-unweighted rule is silently dropped).
19
+ *
20
+ * `refreshOraclePrices` additionally routes the on-chain price *update* leg
21
+ * (the fetch + verify/push step, before any of the above feeding) through the
22
+ * `PriceUpdateRule` selected by `host.oracleSource` — see `rule-registry.ts`.
17
23
  */
18
24
  import type { Transaction, TransactionArgument } from "@mysten/sui/transactions";
19
25
  import type { OracleHost } from "./host.ts";
20
- import { type PythCache } from "./pyth.ts";
26
+ import type { OracleSource, PriceUpdateRule, UpdateDataProvider } from "./price-update-rule.ts";
27
+ import { type OracleFeeSource, type PythCache } from "./pyth.ts";
21
28
  /**
22
29
  * Aggregate one ticker's price into the shared `Oracle`: build a collector, feed
23
30
  * every rule the ticker is configured for, then `aggregate`.
24
31
  *
25
32
  * - **Pyth** — fed when `priceInfoObjectId` is supplied (i.e. the ticker has a
26
- * `pyth_rule.feeds` entry). Caller must run the Pyth update first so the
27
- * `PriceInfoObject` is fresh.
28
- * - **Supra** fed alongside Pyth when supra is enabled + wired (abstains
29
- * on-chain for symbols it has no pair for).
33
+ * `pyth_rule.feeds` entry). When this PTB's update leg refreshed the
34
+ * `PriceInfoObject` it contributes a fresh price; when it did not (a
35
+ * lazer-routed ticker), the on-chain rule only READS the object and abstains
36
+ * if it is stale — it never aborts so the call stays mandatory while
37
+ * `pyth_rule` remains in the ticker's on-chain weighted set
38
+ * (`EMissingPriceSource` requires every weighted rule to appear).
39
+ * - **Lazer** — fed when `lazerUpdate` is supplied: the verified
40
+ * `pyth_lazer::update::Update` produced by this PTB's lazer update leg
41
+ * (see `PythLazerRule.buildUpdateCalls`). If the ticker's aggregator does
42
+ * not (yet) weight `PythLazerRule`, the contribution is silently dropped
43
+ * on-chain — feeding ahead of the weight migration is harmless.
44
+ * - **Supra** — fed alongside Pyth/Lazer when supra is enabled + wired
45
+ * (abstains on-chain for symbols it has no pair for).
30
46
  * - **Constant** — fed when the ticker is a constant ticker
31
47
  * ({@link OracleHost.isConstantTicker}).
32
48
  *
33
- * "Dual-feed" (Pyth + Constant) and "constant-only" are not special cases — they
34
- * fall out of which rules the ticker is in. Throws if no rule applies.
49
+ * "Dual-feed" (Pyth + Constant, or Pyth + Lazer) and "constant-only" are not
50
+ * special cases — they fall out of which rules the ticker is in. Throws if no
51
+ * rule applies.
35
52
  */
36
53
  export declare function aggregateTicker(tx: Transaction, host: OracleHost, args: {
37
54
  ticker: string;
38
55
  priceInfoObjectId?: string;
56
+ lazerUpdate?: TransactionArgument;
39
57
  }): void;
40
58
  /**
41
59
  * Thin wrapper over {@link aggregateTicker} for a Pyth-fed ticker. Kept for
@@ -61,14 +79,77 @@ export declare function aggregateTickerWithConstant(tx: Transaction, host: Oracl
61
79
  /**
62
80
  * Refresh multiple tickers in one PTB. For each ticker {@link aggregateTicker}
63
81
  * feeds whichever rules it is configured for (Pyth if it has a `pyth_rule.feeds`
64
- * entry, Supra when enabled, Constant when it's a constant ticker). Tickers with a
65
- * Pyth feed are updated on-chain via one shared Pyth accumulator first; the rest
66
- * (constant-only) skip Pyth entirely.
82
+ * entry, Lazer if the lazer update leg served it see below Supra when
83
+ * enabled, Constant when it's a constant ticker).
84
+ *
85
+ * Before that, the on-chain price *update* leg is routed by `host.oracleSource`
86
+ * (see `rule-registry.ts`): the selected rule serves every ticker in its
87
+ * `supportedTickers(host)`; tickers it doesn't cover fall back to `pyth_rule`
88
+ * (`PythCoreRule`) when THEY support it — so when `oracleSource` IS `'pyth_rule'`
89
+ * there is exactly one group, identical to the pre-routing behavior. A ticker
90
+ * supported by neither is simply skipped from this leg (no fetch/build call for
91
+ * it) — the same way today's non-pyth tickers (e.g. constant-only) always were;
92
+ * it still gets aggregated below via whichever rule {@link aggregateTicker} finds.
93
+ *
94
+ * Each group's fetch + build runs against its own rule, which guarantees
95
+ * per-rule PTB atomicity (no mixed-generation payload within one rule's calls).
96
+ * When `oracleSource` isn't `'pyth_rule'`, one PTB may legitimately carry BOTH a
97
+ * non-Pyth-Core block (selected group) and a Pyth Core block (fallback group) —
98
+ * each verifies against its own contract objects, so that's fine. A fee-source
99
+ * pre-check runs first, across every group's `requiresFeeSource` — BEFORE any
100
+ * off-chain fetch or PTB mutation — so a fee-charging group with no
101
+ * `opts.feeSource` throws `OracleFeeSourceUnavailable` with zero wasted
102
+ * network calls and zero stray moveCalls, even in a mixed shape (e.g. a
103
+ * fee-free Lazer group ordered ahead of a Pyth Core fallback group). Only once
104
+ * that check passes do all groups' off-chain fetches run concurrently
105
+ * (`Promise.all`) and complete before any PTB mutation; on-chain reads inside
106
+ * `buildUpdateCalls` can still fail mid-append for other reasons — callers
107
+ * discard the tx on any throw.
108
+ *
109
+ * **Collector-feed leg is rule-aware:** a lazer-served group's
110
+ * `buildUpdateCalls` returns the verified `Update` PTB value
111
+ * ({@link RuleUpdateHandle}), and every ticker in that group is aggregated
112
+ * with `lazerUpdate` set so {@link aggregateTicker} appends
113
+ * `pyth_lazer_rule::feed` against it. A lazer-routed ticker that still has a
114
+ * `pyth_rule.feeds` entry ALSO keeps its `pyth_rule::feed` leg — required
115
+ * on-chain while `pyth_rule` stays in the ticker's weighted set
116
+ * (`aggregator::remove_outliers` aborts `EMissingPriceSource` unless every
117
+ * weighted rule appears in the collector; an abstention counts as
118
+ * appearing), and safe: `pyth_rule::feed` only READS the `PriceInfoObject`
119
+ * this PTB never refreshed and abstains when it is stale rather than
120
+ * aborting. Conversely, a lazer feed call on an aggregator that does not
121
+ * (yet) weight `PythLazerRule` is silently dropped on-chain — so
122
+ * lazer-routing a ticker ahead of its on-chain weight migration prices it
123
+ * from the remaining weighted rules instead of failing.
67
124
  */
68
125
  export declare function refreshOraclePrices(tx: Transaction, host: OracleHost, tickers: string[], opts?: {
69
126
  cache?: PythCache;
70
- sponsorFund?: {
71
- fund: TransactionArgument;
72
- packageId: string;
73
- };
127
+ /**
128
+ * The single resolved fee source for the Pyth update fee, forwarded
129
+ * verbatim to each group's `PriceUpdateRule.buildUpdateCalls` as
130
+ * `BuildUpdateOpts.feeSource`. Already-resolved by the caller (see
131
+ * {@link OracleFeeSource}'s own doc for where/how) — this function makes
132
+ * no sponsor-vs-gas decision itself, it only checks whether a source was
133
+ * resolved at all. Ignored by rules with no update fee (e.g.
134
+ * `pyth_lazer_rule`). Building with `feeSource` unset throws
135
+ * `OracleFeeSourceUnavailable` (see `oracle/pyth.ts`) instead of
136
+ * silently drawing from `tx.gas`.
137
+ */
138
+ feeSource?: OracleFeeSource;
139
+ /**
140
+ * @internal Test-only: layer fake `PriceUpdateRule`s on top of the
141
+ * production registry (see `rule-registry.ts`'s `resolveOracleRule`).
142
+ * Production callers never set this — routing is by `host.oracleSource`
143
+ * alone.
144
+ */
145
+ ruleOverrides?: Partial<Record<OracleSource, PriceUpdateRule>>;
146
+ /**
147
+ * BE prefetch-cache seam: checked per group BEFORE that group's live
148
+ * `rule.fetchUpdateData`. See {@link UpdateDataProvider}. A cache miss
149
+ * (`null`) or a throw from the provider falls back to the live fetch —
150
+ * a degraded/broken cache must never break the money path; a
151
+ * kind-mismatched hit (the provider handed back the wrong rule's
152
+ * payload) throws instead, since that is a caller bug, not a cache miss.
153
+ */
154
+ updateDataProvider?: UpdateDataProvider;
74
155
  }): Promise<void>;
@@ -3,18 +3,24 @@
3
3
  * Oracle aggregation — the orchestrator that composes rules into the shared
4
4
  * `Oracle`. This is the ONE file that knows about every rule: it builds a
5
5
  * `PriceCollector`, feeds whichever rules a ticker is configured for
6
- * (Pyth / Supra / Constant), then `aggregate`s.
6
+ * (Pyth / Lazer / Supra / Constant), then `aggregate`s.
7
7
  *
8
8
  * Per ticker:
9
9
  * collector = oracle::new_collector(ticker)
10
10
  * [pyth_rule::feed] when the ticker has a pyth_rule.feeds entry
11
+ * [pyth_lazer_rule::feed] when the update leg produced a verified lazer Update
11
12
  * [supra_rule::feed] when supra is enabled + wired
12
13
  * [constant_rule::feed] when the ticker is a constant ticker
13
14
  * oracle::aggregate(oracle, collector)
14
15
  *
15
- * The fed rule set must match the on-chain weighted set for the ticker —
16
+ * The fed rule set must cover the on-chain weighted set for the ticker —
16
17
  * `aggregator::remove_outliers` aborts `EMissingPriceSource` if a weighted rule
17
- * is missing from the collector.
18
+ * is missing from the collector (an abstaining feed call counts as present;
19
+ * a fed-but-unweighted rule is silently dropped).
20
+ *
21
+ * `refreshOraclePrices` additionally routes the on-chain price *update* leg
22
+ * (the fetch + verify/push step, before any of the above feeding) through the
23
+ * `PriceUpdateRule` selected by `host.oracleSource` — see `rule-registry.ts`.
18
24
  */
19
25
  Object.defineProperty(exports, "__esModule", { value: true });
20
26
  exports.aggregateTicker = aggregateTicker;
@@ -23,23 +29,77 @@ exports.aggregateTickerWithConstant = aggregateTickerWithConstant;
23
29
  exports.refreshOraclePrices = refreshOraclePrices;
24
30
  const oracle_ts_1 = require("../generated/waterx_oracle/oracle.js");
25
31
  const pyth_ts_1 = require("./pyth.js");
32
+ const rule_registry_ts_1 = require("./rule-registry.js");
26
33
  const constant_rule_ts_1 = require("./rules/constant-rule.js");
34
+ const pyth_lazer_rule_ts_1 = require("./rules/pyth-lazer-rule.js");
27
35
  const pyth_rule_ts_1 = require("./rules/pyth-rule.js");
28
36
  const supra_rule_ts_1 = require("./rules/supra-rule.js");
37
+ /**
38
+ * Resolve one group's off-chain update payload for {@link refreshOraclePrices}:
39
+ * try `provider.get(source, tickers)` first (when a provider is configured),
40
+ * falling back to the group's own live `rule.fetchUpdateData` on a cache miss
41
+ * (`null`) or a throw from the provider — a broken/degraded cache must never
42
+ * break the money path.
43
+ *
44
+ * A cache HIT is treated as a payload for a POSSIBLY-WIDER ticker set (a
45
+ * provider commonly caches one whole-universe payload per source — see
46
+ * {@link UpdateDataProvider}), so it is narrowed to exactly `group.tickers`
47
+ * via `rule.narrowUpdateData` before use. This is load-bearing, not
48
+ * defensive: without it a Pyth Core hit would emit an
49
+ * `update_single_price_feed` — and charge its fee — for every cached feed
50
+ * instead of just this group's, and a payload that cannot cover the group
51
+ * (`narrowUpdateData` → `null`) would never reach the live-fetch fallback.
52
+ * Each rule owns its own subsetting (Core subsets per-feed entries; Lazer's
53
+ * indivisible payload passes whole iff fully covered), so the orchestrator
54
+ * never branches on `kind` here. A hit whose `kind` doesn't match the
55
+ * group's rule is a caller bug (the provider handed back a different rule's
56
+ * payload), so that throws — via `narrowUpdateData`'s own
57
+ * `assertRuleUpdateData` guard — instead of silently falling back.
58
+ */
59
+ async function resolveGroupUpdateData(host, group, provider) {
60
+ if (provider) {
61
+ let cached = null;
62
+ try {
63
+ cached = await provider.get(group.source, group.tickers);
64
+ }
65
+ catch {
66
+ // Provider errors must never break the money path — fall through to
67
+ // the live fetch below exactly as a cache miss (`null`) would.
68
+ }
69
+ if (cached !== null) {
70
+ // Wrong-kind hit throws inside narrowUpdateData (assertRuleUpdateData);
71
+ // a hit that can't cover the group narrows to null → live-fetch below.
72
+ const narrowed = group.rule.narrowUpdateData(host, cached, group.tickers);
73
+ if (narrowed !== null)
74
+ return narrowed;
75
+ }
76
+ }
77
+ return group.rule.fetchUpdateData(host, group.tickers);
78
+ }
29
79
  /**
30
80
  * Aggregate one ticker's price into the shared `Oracle`: build a collector, feed
31
81
  * every rule the ticker is configured for, then `aggregate`.
32
82
  *
33
83
  * - **Pyth** — fed when `priceInfoObjectId` is supplied (i.e. the ticker has a
34
- * `pyth_rule.feeds` entry). Caller must run the Pyth update first so the
35
- * `PriceInfoObject` is fresh.
36
- * - **Supra** fed alongside Pyth when supra is enabled + wired (abstains
37
- * on-chain for symbols it has no pair for).
84
+ * `pyth_rule.feeds` entry). When this PTB's update leg refreshed the
85
+ * `PriceInfoObject` it contributes a fresh price; when it did not (a
86
+ * lazer-routed ticker), the on-chain rule only READS the object and abstains
87
+ * if it is stale — it never aborts so the call stays mandatory while
88
+ * `pyth_rule` remains in the ticker's on-chain weighted set
89
+ * (`EMissingPriceSource` requires every weighted rule to appear).
90
+ * - **Lazer** — fed when `lazerUpdate` is supplied: the verified
91
+ * `pyth_lazer::update::Update` produced by this PTB's lazer update leg
92
+ * (see `PythLazerRule.buildUpdateCalls`). If the ticker's aggregator does
93
+ * not (yet) weight `PythLazerRule`, the contribution is silently dropped
94
+ * on-chain — feeding ahead of the weight migration is harmless.
95
+ * - **Supra** — fed alongside Pyth/Lazer when supra is enabled + wired
96
+ * (abstains on-chain for symbols it has no pair for).
38
97
  * - **Constant** — fed when the ticker is a constant ticker
39
98
  * ({@link OracleHost.isConstantTicker}).
40
99
  *
41
- * "Dual-feed" (Pyth + Constant) and "constant-only" are not special cases — they
42
- * fall out of which rules the ticker is in. Throws if no rule applies.
100
+ * "Dual-feed" (Pyth + Constant, or Pyth + Lazer) and "constant-only" are not
101
+ * special cases — they fall out of which rules the ticker is in. Throws if no
102
+ * rule applies.
43
103
  */
44
104
  function aggregateTicker(tx, host, args) {
45
105
  const oraclePkg = host.config.packages.waterx_oracle.published_at;
@@ -50,16 +110,22 @@ function aggregateTicker(tx, host, args) {
50
110
  let fed = false;
51
111
  if (args.priceInfoObjectId) {
52
112
  (0, pyth_rule_ts_1.feedPythRule)(tx, host, collector, args.priceInfoObjectId);
113
+ fed = true;
114
+ }
115
+ if (args.lazerUpdate !== undefined) {
116
+ (0, pyth_lazer_rule_ts_1.feedLazerRule)(tx, host, collector, args.lazerUpdate);
117
+ fed = true;
118
+ }
119
+ if (fed) {
53
120
  // Supra rides on the same collector when enabled (abstains on-chain otherwise).
54
121
  (0, supra_rule_ts_1.maybeFeedSupra)(tx, host, collector);
55
- fed = true;
56
122
  }
57
123
  if (host.isConstantTicker(args.ticker)) {
58
124
  (0, constant_rule_ts_1.feedConstantRule)(tx, host, collector);
59
125
  fed = true;
60
126
  }
61
127
  if (!fed) {
62
- throw new Error(`no oracle rule configured for ticker '${args.ticker}' (no pyth feed, not a constant ticker)`);
128
+ throw new Error(`no oracle rule configured for ticker '${args.ticker}' (no pyth feed, no lazer update, not a constant ticker)`);
63
129
  }
64
130
  (0, oracle_ts_1.aggregate)({
65
131
  package: oraclePkg,
@@ -95,24 +161,128 @@ function aggregateTickerWithConstant(tx, host, args) {
95
161
  /**
96
162
  * Refresh multiple tickers in one PTB. For each ticker {@link aggregateTicker}
97
163
  * feeds whichever rules it is configured for (Pyth if it has a `pyth_rule.feeds`
98
- * entry, Supra when enabled, Constant when it's a constant ticker). Tickers with a
99
- * Pyth feed are updated on-chain via one shared Pyth accumulator first; the rest
100
- * (constant-only) skip Pyth entirely.
164
+ * entry, Lazer if the lazer update leg served it see below Supra when
165
+ * enabled, Constant when it's a constant ticker).
166
+ *
167
+ * Before that, the on-chain price *update* leg is routed by `host.oracleSource`
168
+ * (see `rule-registry.ts`): the selected rule serves every ticker in its
169
+ * `supportedTickers(host)`; tickers it doesn't cover fall back to `pyth_rule`
170
+ * (`PythCoreRule`) when THEY support it — so when `oracleSource` IS `'pyth_rule'`
171
+ * there is exactly one group, identical to the pre-routing behavior. A ticker
172
+ * supported by neither is simply skipped from this leg (no fetch/build call for
173
+ * it) — the same way today's non-pyth tickers (e.g. constant-only) always were;
174
+ * it still gets aggregated below via whichever rule {@link aggregateTicker} finds.
175
+ *
176
+ * Each group's fetch + build runs against its own rule, which guarantees
177
+ * per-rule PTB atomicity (no mixed-generation payload within one rule's calls).
178
+ * When `oracleSource` isn't `'pyth_rule'`, one PTB may legitimately carry BOTH a
179
+ * non-Pyth-Core block (selected group) and a Pyth Core block (fallback group) —
180
+ * each verifies against its own contract objects, so that's fine. A fee-source
181
+ * pre-check runs first, across every group's `requiresFeeSource` — BEFORE any
182
+ * off-chain fetch or PTB mutation — so a fee-charging group with no
183
+ * `opts.feeSource` throws `OracleFeeSourceUnavailable` with zero wasted
184
+ * network calls and zero stray moveCalls, even in a mixed shape (e.g. a
185
+ * fee-free Lazer group ordered ahead of a Pyth Core fallback group). Only once
186
+ * that check passes do all groups' off-chain fetches run concurrently
187
+ * (`Promise.all`) and complete before any PTB mutation; on-chain reads inside
188
+ * `buildUpdateCalls` can still fail mid-append for other reasons — callers
189
+ * discard the tx on any throw.
190
+ *
191
+ * **Collector-feed leg is rule-aware:** a lazer-served group's
192
+ * `buildUpdateCalls` returns the verified `Update` PTB value
193
+ * ({@link RuleUpdateHandle}), and every ticker in that group is aggregated
194
+ * with `lazerUpdate` set so {@link aggregateTicker} appends
195
+ * `pyth_lazer_rule::feed` against it. A lazer-routed ticker that still has a
196
+ * `pyth_rule.feeds` entry ALSO keeps its `pyth_rule::feed` leg — required
197
+ * on-chain while `pyth_rule` stays in the ticker's weighted set
198
+ * (`aggregator::remove_outliers` aborts `EMissingPriceSource` unless every
199
+ * weighted rule appears in the collector; an abstention counts as
200
+ * appearing), and safe: `pyth_rule::feed` only READS the `PriceInfoObject`
201
+ * this PTB never refreshed and abstains when it is stale rather than
202
+ * aborting. Conversely, a lazer feed call on an aggregator that does not
203
+ * (yet) weight `PythLazerRule` is silently dropped on-chain — so
204
+ * lazer-routing a ticker ahead of its on-chain weight migration prices it
205
+ * from the remaining weighted rules instead of failing.
101
206
  */
102
207
  async function refreshOraclePrices(tx, host, tickers, opts = {}) {
103
208
  if (tickers.length === 0)
104
209
  return;
105
- // Every ticker with a pyth_rule.feeds entry needs the on-chain Pyth update
106
- // first (one shared accumulator). Constant-only tickers (no pyth feed) skip it.
210
+ // price_info_object lookup for every ticker with a pyth_rule.feeds entry
211
+ // needed by aggregateTicker's (unchanged) Pyth feed step below regardless of
212
+ // which rule performed the on-chain update for that ticker.
107
213
  const pythTickers = tickers.filter((t) => host.config.packages.pyth_rule?.feeds?.[t] !== undefined);
108
214
  const priceInfoByTicker = new Map();
109
- if (pythTickers.length > 0) {
110
- const entries = pythTickers.map((t) => host.getPythFeed(t));
111
- await (0, pyth_ts_1.updatePythPrices)(tx, host, entries.map((e) => e.feed_id), opts.cache, opts.sponsorFund);
112
- pythTickers.forEach((t, i) => priceInfoByTicker.set(t, entries[i].price_info_object));
215
+ pythTickers.forEach((t) => priceInfoByTicker.set(t, host.getPythFeed(t).price_info_object));
216
+ // Group tickers for the on-chain update leg: selected rule first, then the
217
+ // pyth_rule fallback for whatever the selected rule doesn't cover. `source`
218
+ // is tracked alongside each group (rather than read back off `rule.kind`,
219
+ // which is typed as the broader PriceUpdateRuleKind) so the provider lookup
220
+ // below has an OracleSource to key on without a cast.
221
+ const selectedRule = (0, rule_registry_ts_1.resolveOracleRule)(host.oracleSource, opts.ruleOverrides);
222
+ const selectedSupported = new Set(selectedRule.supportedTickers(host));
223
+ const selectedGroup = tickers.filter((t) => selectedSupported.has(t));
224
+ const groups = [];
225
+ if (selectedGroup.length > 0) {
226
+ groups.push({ source: host.oracleSource, rule: selectedRule, tickers: selectedGroup });
227
+ }
228
+ if (host.oracleSource !== "pyth_rule") {
229
+ const fallbackRule = (0, rule_registry_ts_1.resolveOracleRule)("pyth_rule", opts.ruleOverrides);
230
+ const fallbackSupported = new Set(fallbackRule.supportedTickers(host));
231
+ const fallbackGroup = tickers.filter((t) => !selectedSupported.has(t) && fallbackSupported.has(t));
232
+ if (fallbackGroup.length > 0) {
233
+ groups.push({ source: "pyth_rule", rule: fallbackRule, tickers: fallbackGroup });
234
+ }
235
+ }
236
+ // Fee-source pre-check, hoisted ABOVE both the off-chain fetch below AND
237
+ // the per-group build loop further down. The condition only consults
238
+ // `group.rule.requiresFeeSource` — known the moment `groups` is built,
239
+ // before any fetch or PTB mutation — so this throws with ZERO wasted
240
+ // network calls and zero PTB commands. A per-call guard inside
241
+ // `buildPythPriceUpdateCalls` alone would not be early enough: in a mixed
242
+ // shape (e.g. a lazer-selected `oracleSource` with a `pyth_rule` fallback
243
+ // group for tickers Lazer doesn't cover), the build loop runs each
244
+ // group's `buildUpdateCalls` in sequence — a fee-free group ordered ahead
245
+ // of a fee-charging one would already have appended its verify/feed
246
+ // moveCalls to the shared `tx` by the time the fee-charging group's own
247
+ // guard fired, breaking the "throw before any PTB mutation" guarantee.
248
+ // Checking every group's `requiresFeeSource` up front — before ANY group
249
+ // fetches or builds — closes that gap, and (unlike a referential check
250
+ // against a specific rule instance) keeps protecting a future
251
+ // fee-charging rule or a test double standing in for one.
252
+ if (!opts.feeSource && groups.some((group) => group.rule.requiresFeeSource)) {
253
+ throw new pyth_ts_1.OracleFeeSourceUnavailableError();
254
+ }
255
+ // Fetch every group's off-chain payload concurrently (independent network
256
+ // calls — no reason to serialize) and let ALL of them settle before the
257
+ // first PTB mutation below, so a later group's fetch failure can never
258
+ // leave an earlier group's moveCalls stranded in a caller-owned tx.
259
+ const groupsWithData = await Promise.all(groups.map(async (group) => ({
260
+ rule: group.rule,
261
+ tickers: group.tickers,
262
+ data: await resolveGroupUpdateData(host, group, opts.updateDataProvider),
263
+ })));
264
+ // Verified-`Update` handle per lazer-served ticker (one shared PTB value per
265
+ // group) — consumed by the collector-feed leg below.
266
+ const lazerUpdateByTicker = new Map();
267
+ for (const group of groupsWithData) {
268
+ const handle = (await group.rule.buildUpdateCalls(tx, host, group.data, {
269
+ cache: opts.cache,
270
+ feeSource: opts.feeSource,
271
+ })) ?? undefined;
272
+ // Route by the handle's kind discriminant — the one site the tag exists to
273
+ // protect: a future non-lazer handle (e.g. a WaterxRule value) must never
274
+ // be silently fed into pyth_lazer_rule::feed.
275
+ if (handle?.kind === "pyth_lazer_rule") {
276
+ for (const ticker of group.tickers)
277
+ lazerUpdateByTicker.set(ticker, handle.update);
278
+ }
113
279
  }
114
280
  // Aggregate each ticker, feeding whichever rules it is configured for.
115
281
  for (const ticker of tickers) {
116
- aggregateTicker(tx, host, { ticker, priceInfoObjectId: priceInfoByTicker.get(ticker) });
282
+ aggregateTicker(tx, host, {
283
+ ticker,
284
+ priceInfoObjectId: priceInfoByTicker.get(ticker),
285
+ lazerUpdate: lazerUpdateByTicker.get(ticker),
286
+ });
117
287
  }
118
288
  }
@@ -22,6 +22,24 @@ export interface PythRulePackage extends BasePackageEntry {
22
22
  export interface PythSponsorRulePackage extends BasePackageEntry {
23
23
  pyth_sponsor: string;
24
24
  }
25
+ /**
26
+ * `pyth_lazer_rule` deployment entry — present in the deployed testnet
27
+ * `waterx-config` JSON. Read by `PythLazerRule` (`rules/pyth-lazer-rule.ts`):
28
+ * `feeds` for ticker support + integer feed-id resolution, `state` for the
29
+ * verify call, `published_at`/`config` for the per-ticker feed call.
30
+ *
31
+ * `enabled` mirrors the JSON field verbatim but MUST NOT be read for routing —
32
+ * which rule prices a ticker is decided solely by the client's `oracleSource`
33
+ * create option (see `OracleHost.oracleSource`), never by this flag or any
34
+ * other config value.
35
+ */
36
+ export interface PythLazerRulePackage extends BasePackageEntry {
37
+ config: string;
38
+ state: string;
39
+ enabled?: boolean;
40
+ /** Oracle ticker → integer Pyth Lazer feed id (distinct id scheme from `pyth_rule`'s hex `feed_id`). */
41
+ feeds: Record<string, number>;
42
+ }
25
43
  /** Per-ticker `constant_rule` feed entry (mirrors the `pyth_rule.feeds` shape). */
26
44
  export interface ConstantFeedEntry {
27
45
  /**
@@ -77,6 +95,8 @@ export interface WaterxOraclePackage extends BasePackageEntry {
77
95
  export interface OraclePackages {
78
96
  pyth_rule: PythRulePackage;
79
97
  pyth_sponsor_rule?: PythSponsorRulePackage;
98
+ /** See {@link PythLazerRulePackage} — typed only, not read for routing. */
99
+ pyth_lazer_rule?: PythLazerRulePackage;
80
100
  constant_rule?: WaterxConstantRulePackage;
81
101
  supra_rule?: SupraRulePackage;
82
102
  waterx_oracle: WaterxOraclePackage;
@@ -85,8 +105,91 @@ export interface PythInfraConfig {
85
105
  state_id: string;
86
106
  wormhole_state_id: string;
87
107
  hermes_endpoint: string;
108
+ /**
109
+ * Pyth Pro / Lazer access token (`Authorization: Bearer …`) for
110
+ * `PythLazerRule`'s signed-update fetch — Lazer is auth-first, so there is
111
+ * no keyless default. Optional: Pyth-Core-only deployments never need it.
112
+ * Consumers pass it through client config (`config.pyth`); the SDK never
113
+ * reads `process.env`. Absent when a lazer-routed fetch runs →
114
+ * `LazerApiKeyMissing` is thrown at fetch time. As of the Pyth Pro
115
+ * migration (post-2026-08-18, per
116
+ * https://docs.pyth.network/price-feeds/core/upgrade) this is ALSO required
117
+ * for `pyth_rule`'s Hermes fetch (`fetchPriceFeedsUpdateData`) — see
118
+ * `fetch` below.
119
+ */
120
+ api_key?: string;
121
+ /**
122
+ * Retry/timeout policy override for the Hermes (`fetchPriceFeedsUpdateData`)
123
+ * and Lazer (`PythLazerRule`) off-chain update fetches — see
124
+ * `fetchWithPolicy` (`./update-fetch.ts`) for the full policy (backoff,
125
+ * which statuses retry, Bearer attachment). Optional: both fetches default
126
+ * to `fetchWithPolicy`'s built-in defaults (15s timeout, 2 retries) when
127
+ * unset.
128
+ */
129
+ fetch?: {
130
+ timeoutMs?: number;
131
+ retries?: number;
132
+ };
88
133
  }
89
134
  export declare const PYTH_DEFAULTS: Record<Network, PythInfraConfig>;
135
+ /**
136
+ * Which Pyth Core contract generation feeds `host.pyth` when the config JSON
137
+ * carries no explicit `pyth` override:
138
+ *
139
+ * - `'core'` (default) — the original contracts + keyless Hermes
140
+ * ({@link PYTH_DEFAULTS}).
141
+ * - `'pro'` — the Pro-compatible upgraded contracts + the Hermes-compatible
142
+ * endpoint ({@link PYTH_PRO_DEFAULTS}); pair with `pyth.api_key` after the
143
+ * 2026-08-18 cutover.
144
+ *
145
+ * Resolved once at client creation from the `pythGeneration` create option.
146
+ * Orthogonal to `oracleSource` — this flips the Pyth-Core *infra* (state ids
147
+ * + endpoint), not which `PriceUpdateRule` routes tickers.
148
+ */
149
+ export type PythGeneration = "core" | "pro";
150
+ /**
151
+ * Pyth **Pro-generation** Core-compatible infra — the post-2026-08-18
152
+ * contracts (cutover date per
153
+ * https://docs.pyth.network/price-feeds/core/upgrade) from Pyth's Core-Upgrade
154
+ * docs
155
+ * (https://docs.pyth.network/price-feeds/core/upgrade/contracts, Sui section;
156
+ * package revs `sui-pro-compatible-contract-mainnet` /
157
+ * `sui-pro-compatible-contract-testnet`). Selected via the client's
158
+ * `pythGeneration: 'pro'` create option; `config.pyth` still overrides
159
+ * wholesale (see `PerpClient`). All four state ids were verified on-chain
160
+ * (shared `state::State` objects under the docs' upgraded package ids).
161
+ *
162
+ * Kept as a second flat map beside {@link PYTH_DEFAULTS} rather than a nested
163
+ * `PYTH_INFRA[network][generation]` — `PYTH_DEFAULTS` is a published export
164
+ * with external consumers, so the smallest honest surface is an additive
165
+ * sibling (same deferral note as {@link LAZER_DEFAULTS}).
166
+ *
167
+ * The Hermes-compatible endpoint (`pyth.dourolabs.app/hermes`) serves the
168
+ * same REST surface as `hermes.pyth.network` but requires `pyth.api_key`
169
+ * (`Authorization: Bearer …`) after the cutover — see
170
+ * {@link PythInfraConfig.api_key}.
171
+ */
172
+ export declare const PYTH_PRO_DEFAULTS: Record<Network, PythInfraConfig>;
173
+ /**
174
+ * Pyth Lazer (Pyth Pro) external infra the `PythLazerRule` needs, by network.
175
+ * Mirrors {@link PYTH_DEFAULTS}: per-network constants for infrastructure Pyth
176
+ * operates (not part of the `waterx-config` JSON). A fuller `PYTH_INFRA`
177
+ * restructure is deferred — this stays a minimal map until then.
178
+ *
179
+ * - `endpoint` — Lazer HTTP API base; signed updates come from
180
+ * `POST /v1/latest_price` (Bearer-authenticated). The service is
181
+ * network-agnostic (one signed payload verifies on any chain that trusts the
182
+ * Lazer signers), so both networks share the production host.
183
+ * - `verifier_package` — the Sui package carrying
184
+ * `pyth_lazer::parse_and_verify_le_ecdsa_update`. Per-network: testnet is
185
+ * still the original v1 publish; mainnet is the v2-upgraded package (which
186
+ * still exposes the v1 entry `pyth_lazer_rule` binds). Values mirror the
187
+ * contract repo's `pyth_lazer_rule/Move.toml` published-at pins.
188
+ */
189
+ export declare const LAZER_DEFAULTS: Record<Network, {
190
+ endpoint: string;
191
+ verifier_package: string;
192
+ }>;
90
193
  /**
91
194
  * The narrow config shape the oracle/refresh code needs. `WaterXConfig`
92
195
  * (the perp line's full config) is assignable to this, so `PerpClient` satisfies