@waterx/sdk 3.1.1 → 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 (76) hide show
  1. package/README.md +54 -18
  2. package/dist/cjs/src/account/config.d.ts +0 -16
  3. package/dist/cjs/src/oracle/aggregate.d.ts +96 -17
  4. package/dist/cjs/src/oracle/aggregate.js +179 -21
  5. package/dist/cjs/src/oracle/config.d.ts +85 -2
  6. package/dist/cjs/src/oracle/config.js +30 -1
  7. package/dist/cjs/src/oracle/host.d.ts +14 -1
  8. package/dist/cjs/src/oracle/index.d.ts +19 -5
  9. package/dist/cjs/src/oracle/index.js +54 -6
  10. package/dist/cjs/src/oracle/price-update-rule.d.ts +180 -0
  11. package/dist/cjs/src/oracle/price-update-rule.js +56 -0
  12. package/dist/cjs/src/oracle/pyth.d.ts +142 -11
  13. package/dist/cjs/src/oracle/pyth.js +408 -25
  14. package/dist/cjs/src/oracle/rule-registry.d.ts +41 -0
  15. package/dist/cjs/src/oracle/rule-registry.js +65 -0
  16. package/dist/cjs/src/oracle/rules/pyth-core-rule.d.ts +15 -0
  17. package/dist/cjs/src/oracle/rules/pyth-core-rule.js +99 -0
  18. package/dist/cjs/src/oracle/rules/pyth-lazer-rule.d.ts +41 -0
  19. package/dist/cjs/src/oracle/rules/pyth-lazer-rule.js +197 -0
  20. package/dist/cjs/src/oracle/rules/pyth-rule.js +5 -0
  21. package/dist/cjs/src/oracle/rules/sponsor.d.ts +11 -7
  22. package/dist/cjs/src/oracle/rules/sponsor.js +11 -7
  23. package/dist/cjs/src/oracle/update-fetch.d.ts +115 -0
  24. package/dist/cjs/src/oracle/update-fetch.js +285 -0
  25. package/dist/cjs/src/perp/client.d.ts +40 -5
  26. package/dist/cjs/src/perp/client.js +18 -3
  27. package/dist/cjs/src/perp/config.d.ts +10 -7
  28. package/dist/cjs/src/perp/config.js +77 -17
  29. package/dist/cjs/src/perp/index.d.ts +3 -2
  30. package/dist/cjs/src/perp/index.js +6 -2
  31. package/dist/cjs/src/perp/tx-builders/common.d.ts +52 -15
  32. package/dist/cjs/src/perp/tx-builders/common.js +39 -6
  33. package/dist/cjs/src/perp/tx-builders/wlp.d.ts +11 -3
  34. package/dist/cjs/src/perp/tx-builders/wlp.js +29 -3
  35. package/dist/cjs/src/perp/tx-builders.d.ts +3 -3
  36. package/dist/cjs/src/perp/tx-builders.js +3 -3
  37. package/dist/cjs/src/unified-client.d.ts +25 -0
  38. package/dist/cjs/src/unified-client.js +3 -0
  39. package/dist/src/account/config.d.ts +0 -16
  40. package/dist/src/oracle/aggregate.d.ts +96 -17
  41. package/dist/src/oracle/aggregate.js +180 -22
  42. package/dist/src/oracle/config.d.ts +85 -2
  43. package/dist/src/oracle/config.js +29 -0
  44. package/dist/src/oracle/host.d.ts +14 -1
  45. package/dist/src/oracle/index.d.ts +19 -5
  46. package/dist/src/oracle/index.js +42 -6
  47. package/dist/src/oracle/price-update-rule.d.ts +180 -0
  48. package/dist/src/oracle/price-update-rule.js +53 -0
  49. package/dist/src/oracle/pyth.d.ts +142 -11
  50. package/dist/src/oracle/pyth.js +402 -24
  51. package/dist/src/oracle/rule-registry.d.ts +41 -0
  52. package/dist/src/oracle/rule-registry.js +60 -0
  53. package/dist/src/oracle/rules/pyth-core-rule.d.ts +15 -0
  54. package/dist/src/oracle/rules/pyth-core-rule.js +96 -0
  55. package/dist/src/oracle/rules/pyth-lazer-rule.d.ts +41 -0
  56. package/dist/src/oracle/rules/pyth-lazer-rule.js +192 -0
  57. package/dist/src/oracle/rules/pyth-rule.js +5 -0
  58. package/dist/src/oracle/rules/sponsor.d.ts +11 -7
  59. package/dist/src/oracle/rules/sponsor.js +11 -7
  60. package/dist/src/oracle/update-fetch.d.ts +115 -0
  61. package/dist/src/oracle/update-fetch.js +277 -0
  62. package/dist/src/perp/client.d.ts +40 -5
  63. package/dist/src/perp/client.js +18 -3
  64. package/dist/src/perp/config.d.ts +10 -7
  65. package/dist/src/perp/config.js +77 -17
  66. package/dist/src/perp/index.d.ts +3 -2
  67. package/dist/src/perp/index.js +1 -1
  68. package/dist/src/perp/tx-builders/common.d.ts +52 -15
  69. package/dist/src/perp/tx-builders/common.js +39 -6
  70. package/dist/src/perp/tx-builders/wlp.d.ts +11 -3
  71. package/dist/src/perp/tx-builders/wlp.js +29 -3
  72. package/dist/src/perp/tx-builders.d.ts +3 -3
  73. package/dist/src/perp/tx-builders.js +3 -3
  74. package/dist/src/unified-client.d.ts +25 -0
  75. package/dist/src/unified-client.js +3 -0
  76. package/package.json +1 -1
package/README.md CHANGED
@@ -14,11 +14,12 @@ import { WaterXClient } from "@waterx/sdk";
14
14
  // waterxConfigUrl is REQUIRED — the SDK has no built-in default and never reads env.
15
15
  const client = await WaterXClient.create({
16
16
  network: "TESTNET",
17
- waterxConfigUrl: "https://raw.githubusercontent.com/WaterXProtocol/waterx-config/main/testnet.json",
17
+ waterxConfigUrl:
18
+ "https://raw.githubusercontent.com/WaterXProtocol/waterx-config/main/testnet.json",
18
19
  });
19
- client.account.createAccount(tx, { alias }); // shared waterx_account + funding (credit/custody)
20
- client.perp.buildPlaceOrderTx(params); // perpetuals
21
- client.predict.placeOrder(tx, params); // prediction markets
20
+ client.account.createAccount(tx, { alias }); // shared waterx_account + funding (credit/custody)
21
+ client.perp.buildPlaceOrderTx(params); // perpetuals
22
+ client.predict.placeOrder(tx, params); // prediction markets
22
23
  // client.perp / client.predict ARE the line clients — sign/execute on them directly:
23
24
  // await client.perp.signAndExecuteTransaction({ transaction: tx, signer })
24
25
  // each line can target a different network + URL:
@@ -29,11 +30,11 @@ client.predict.placeOrder(tx, params); // prediction markets
29
30
 
30
31
  Import surfaces:
31
32
 
32
- | Import | What |
33
- |--------|------|
34
- | `@waterx/sdk` | `WaterXClient` (umbrella) + `perp` / `prediction` namespaces. Perp's API is also re-exported flat here (**deprecated** — prefer `client.perp` or the `perp` namespace; removed next major). |
35
- | `@waterx/sdk/perp` | Perp line: `PerpClient`, builders, fetch, Pyth/Wormhole utils. |
36
- | `@waterx/sdk/prediction` | Prediction line: `PredictClient`, builders, fetch, utils. |
33
+ | Import | What |
34
+ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
35
+ | `@waterx/sdk` | `WaterXClient` (umbrella) + `perp` / `prediction` namespaces. Perp's API is also re-exported flat here (**deprecated** — prefer `client.perp` or the `perp` namespace; removed next major). |
36
+ | `@waterx/sdk/perp` | Perp line: `PerpClient`, builders, fetch, Pyth/Wormhole utils. |
37
+ | `@waterx/sdk/prediction` | Prediction line: `PredictClient`, builders, fetch, utils. |
37
38
 
38
39
  ## Install
39
40
 
@@ -99,6 +100,41 @@ const predict = await PredictClient.create("TESTNET", { waterxConfigUrl }); // o
99
100
 
100
101
  Read-only queries use gRPC `simulateTransaction` (no signer) — the `getX` view helpers, e.g. `await perp.simulate(tx)` or `getMarketData(perp, …)`.
101
102
 
103
+ ## Oracle sources
104
+
105
+ ONE client create option, `oracleSource`, selects the price-update source. Each source is **self-contained** — it owns its own infra + config and does **not** back-stop any other source. The name is source-neutral on purpose: a future source need not be Pyth. The SDK **never reads `process.env`** — each consumer wires it from its own env var, so every environment runs the **same SDK version** and differs only by env:
106
+
107
+ | Option | Values | What it selects |
108
+ | -------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
109
+ | `oracleSource` | `'pyth_rule'` (default) \| `'pyth_lazer_rule'` | The price-update source. `'pyth_rule'` = Pyth Core updates (Hermes VAA + per-feed update fees, keyless `hermes.pyth.network`). `'pyth_lazer_rule'` = Pyth Lazer signed updates (ONE `leEcdsa` verify per PTB, no per-feed fees); needs `packages.pyth_lazer_rule` feeds + a `pythApiKey`. |
110
+
111
+ **No cross-source fallback, no init guard.** Selecting a source whose feed for a requested ticker is absent is **not** an error at client creation — it fails at **tx-build** for exactly those tickers (constant-only tickers, which need no price update, are exempt). A present-but-wrong feed id is not validated by the SDK; it aborts on-chain at dry-run.
112
+
113
+ The Pyth Core infra (`client.pyth`: state ids + Hermes endpoint) is **fixed per network** by `PYTH_DEFAULTS` and is **not** deployment-overridable — the canonical `waterx-config` JSON carries no `pyth` block. The `pyth_lazer_rule` source reads only the credential/policy (`api_key`/`fetch`) from `client.pyth`; its on-chain infra comes from `LAZER_DEFAULTS` + config. The credential and fetch policy are passed at client init (`pythApiKey` / `pythFetch`), never through the JSON.
114
+
115
+ ```ts
116
+ // Per-environment wiring — the consumer owns the env var, not the SDK:
117
+ const perp = await PerpClient.create(network, {
118
+ waterxConfigUrl,
119
+ oracleSource: process.env.ORACLE_SOURCE as OracleSource | undefined, // e.g. staging: 'pyth_lazer_rule'
120
+ pythApiKey: process.env.PYTH_API_KEY, // Lazer is auth-first; unused by 'pyth_rule'
121
+ });
122
+ ```
123
+
124
+ This is the staging-Lazer / prod-Core rollout pattern: staging sets `ORACLE_SOURCE=pyth_lazer_rule` (+ `PYTH_API_KEY`) while production leaves it unset — flipping an environment is an env-var change, never an SDK release.
125
+
126
+ ### Adding an oracle source (runbook)
127
+
128
+ Every source 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`.
134
+ 5. **Consumers flip `oracleSource`** 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:
@@ -111,14 +147,14 @@ Perp `build*Tx` helpers are Pyth-backed (`async`; they refresh feeds before the
111
147
 
112
148
  ## Development
113
149
 
114
- | Command | Use |
115
- | --- | --- |
116
- | `pnpm typecheck` | Typecheck the whole tree |
117
- | `pnpm test` / `pnpm test:unit` | Unit tests (perp + prediction) |
118
- | `pnpm test:e2e` | Testnet simulate e2e (perp + prediction) |
119
- | `pnpm test:integration` | On-chain integration (needs `SUI_PRIVATE_KEY`; local-only) |
120
- | `pnpm lint` / `pnpm format` | ESLint + Prettier |
121
- | `pnpm codegen` | Regenerate `src/generated` from Move |
122
- | `pnpm seed:testnet` | Seed prediction testnet fixtures (needs `SUI_PRIVATE_KEY`) |
150
+ | Command | Use |
151
+ | ------------------------------ | ---------------------------------------------------------- |
152
+ | `pnpm typecheck` | Typecheck the whole tree |
153
+ | `pnpm test` / `pnpm test:unit` | Unit tests (perp + prediction) |
154
+ | `pnpm test:e2e` | Testnet simulate e2e (perp + prediction) |
155
+ | `pnpm test:integration` | On-chain integration (needs `SUI_PRIVATE_KEY`; local-only) |
156
+ | `pnpm lint` / `pnpm format` | ESLint + Prettier |
157
+ | `pnpm codegen` | Regenerate `src/generated` from Move |
158
+ | `pnpm seed:testnet` | Seed prediction testnet fixtures (needs `SUI_PRIVATE_KEY`) |
123
159
 
124
160
  Tests are split per line under `test/perp/` and `test/prediction/`, each with `unit` / `e2e` / `integration` tiers. See the per-line `README.md` in each.
@@ -48,28 +48,12 @@ export interface NativeCustodyPackage {
48
48
  /** Backing assets registered via `add_asset` (array, identified by `type`). */
49
49
  assets: NativeCustodyAsset[];
50
50
  }
51
- export interface TrustedEmitterRow {
52
- /** Source EVM chain's Wormhole chain id (e.g. 10002 = Sepolia). */
53
- chain_id: number;
54
- /** 32-byte left-padded EVM bridge address (0x form). */
55
- evm_bridge_address_32b: string;
56
- /** Whitelisted 20-byte EVM token addresses (0x form). */
57
- evm_tokens_20b: string[];
58
- }
59
51
  export interface WormholeBridgePackage {
60
52
  published_at: string;
61
53
  /** Shared Sui Wormhole `State` object id for this deployment. */
62
54
  wormhole_state: string;
63
- hourly_mint_limit?: string;
64
55
  max_mint_per_tx?: string;
65
- hourly_burn_limit?: string;
66
56
  max_burn_per_tx?: string;
67
- /**
68
- * @deprecated EVM emitter↔token config now lives solely under `evm.bridge.chains`
69
- * (deposit_vault = emitter, wormhole_chain_id = chain key). The runtime allowlist is
70
- * read from the on-chain `Bridge` object, not from config. Kept optional for back-compat.
71
- */
72
- trusted_emitters?: TrustedEmitterRow[];
73
57
  /** Shared `Bridge` (phase-5 output). */
74
58
  bridge?: string;
75
59
  /**
@@ -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,75 @@ 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 ONE selected source serves every ticker in its
87
+ * `supportedTickers(host)`. There is **no cross-source fallback** — a requested
88
+ * ticker the selected source does not serve, and that is not a constant-only
89
+ * ticker (which needs no price-update leg), fails the build immediately with a
90
+ * clear error naming the ticker and source. That is the deliberate "fail the
91
+ * tx-build, don't silently reroute" contract: a wrong-but-present feed id is
92
+ * NOT validated here (it surfaces on-chain at dry-run); a MISSING feed for the
93
+ * selected source is caught here. When the selected source's feed exists but is
94
+ * wrong, this function does nothing special — the on-chain aggregate aborts at
95
+ * dry-run, which is correct.
96
+ *
97
+ * The selected source's fetch + build runs against its own infra, guaranteeing
98
+ * per-rule PTB atomicity. A fee-source pre-check runs first (the source's
99
+ * `requiresFeeSource`) BEFORE any off-chain fetch or PTB mutation — so a
100
+ * fee-charging source with no `opts.feeSource` throws
101
+ * `OracleFeeSourceUnavailable` with zero wasted network calls and zero stray
102
+ * moveCalls. Only once that check passes does the off-chain fetch run and
103
+ * complete before any PTB mutation; on-chain reads inside `buildUpdateCalls`
104
+ * can still fail mid-append for other reasons — callers discard the tx on any
105
+ * throw.
106
+ *
107
+ * **Collector-feed leg is rule-aware:** a lazer-served group's
108
+ * `buildUpdateCalls` returns the verified `Update` PTB value
109
+ * ({@link RuleUpdateHandle}), and every ticker in that group is aggregated
110
+ * with `lazerUpdate` set so {@link aggregateTicker} appends
111
+ * `pyth_lazer_rule::feed` against it. A lazer-routed ticker that still has a
112
+ * `pyth_rule.feeds` entry ALSO keeps its `pyth_rule::feed` leg — required
113
+ * on-chain while `pyth_rule` stays in the ticker's weighted set
114
+ * (`aggregator::remove_outliers` aborts `EMissingPriceSource` unless every
115
+ * weighted rule appears in the collector; an abstention counts as
116
+ * appearing), and safe: `pyth_rule::feed` only READS the `PriceInfoObject`
117
+ * this PTB never refreshed and abstains when it is stale rather than
118
+ * aborting. Conversely, a lazer feed call on an aggregator that does not
119
+ * (yet) weight `PythLazerRule` is silently dropped on-chain — so
120
+ * lazer-routing a ticker ahead of its on-chain weight migration prices it
121
+ * from the remaining weighted rules instead of failing.
67
122
  */
68
123
  export declare function refreshOraclePrices(tx: Transaction, host: OracleHost, tickers: string[], opts?: {
69
124
  cache?: PythCache;
70
- sponsorFund?: {
71
- fund: TransactionArgument;
72
- packageId: string;
73
- };
125
+ /**
126
+ * The single resolved fee source for the Pyth update fee, forwarded
127
+ * verbatim to each group's `PriceUpdateRule.buildUpdateCalls` as
128
+ * `BuildUpdateOpts.feeSource`. Already-resolved by the caller (see
129
+ * {@link OracleFeeSource}'s own doc for where/how) — this function makes
130
+ * no sponsor-vs-gas decision itself, it only checks whether a source was
131
+ * resolved at all. Ignored by rules with no update fee (e.g.
132
+ * `pyth_lazer_rule`). Building with `feeSource` unset throws
133
+ * `OracleFeeSourceUnavailable` (see `oracle/pyth.ts`) instead of
134
+ * silently drawing from `tx.gas`.
135
+ */
136
+ feeSource?: OracleFeeSource;
137
+ /**
138
+ * @internal Test-only: layer fake `PriceUpdateRule`s on top of the
139
+ * production registry (see `rule-registry.ts`'s `resolveOracleRule`).
140
+ * Production callers never set this — routing is by `host.oracleSource`
141
+ * alone.
142
+ */
143
+ ruleOverrides?: Partial<Record<OracleSource, PriceUpdateRule>>;
144
+ /**
145
+ * BE prefetch-cache seam: checked per group BEFORE that group's live
146
+ * `rule.fetchUpdateData`. See {@link UpdateDataProvider}. A cache miss
147
+ * (`null`) or a throw from the provider falls back to the live fetch —
148
+ * a degraded/broken cache must never break the money path; a
149
+ * kind-mismatched hit (the provider handed back the wrong rule's
150
+ * payload) throws instead, since that is a caller bug, not a cache miss.
151
+ */
152
+ updateDataProvider?: UpdateDataProvider;
74
153
  }): 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,116 @@ 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 ONE selected source serves every ticker in its
169
+ * `supportedTickers(host)`. There is **no cross-source fallback** — a requested
170
+ * ticker the selected source does not serve, and that is not a constant-only
171
+ * ticker (which needs no price-update leg), fails the build immediately with a
172
+ * clear error naming the ticker and source. That is the deliberate "fail the
173
+ * tx-build, don't silently reroute" contract: a wrong-but-present feed id is
174
+ * NOT validated here (it surfaces on-chain at dry-run); a MISSING feed for the
175
+ * selected source is caught here. When the selected source's feed exists but is
176
+ * wrong, this function does nothing special — the on-chain aggregate aborts at
177
+ * dry-run, which is correct.
178
+ *
179
+ * The selected source's fetch + build runs against its own infra, guaranteeing
180
+ * per-rule PTB atomicity. A fee-source pre-check runs first (the source's
181
+ * `requiresFeeSource`) BEFORE any off-chain fetch or PTB mutation — so a
182
+ * fee-charging source with no `opts.feeSource` throws
183
+ * `OracleFeeSourceUnavailable` with zero wasted network calls and zero stray
184
+ * moveCalls. Only once that check passes does the off-chain fetch run and
185
+ * complete before any PTB mutation; on-chain reads inside `buildUpdateCalls`
186
+ * can still fail mid-append for other reasons — callers discard the tx on any
187
+ * throw.
188
+ *
189
+ * **Collector-feed leg is rule-aware:** a lazer-served group's
190
+ * `buildUpdateCalls` returns the verified `Update` PTB value
191
+ * ({@link RuleUpdateHandle}), and every ticker in that group is aggregated
192
+ * with `lazerUpdate` set so {@link aggregateTicker} appends
193
+ * `pyth_lazer_rule::feed` against it. A lazer-routed ticker that still has a
194
+ * `pyth_rule.feeds` entry ALSO keeps its `pyth_rule::feed` leg — required
195
+ * on-chain while `pyth_rule` stays in the ticker's weighted set
196
+ * (`aggregator::remove_outliers` aborts `EMissingPriceSource` unless every
197
+ * weighted rule appears in the collector; an abstention counts as
198
+ * appearing), and safe: `pyth_rule::feed` only READS the `PriceInfoObject`
199
+ * this PTB never refreshed and abstains when it is stale rather than
200
+ * aborting. Conversely, a lazer feed call on an aggregator that does not
201
+ * (yet) weight `PythLazerRule` is silently dropped on-chain — so
202
+ * lazer-routing a ticker ahead of its on-chain weight migration prices it
203
+ * from the remaining weighted rules instead of failing.
101
204
  */
102
205
  async function refreshOraclePrices(tx, host, tickers, opts = {}) {
103
206
  if (tickers.length === 0)
104
207
  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.
208
+ // price_info_object lookup for every ticker with a pyth_rule.feeds entry
209
+ // needed by aggregateTicker's (unchanged) Pyth feed step below regardless of
210
+ // which rule performed the on-chain update for that ticker.
107
211
  const pythTickers = tickers.filter((t) => host.config.packages.pyth_rule?.feeds?.[t] !== undefined);
108
212
  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));
213
+ pythTickers.forEach((t) => priceInfoByTicker.set(t, host.getPythFeed(t).price_info_object));
214
+ // ONE source, no fallback. The selected source serves the tickers in its
215
+ // `supportedTickers(host)`; `source` is tracked alongside the group (rather
216
+ // than read back off `rule.kind`, typed as the broader PriceUpdateRuleKind)
217
+ // so the provider lookup below has an OracleSource to key on without a cast.
218
+ const selectedRule = (0, rule_registry_ts_1.resolveOracleRule)(host.oracleSource, opts.ruleOverrides);
219
+ const selectedSupported = new Set(selectedRule.supportedTickers(host));
220
+ // Fail the tx-build (NOT client init, NOT a silent reroute) when the selected
221
+ // source has no feed for a requested ticker that actually needs a price
222
+ // update. Only a CONSTANT-ONLY ticker is exempt — priced entirely by
223
+ // `constant_rule`, it needs no update leg from any source. A DUAL-FEED ticker
224
+ // (constant AND pyth) still needs its Pyth leg refreshed, so `isConstantTicker`
225
+ // alone must NOT exempt it: under a source that can't serve it, with no
226
+ // fallback, feeding an unrefreshed Pyth leg would price it stale (or abort on a
227
+ // missing weighted source). `priceInfoByTicker.has(t)` ⇔ the ticker has a
228
+ // `pyth_rule.feeds` entry, so `constant && !hasPyth` is exactly constant-only.
229
+ // This catches a MISSING feed; a present-but-WRONG feed id is deliberately not
230
+ // validated here (it aborts on-chain at dry-run).
231
+ const isConstantOnly = (t) => host.isConstantTicker(t) && !priceInfoByTicker.has(t);
232
+ const unservable = tickers.filter((t) => !selectedSupported.has(t) && !isConstantOnly(t));
233
+ if (unservable.length > 0) {
234
+ throw new Error(`oracleSource '${host.oracleSource}' has no feed configured for ticker(s): ` +
235
+ `${unservable.join(", ")}. Sources are self-contained with no fallback — add ` +
236
+ `${host.oracleSource} feeds for them, or select a source that serves them.`);
237
+ }
238
+ const selectedGroup = tickers.filter((t) => selectedSupported.has(t));
239
+ // Fee-source pre-check, hoisted ABOVE the off-chain fetch and PTB build below.
240
+ // It consults only `rule.requiresFeeSource` — known before any fetch or PTB
241
+ // mutation — so a fee-charging source with no `feeSource` throws with ZERO
242
+ // wasted network calls and zero PTB commands, rather than waiting for
243
+ // `buildPythPriceUpdateCalls`'s own per-call guard to fire after the off-chain
244
+ // fetch already ran.
245
+ if (selectedGroup.length > 0 && !opts.feeSource && selectedRule.requiresFeeSource) {
246
+ throw new pyth_ts_1.OracleFeeSourceUnavailableError();
247
+ }
248
+ // Resolve + build the selected source's update leg. The off-chain fetch
249
+ // settles before the first PTB mutation, so a fetch failure never strands
250
+ // moveCalls in a caller-owned tx. Map each lazer-served ticker to the one
251
+ // verified `Update` PTB value for the collector-feed leg below.
252
+ const lazerUpdateByTicker = new Map();
253
+ if (selectedGroup.length > 0) {
254
+ const group = { source: host.oracleSource, rule: selectedRule, tickers: selectedGroup };
255
+ const data = await resolveGroupUpdateData(host, group, opts.updateDataProvider);
256
+ const handle = (await selectedRule.buildUpdateCalls(tx, host, data, {
257
+ cache: opts.cache,
258
+ feeSource: opts.feeSource,
259
+ })) ?? undefined;
260
+ // Route by the handle's kind discriminant — the one site the tag exists to
261
+ // protect: a future non-lazer handle (e.g. a WaterxRule value) must never
262
+ // be silently fed into pyth_lazer_rule::feed.
263
+ if (handle?.kind === "pyth_lazer_rule") {
264
+ for (const ticker of selectedGroup)
265
+ lazerUpdateByTicker.set(ticker, handle.update);
266
+ }
113
267
  }
114
268
  // Aggregate each ticker, feeding whichever rules it is configured for.
115
269
  for (const ticker of tickers) {
116
- aggregateTicker(tx, host, { ticker, priceInfoObjectId: priceInfoByTicker.get(ticker) });
270
+ aggregateTicker(tx, host, {
271
+ ticker,
272
+ priceInfoObjectId: priceInfoByTicker.get(ticker),
273
+ lazerUpdate: lazerUpdateByTicker.get(ticker),
274
+ });
117
275
  }
118
276
  }