@waterx/sdk 4.3.2 → 5.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 (150) hide show
  1. package/.claude/skills/waterx-sdk-integration/SKILL.md +225 -0
  2. package/README.md +216 -45
  3. package/SKILLS.md +34 -0
  4. package/dist/cjs/src/account/config.d.ts +3 -5
  5. package/dist/cjs/src/account/funding/wormhole.d.ts +1 -2
  6. package/dist/cjs/src/account/funding/wormhole.js +1 -2
  7. package/dist/cjs/src/generated/waterx_rule/waterx_rule.d.ts +140 -7
  8. package/dist/cjs/src/generated/waterx_rule/waterx_rule.js +163 -8
  9. package/dist/cjs/src/oracle/aggregate.d.ts +49 -62
  10. package/dist/cjs/src/oracle/aggregate.js +208 -164
  11. package/dist/cjs/src/oracle/config.d.ts +38 -51
  12. package/dist/cjs/src/oracle/config.js +1 -1
  13. package/dist/cjs/src/oracle/host.d.ts +14 -19
  14. package/dist/cjs/src/oracle/host.js +3 -3
  15. package/dist/cjs/src/oracle/index.d.ts +25 -16
  16. package/dist/cjs/src/oracle/index.js +87 -69
  17. package/dist/cjs/src/oracle/price-update-rule.d.ts +104 -66
  18. package/dist/cjs/src/oracle/price-update-rule.js +24 -16
  19. package/dist/cjs/src/oracle/pyth-pro-history.d.ts +36 -0
  20. package/dist/cjs/src/oracle/pyth-pro-history.js +41 -0
  21. package/dist/cjs/src/oracle/read-plane.d.ts +35 -53
  22. package/dist/cjs/src/oracle/read-plane.js +26 -48
  23. package/dist/cjs/src/oracle/read-prices.d.ts +105 -0
  24. package/dist/cjs/src/oracle/read-prices.js +162 -0
  25. package/dist/cjs/src/oracle/rule-registry.d.ts +12 -12
  26. package/dist/cjs/src/oracle/rule-registry.js +12 -14
  27. package/dist/cjs/src/oracle/rules/pyth-lazer-rule.d.ts +24 -3
  28. package/dist/cjs/src/oracle/rules/pyth-lazer-rule.js +40 -20
  29. package/dist/cjs/src/oracle/rules/waterx-rule.d.ts +271 -57
  30. package/dist/cjs/src/oracle/rules/waterx-rule.js +743 -144
  31. package/dist/cjs/src/oracle/schedule.d.ts +109 -0
  32. package/dist/cjs/src/oracle/schedule.js +991 -0
  33. package/dist/cjs/src/oracle/source-list.d.ts +31 -27
  34. package/dist/cjs/src/oracle/source-list.js +43 -45
  35. package/dist/cjs/src/oracle/symbol-catalog.d.ts +54 -0
  36. package/dist/cjs/src/oracle/symbol-catalog.js +69 -0
  37. package/dist/cjs/src/oracle/update-fetch.d.ts +11 -15
  38. package/dist/cjs/src/oracle/update-fetch.js +12 -19
  39. package/dist/cjs/src/oracle/validate.d.ts +105 -0
  40. package/dist/cjs/src/oracle/validate.js +185 -0
  41. package/dist/cjs/src/oracle/weight-coverage.d.ts +79 -0
  42. package/dist/cjs/src/oracle/weight-coverage.js +173 -0
  43. package/dist/cjs/src/perp/client.d.ts +40 -55
  44. package/dist/cjs/src/perp/client.js +44 -17
  45. package/dist/cjs/src/perp/config-view.d.ts +5 -14
  46. package/dist/cjs/src/perp/config-view.js +5 -16
  47. package/dist/cjs/src/perp/config.d.ts +3 -3
  48. package/dist/cjs/src/perp/config.js +11 -13
  49. package/dist/cjs/src/perp/index.d.ts +4 -6
  50. package/dist/cjs/src/perp/index.js +43 -18
  51. package/dist/cjs/src/perp/tx-builders/common.d.ts +86 -48
  52. package/dist/cjs/src/perp/tx-builders/common.js +115 -47
  53. package/dist/cjs/src/perp/tx-builders/wlp.d.ts +3 -14
  54. package/dist/cjs/src/perp/tx-builders/wlp.js +6 -35
  55. package/dist/cjs/src/perp/tx-builders.d.ts +6 -5
  56. package/dist/cjs/src/perp/tx-builders.js +16 -10
  57. package/dist/cjs/src/unified-client.d.ts +9 -35
  58. package/dist/cjs/src/unified-client.js +3 -4
  59. package/dist/cjs/src/utils/config.d.ts +0 -8
  60. package/dist/cjs/src/utils/config.js +5 -13
  61. package/dist/src/account/config.d.ts +3 -5
  62. package/dist/src/account/funding/wormhole.d.ts +1 -2
  63. package/dist/src/account/funding/wormhole.js +1 -2
  64. package/dist/src/generated/waterx_rule/waterx_rule.d.ts +140 -7
  65. package/dist/src/generated/waterx_rule/waterx_rule.js +151 -7
  66. package/dist/src/oracle/aggregate.d.ts +49 -62
  67. package/dist/src/oracle/aggregate.js +209 -164
  68. package/dist/src/oracle/config.d.ts +38 -51
  69. package/dist/src/oracle/config.js +1 -1
  70. package/dist/src/oracle/host.d.ts +14 -19
  71. package/dist/src/oracle/host.js +3 -3
  72. package/dist/src/oracle/index.d.ts +25 -16
  73. package/dist/src/oracle/index.js +61 -54
  74. package/dist/src/oracle/price-update-rule.d.ts +104 -66
  75. package/dist/src/oracle/price-update-rule.js +23 -16
  76. package/dist/src/oracle/pyth-pro-history.d.ts +36 -0
  77. package/dist/src/oracle/pyth-pro-history.js +38 -0
  78. package/dist/src/oracle/read-plane.d.ts +35 -53
  79. package/dist/src/oracle/read-plane.js +25 -47
  80. package/dist/src/oracle/read-prices.d.ts +105 -0
  81. package/dist/src/oracle/read-prices.js +156 -0
  82. package/dist/src/oracle/rule-registry.d.ts +12 -12
  83. package/dist/src/oracle/rule-registry.js +12 -14
  84. package/dist/src/oracle/rules/pyth-lazer-rule.d.ts +24 -3
  85. package/dist/src/oracle/rules/pyth-lazer-rule.js +39 -20
  86. package/dist/src/oracle/rules/waterx-rule.d.ts +271 -57
  87. package/dist/src/oracle/rules/waterx-rule.js +735 -144
  88. package/dist/src/oracle/schedule.d.ts +109 -0
  89. package/dist/src/oracle/schedule.js +985 -0
  90. package/dist/src/oracle/source-list.d.ts +31 -27
  91. package/dist/src/oracle/source-list.js +42 -43
  92. package/dist/src/oracle/symbol-catalog.d.ts +54 -0
  93. package/dist/src/oracle/symbol-catalog.js +65 -0
  94. package/dist/src/oracle/update-fetch.d.ts +11 -15
  95. package/dist/src/oracle/update-fetch.js +12 -18
  96. package/dist/src/oracle/validate.d.ts +105 -0
  97. package/dist/src/oracle/validate.js +177 -0
  98. package/dist/src/oracle/weight-coverage.d.ts +79 -0
  99. package/dist/src/oracle/weight-coverage.js +166 -0
  100. package/dist/src/perp/client.d.ts +40 -55
  101. package/dist/src/perp/client.js +45 -18
  102. package/dist/src/perp/config-view.d.ts +5 -14
  103. package/dist/src/perp/config-view.js +5 -16
  104. package/dist/src/perp/config.d.ts +3 -3
  105. package/dist/src/perp/config.js +11 -13
  106. package/dist/src/perp/index.d.ts +4 -6
  107. package/dist/src/perp/index.js +17 -5
  108. package/dist/src/perp/tx-builders/common.d.ts +86 -48
  109. package/dist/src/perp/tx-builders/common.js +114 -48
  110. package/dist/src/perp/tx-builders/wlp.d.ts +3 -14
  111. package/dist/src/perp/tx-builders/wlp.js +6 -35
  112. package/dist/src/perp/tx-builders.d.ts +6 -5
  113. package/dist/src/perp/tx-builders.js +15 -6
  114. package/dist/src/unified-client.d.ts +9 -35
  115. package/dist/src/unified-client.js +3 -4
  116. package/dist/src/utils/config.d.ts +0 -8
  117. package/dist/src/utils/config.js +5 -12
  118. package/package.json +8 -2
  119. package/dist/cjs/src/generated/pyth_sponsor_rule/deps/sui/balance.d.ts +0 -12
  120. package/dist/cjs/src/generated/pyth_sponsor_rule/deps/sui/balance.js +0 -17
  121. package/dist/cjs/src/generated/pyth_sponsor_rule/pyth_sponsor_rule.d.ts +0 -68
  122. package/dist/cjs/src/generated/pyth_sponsor_rule/pyth_sponsor_rule.js +0 -113
  123. package/dist/cjs/src/generated/waterx_pyth_rule/deps/sui/vec_map.d.ts +0 -36
  124. package/dist/cjs/src/generated/waterx_pyth_rule/deps/sui/vec_map.js +0 -31
  125. package/dist/cjs/src/generated/waterx_pyth_rule/pyth_rule.d.ts +0 -192
  126. package/dist/cjs/src/generated/waterx_pyth_rule/pyth_rule.js +0 -214
  127. package/dist/cjs/src/oracle/pyth.d.ts +0 -227
  128. package/dist/cjs/src/oracle/pyth.js +0 -652
  129. package/dist/cjs/src/oracle/rules/pyth-core-rule.d.ts +0 -15
  130. package/dist/cjs/src/oracle/rules/pyth-core-rule.js +0 -100
  131. package/dist/cjs/src/oracle/rules/pyth-rule.d.ts +0 -11
  132. package/dist/cjs/src/oracle/rules/pyth-rule.js +0 -29
  133. package/dist/cjs/src/oracle/rules/sponsor.d.ts +0 -32
  134. package/dist/cjs/src/oracle/rules/sponsor.js +0 -56
  135. package/dist/src/generated/pyth_sponsor_rule/deps/sui/balance.d.ts +0 -12
  136. package/dist/src/generated/pyth_sponsor_rule/deps/sui/balance.js +0 -14
  137. package/dist/src/generated/pyth_sponsor_rule/pyth_sponsor_rule.d.ts +0 -68
  138. package/dist/src/generated/pyth_sponsor_rule/pyth_sponsor_rule.js +0 -73
  139. package/dist/src/generated/waterx_pyth_rule/deps/sui/vec_map.d.ts +0 -36
  140. package/dist/src/generated/waterx_pyth_rule/deps/sui/vec_map.js +0 -27
  141. package/dist/src/generated/waterx_pyth_rule/pyth_rule.d.ts +0 -192
  142. package/dist/src/generated/waterx_pyth_rule/pyth_rule.js +0 -170
  143. package/dist/src/oracle/pyth.d.ts +0 -227
  144. package/dist/src/oracle/pyth.js +0 -638
  145. package/dist/src/oracle/rules/pyth-core-rule.d.ts +0 -15
  146. package/dist/src/oracle/rules/pyth-core-rule.js +0 -97
  147. package/dist/src/oracle/rules/pyth-rule.d.ts +0 -11
  148. package/dist/src/oracle/rules/pyth-rule.js +0 -26
  149. package/dist/src/oracle/rules/sponsor.d.ts +0 -32
  150. package/dist/src/oracle/rules/sponsor.js +0 -52
@@ -2,12 +2,12 @@
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 / Lazer / Supra / Constant), then `aggregate`s.
5
+ * (Lazer / Waterx / Supra / Constant), then `aggregate`s.
6
6
  *
7
7
  * Per ticker:
8
8
  * collector = oracle::new_collector(ticker)
9
- * [pyth_rule::feed] when the ticker has a pyth_rule.feeds entry
10
9
  * [pyth_lazer_rule::feed] when the update leg produced a verified lazer Update
10
+ * [waterx_rule::collect_*] when the update leg fetched signed waterx data
11
11
  * [supra_rule::feed] when supra is enabled + wired
12
12
  * [constant_rule::feed] when the ticker is a constant ticker
13
13
  * oracle::aggregate(oracle, collector)
@@ -23,14 +23,13 @@
23
23
  * `rule-registry.ts`.
24
24
  */
25
25
  import { aggregate as aggregateCall, newCollector } from "../generated/waterx_oracle/oracle.js";
26
- import { ownEntry } from "../utils/record.js";
27
- import { OracleFeeSourceUnavailableError } from "./pyth.js";
26
+ import { oracleCredentialsFromHost, } from "./price-update-rule.js";
28
27
  import { resolveOracleRule } from "./rule-registry.js";
29
28
  import { feedConstantRule } from "./rules/constant-rule.js";
30
29
  import { feedLazerRule } from "./rules/pyth-lazer-rule.js";
31
- import { feedPythRule } from "./rules/pyth-rule.js";
32
30
  import { maybeFeedSupra } from "./rules/supra-rule.js";
33
- import { feedWaterxRule, waterxEnvelopeOf, } from "./rules/waterx-rule.js";
31
+ import { feedWaterxRule, feedWaterxRuleWithProof, waterxEnvelopeOf, waterxLeavesOf, } from "./rules/waterx-rule.js";
32
+ import { partitionServableTickers } from "./validate.js";
34
33
  /**
35
34
  * Resolve one group's off-chain update payload for {@link refreshOraclePrices}:
36
35
  * try `provider.get(source, tickers)` first (when a provider is configured),
@@ -42,60 +41,53 @@ import { feedWaterxRule, waterxEnvelopeOf, } from "./rules/waterx-rule.js";
42
41
  * provider commonly caches one whole-universe payload per source — see
43
42
  * {@link UpdateDataProvider}), so it is narrowed to exactly `group.tickers`
44
43
  * via `rule.narrowUpdateData` before use. This is load-bearing, not
45
- * defensive: without it a Pyth Core hit would emit an
46
- * `update_single_price_feed` and charge its fee for every cached feed
47
- * instead of just this group's, and a payload that cannot cover the group
48
- * (`narrowUpdateData` `null`) would never reach the live-fetch fallback.
49
- * Each rule owns its own subsetting (Core subsets per-feed entries; Lazer's
50
- * indivisible payload passes whole iff fully covered), so the orchestrator
51
- * never branches on `kind` here. A hit whose `kind` doesn't match the
52
- * group's rule is a caller bug (the provider handed back a different rule's
53
- * payload), so that throws — via `narrowUpdateData`'s own
44
+ * defensive: a payload that cannot cover the group (`narrowUpdateData`
45
+ * `null`) would never reach the live-fetch fallback without it, and a
46
+ * divisible payload (waterx leaves) is subset instead of fanned out whole.
47
+ * Each rule owns its own subsetting (waterx subsets per-symbol leaves;
48
+ * Lazer's indivisible payload passes whole iff fully covered), so the
49
+ * orchestrator never branches on `kind` here. A hit whose `kind` doesn't
50
+ * match the group's rule is a caller bug (the provider handed back a
51
+ * different rule's payload), so that throws via `narrowUpdateData`'s own
54
52
  * `assertRuleUpdateData` guard — instead of silently falling back.
55
53
  */
56
- async function resolveGroupUpdateData(host, group, provider) {
57
- if (provider) {
58
- let cached = null;
59
- try {
60
- cached = await provider.get(group.source, group.tickers);
61
- }
62
- catch {
63
- // Provider errors must never break the money path — fall through to
64
- // the live fetch below exactly as a cache miss (`null`) would.
65
- }
66
- if (cached !== null) {
67
- // Wrong-kind hit throws inside narrowUpdateData (assertRuleUpdateData);
68
- // a hit that can't cover the group narrows to null → live-fetch below.
69
- const narrowed = group.rule.narrowUpdateData(host, cached, group.tickers);
70
- if (narrowed !== null)
71
- return narrowed;
72
- }
54
+ async function resolveCachedUpdateData(host, group, provider) {
55
+ if (!provider)
56
+ return null;
57
+ let cached;
58
+ try {
59
+ cached = await provider.get(group.source, group.tickers);
73
60
  }
74
- return group.rule.fetchUpdateData(host, group.tickers);
61
+ catch {
62
+ // Provider errors must never break the money path — report a miss and let
63
+ // the caller live-fetch, exactly as a `null` return would.
64
+ return null;
65
+ }
66
+ if (cached === null)
67
+ return null;
68
+ // Wrong-kind hit throws inside narrowUpdateData (assertRuleUpdateData); a
69
+ // hit that can't cover the group narrows to null, i.e. a miss.
70
+ return group.rule.narrowUpdateData(host, cached, group.tickers);
75
71
  }
76
72
  /**
77
73
  * Aggregate one ticker's price into the shared `Oracle`: build a collector, feed
78
74
  * every rule the ticker is configured for, then `aggregate`.
79
75
  *
80
- * - **Pyth** — fed when `priceInfoObjectId` is supplied (i.e. the ticker has a
81
- * `pyth_rule.feeds` entry). When this PTB's update leg refreshed the
82
- * `PriceInfoObject` it contributes a fresh price; when it did not (a
83
- * lazer-routed ticker), the on-chain rule only READS the object and abstains
84
- * if it is stale — it never aborts — so the call stays mandatory while
85
- * `pyth_rule` remains in the ticker's on-chain weighted set
86
- * (`EMissingPriceSource` requires every weighted rule to appear).
87
76
  * - **Lazer** — fed when `lazerUpdate` is supplied: the verified update this
88
77
  * PTB's lazer update leg produced with the network's verify entry
89
78
  * (`update_v2::Update` on mainnet, `update::Update` on testnet — see
90
79
  * `PythLazerRule.buildUpdateCalls`). If the ticker's aggregator does
91
80
  * not (yet) weight `PythLazerRule`, the contribution is silently dropped
92
81
  * on-chain — feeding ahead of the weight migration is harmless.
93
- * - **Supra** — fed alongside Pyth/Lazer when supra is enabled + wired
82
+ * - **Waterx** — fed when `waterxLeaf` (default shape) or `waterxEnvelope`
83
+ * (fallback shape) is supplied; verify AND feed are bundled into the one
84
+ * collect call per collector.
85
+ * - **Supra** — fed alongside the sources when supra is enabled + wired
94
86
  * (abstains on-chain for symbols it has no pair for).
95
87
  * - **Constant** — fed when the ticker is a constant ticker
96
88
  * ({@link OracleHost.isConstantTicker}).
97
89
  *
98
- * "Dual-feed" (Pyth + Constant, or Pyth + Lazer) and "constant-only" are not
90
+ * "Dual-feed" (Lazer + Constant, or Lazer + Waterx) and "constant-only" are not
99
91
  * special cases — they fall out of which rules the ticker is in. Throws if no
100
92
  * rule applies.
101
93
  */
@@ -106,25 +98,29 @@ export function aggregateTicker(tx, host, args) {
106
98
  arguments: { symbol: args.ticker },
107
99
  })(tx);
108
100
  let fed = false;
109
- if (args.priceInfoObjectId) {
110
- feedPythRule(tx, host, collector, args.priceInfoObjectId);
111
- fed = true;
112
- }
113
101
  if (args.lazerUpdate !== undefined) {
114
102
  feedLazerRule(tx, host, collector, args.lazerUpdate);
115
103
  fed = true;
116
104
  }
117
- if (args.waterxEnvelope !== undefined) {
118
- // waterx_rule::collect_batch_latest verifies the batch signature and feeds
119
- // this collector's symbol from the batch. If the ticker's aggregator does
120
- // not (yet) weight `WaterxRule`, the contribution is silently dropped
121
- // on-chain feeding ahead of the weight migration is safe for THIS tx.
122
- // CAVEAT (unlike lazer): the feed call records a per-symbol signed-
123
- // timestamp high-water mark REGARDLESS of weights, and a replayed
124
- // timestamp ABORTS (`EReplayedSignature`, audit F-014) so two PTBs
125
- // carrying the same envelope for the same symbol cannot both land; the
126
- // second aborts even where waterx is unweighted. See WaterxRule's module
127
- // header.
105
+ // One waterx leg at most, and the leaf shape wins: both entries record the
106
+ // same per-symbol signed-timestamp high-water mark and feed the same rule
107
+ // witness into this collector, so emitting both would make the second one
108
+ // abstain on its own predecessor's mark for no gain. `refreshOraclePrices`
109
+ // only ever supplies one; a caller that passes both gets the cheaper leg.
110
+ if (args.waterxLeaf !== undefined) {
111
+ // waterx_rule::collect_single_with_proof re-derives the snapshot root from
112
+ // this leaf + its proof, verifies the enclave signature over that root, and
113
+ // feeds the price. If the ticker's aggregator does not (yet) weight
114
+ // `WaterxRule`, the contribution is silently dropped on-chain feeding
115
+ // ahead of the weight migration is safe. See WaterxRule's module header for
116
+ // the abort-vs-abstain split.
117
+ feedWaterxRuleWithProof(tx, host, collector, args.waterxLeaf);
118
+ fed = true;
119
+ }
120
+ else if (args.waterxEnvelope !== undefined) {
121
+ // Fallback shape: collect_batch_latest re-verifies the WHOLE batch
122
+ // signature (every item rebuilt in-PTB) and feeds this collector's symbol
123
+ // out of it. Only reached against a quote-center with no leaf route.
128
124
  feedWaterxRule(tx, host, collector, args.waterxEnvelope);
129
125
  fed = true;
130
126
  }
@@ -137,7 +133,7 @@ export function aggregateTicker(tx, host, args) {
137
133
  fed = true;
138
134
  }
139
135
  if (!fed) {
140
- throw new Error(`no oracle rule configured for ticker '${args.ticker}' (no pyth feed, no lazer update, not a constant ticker)`);
136
+ throw new Error(`no oracle rule configured for ticker '${args.ticker}' (no lazer update, no waterx data, not a constant ticker)`);
141
137
  }
142
138
  aggregateCall({
143
139
  package: oraclePkg,
@@ -148,33 +144,20 @@ export function aggregateTicker(tx, host, args) {
148
144
  })(tx);
149
145
  }
150
146
  /**
151
- * Thin wrapper over {@link aggregateTicker} for a Pyth-fed ticker. Kept for
152
- * back-compat (e.g. WLP mint builds). Caller must run the Pyth update first.
153
- */
154
- export function aggregateTickerWithPyth(tx, host, args) {
155
- aggregateTicker(tx, host, args);
156
- }
157
- /**
158
- * {@link aggregateTicker} for a **constant-only** ticker (no Pyth update needed —
159
- * the price comes from the on-chain `constant_rule::Config`).
160
- *
161
- * Throws if the ticker ALSO has a `pyth_rule.feeds` entry (a dual-feed transition
162
- * ticker): feeding only the constant leg would leave the still-weighted Pyth rule
163
- * absent from the collector and abort `aggregate` with `EMissingPriceSource`. Such
164
- * tickers must go through {@link aggregateTicker} with a `priceInfoObjectId` (or
165
- * {@link refreshOraclePrices}), which feeds both.
147
+ * {@link aggregateTicker} for a **constant-only** ticker (no source update
148
+ * needed the price comes from the on-chain `constant_rule::Config`). Kept as
149
+ * a named entry so constant-only call sites (e.g. WLP builders refreshing the
150
+ * USDCUSD pool token) read as what they are; it adds nothing over
151
+ * `aggregateTicker(tx, host, { ticker })`.
166
152
  */
167
153
  export function aggregateTickerWithConstant(tx, host, args) {
168
- if (ownEntry(host.config.packages.pyth_rule?.feeds, args.ticker) !== undefined) {
169
- throw new Error(`'${args.ticker}' is in pyth_rule.feeds (dual-feed) — feed both via aggregateTicker({ priceInfoObjectId }) / refreshOraclePrices, not aggregateTickerWithConstant`);
170
- }
171
154
  aggregateTicker(tx, host, { ticker: args.ticker });
172
155
  }
173
156
  /**
174
157
  * Refresh multiple tickers in one PTB. For each ticker {@link aggregateTicker}
175
- * feeds whichever rules it is configured for (Pyth if it has a `pyth_rule.feeds`
176
- * entry, Lazer if the lazer update leg served it — see below — Supra when
177
- * enabled, Constant when it's a constant ticker).
158
+ * feeds whichever rules it is configured for (Lazer if the lazer update leg
159
+ * served it, Waterx if the waterx leg fetched signed data for it — see below —
160
+ * Supra when enabled, Constant when it's a constant ticker).
178
161
  *
179
162
  * Before that, the on-chain price *update* leg is routed by the
180
163
  * `host.oracleSources` fed set (see `rule-registry.ts`): EVERY listed source
@@ -188,11 +171,15 @@ export function aggregateTickerWithConstant(tx, host, args) {
188
171
  * source's feeds is caught here.
189
172
  *
190
173
  * Each source's fetch + build runs against its own infra, guaranteeing
191
- * per-rule PTB atomicity. A fee-source pre-check runs first (any listed
192
- * source's `requiresFeeSource`) BEFORE any off-chain fetch or PTB mutation
193
- * so a fee-charging source with no `opts.feeSource` throws
194
- * `OracleFeeSourceUnavailable` with zero wasted network calls and zero stray
195
- * moveCalls. Only once that check passes do the off-chain fetches run — in
174
+ * per-rule PTB atomicity. A credential pre-check runs early: any group that
175
+ * still needs to fetch, whose rule declares a `credential` the host does not
176
+ * carry while `host.pyth.api_key` is unset, throws `LazerApiKeyMissing`
177
+ * BEFORE any ORACLE fetch or PTB mutation — zero wasted oracle calls, zero
178
+ * stray moveCalls. (With an `updateDataProvider` configured, its per-source
179
+ * lookups run first so the check can be scoped to the groups the cache did
180
+ * NOT serve; a consumer-implemented provider may do I/O of its own, so the
181
+ * guarantee is about oracle fetches, not about every possible round trip.)
182
+ * Only once that check passes do the off-chain fetches run — in
196
183
  * parallel across sources — and ALL settle before the first PTB mutation;
197
184
  * on-chain reads inside `buildUpdateCalls` can still fail mid-append for
198
185
  * other reasons — callers discard the tx on any throw.
@@ -201,35 +188,24 @@ export function aggregateTickerWithConstant(tx, host, args) {
201
188
  * `buildUpdateCalls` returns the verified `Update` PTB value
202
189
  * ({@link RuleUpdateHandle}), and every ticker in that group is aggregated
203
190
  * with `lazerUpdate` set so {@link aggregateTicker} appends
204
- * `pyth_lazer_rule::feed` against it. A lazer-routed ticker that still has a
205
- * `pyth_rule.feeds` entry ALSO keeps its `pyth_rule::feed` leg required
206
- * on-chain while `pyth_rule` stays in the ticker's weighted set
207
- * (`aggregator::remove_outliers` aborts `EMissingPriceSource` unless every
208
- * weighted rule appears in the collector; an abstention counts as
209
- * appearing), and safe: `pyth_rule::feed` only READS the `PriceInfoObject`
210
- * this PTB never refreshed and abstains when it is stale rather than
211
- * aborting. Conversely, a lazer feed call on an aggregator that does not
212
- * (yet) weight `PythLazerRule` is silently dropped on-chain — so
213
- * lazer-routing a ticker ahead of its on-chain weight migration prices it
214
- * from the remaining weighted rules instead of failing.
191
+ * `pyth_lazer_rule::feed` against it. A waterx-served group's signed data is
192
+ * carried straight from its fetched payload to the per-ticker collect call.
193
+ * A feed call on an aggregator that does not (yet) weight that rule is
194
+ * silently dropped on-chain so routing a ticker ahead of its on-chain
195
+ * weight migration prices it from the remaining weighted rules instead of
196
+ * failing.
215
197
  */
216
198
  export async function refreshOraclePrices(tx, host, tickers, opts = {}) {
217
199
  if (tickers.length === 0)
218
- return;
200
+ return { refreshed: [], skipped: [] };
219
201
  // Dedupe the caller's list (order-preserving): a repeated ticker would
220
- // otherwise aggregate TWICE in this one PTB — wasted gas for every rule,
221
- // and a hard ABORT under waterx: the second `collect_batch_latest` carries
222
- // the same envelope, and the on-chain per-symbol replay guard rejects an
223
- // already-accepted signed timestamp (`EReplayedSignature`, F-014) even
224
- // inside a single transaction.
202
+ // otherwise aggregate TWICE in this one PTB — wasted gas for every rule, and
203
+ // under waterx the second collect would be dead weight on top of that: the
204
+ // on-chain per-symbol replay guard (F-014) sees its own predecessor's
205
+ // high-water mark from earlier in this same transaction and abstains, so the
206
+ // repeat pays full verification cost to contribute nothing.
225
207
  tickers = [...new Set(tickers)];
226
- // price_info_object lookup for every ticker with a pyth_rule.feeds entry
227
- // needed by aggregateTicker's (unchanged) Pyth feed step below regardless of
228
- // which rule performed the on-chain update for that ticker.
229
- const pythTickers = tickers.filter((t) => ownEntry(host.config.packages.pyth_rule?.feeds, t) !== undefined);
230
- const priceInfoByTicker = new Map();
231
- pythTickers.forEach((t) => priceInfoByTicker.set(t, host.getPythFeed(t).price_info_object));
232
- // The fed set is a LIST (`host.oracleSources`, normalized + deduped at
208
+ // The fed set is a LIST (`host.oracleSources`, derived from the config at
233
209
  // client creation): ONE build carries every listed source's data, and the
234
210
  // chain's per-ticker weight tables decide which contributions count —
235
211
  // feeding an unweighted rule's PRICE is dropped on-chain, while starving a
@@ -239,8 +215,8 @@ export async function refreshOraclePrices(tx, host, tickers, opts = {}) {
239
215
  // burns a per-symbol signed-timestamp high-water mark regardless of
240
216
  // weights — see aggregateTicker's waterx branch.) Still NO fallback
241
217
  // BETWEEN sources: each group serves only the tickers its own feeds list.
242
- // Zero-ticker groups are dropped here so everything downstream (fee check,
243
- // fetch fan-out, update-leg build) can assume every group has work.
218
+ // Zero-ticker groups are dropped here so everything downstream (credential
219
+ // check, fetch fan-out, update-leg build) can assume every group has work.
244
220
  const groups = host.oracleSources
245
221
  .map((source) => {
246
222
  const rule = resolveOracleRule(source, opts.ruleOverrides);
@@ -248,66 +224,99 @@ export async function refreshOraclePrices(tx, host, tickers, opts = {}) {
248
224
  return { source, rule, tickers: tickers.filter((t) => supported.has(t)) };
249
225
  })
250
226
  .filter((group) => group.tickers.length > 0);
251
- // Fail the tx-build (NOT client init, NOT a silent reroute) when NO listed
252
- // source has a feed for a requested ticker that actually needs a price
253
- // update. Only a CONSTANT-ONLY ticker is exempt — priced entirely by
254
- // `constant_rule`, it needs no update leg from any source. A DUAL-FEED ticker
255
- // (constant AND pyth) still needs its Pyth leg refreshed, so `isConstantTicker`
256
- // alone must NOT exempt it: with no source able to serve it, feeding an
257
- // unrefreshed Pyth leg would price it stale (or abort on a missing weighted
258
- // source). `priceInfoByTicker.has(t)` the ticker has a `pyth_rule.feeds`
259
- // entry, so `constant && !hasPyth` is exactly constant-only. This catches a
260
- // MISSING feed; a present-but-WRONG feed id is deliberately not validated
261
- // here (it aborts on-chain at dry-run).
227
+ // A ticker no listed source can price is SKIPPED, not thrown on, and named
228
+ // in the returned summary.
229
+ //
230
+ // This is a broad primitive: callers sweep whole market lists through it, and
231
+ // losing 29 tickers because the 30th is unconfigured is the wrong trade — the
232
+ // 29 still need their prices on chain. Safety lives one level up, where the
233
+ // ACTION is known: the `build*Tx` composers fail closed on the tickers their
234
+ // specific call actually depends on (`assertTickersRefreshed` /
235
+ // `assertWlpPoolRefreshed` in `perp/tx-builders/common.ts`). A bare
236
+ // `refreshOraclePrices` caller composing its own PTB reads `skipped` and
237
+ // decides for itself.
238
+ //
239
+ // Exemption: a CONSTANT-ONLY ticker needs no update leg from any source, so
240
+ // it counts as refreshed with just its constant feed.
241
+ //
242
+ // "Constant-only" is deliberately stricter than "constant-pinned". A ticker
243
+ // that constant_rule pins AND some other rule also has a feed for is NOT
244
+ // exempt, even when that other rule is outside the fed set: the on-chain
245
+ // aggregator very likely weights the rule whose feed the config carries, and
246
+ // aggregating a constant-only collector for it aborts `EMissingPriceSource`.
247
+ // Exempting on `isConstantTicker` alone would be correct only while the fed
248
+ // set covers every ticker's weighted set — an operator-maintained property
249
+ // this function cannot verify without reading chain state. So the strict
250
+ // reading fails SAFE: the ticker is skipped, named, and the composers turn
251
+ // that into a build error instead of an opaque on-chain abort.
252
+ //
253
+ // Catches a MISSING feed only; a present-but-WRONG feed id is deliberately
254
+ // left to abort on-chain at dry-run.
262
255
  const covered = new Set(groups.flatMap((group) => group.tickers));
263
- const isConstantOnly = (t) => host.isConstantTicker(t) && !priceInfoByTicker.has(t);
264
- const unservable = tickers.filter((t) => !covered.has(t) && !isConstantOnly(t));
265
- if (unservable.length > 0) {
266
- const sources = host.oracleSources.join(", ");
267
- throw new Error(`oracleSource [${sources}] has no feed configured for ticker(s): ` +
268
- `${unservable.join(", ")}. Sources are self-contained with no fallback add ` +
269
- `feeds for them under a listed source, or list a source that serves them.`);
270
- }
271
- // Fee-source pre-check, hoisted ABOVE the off-chain fetches and PTB build
272
- // below. It consults only `rule.requiresFeeSource` known before any fetch
273
- // or PTB mutation — so a fee-charging source (Pyth Core) in the fed set with
274
- // no `feeSource` throws with ZERO wasted network calls and zero PTB
275
- // commands, rather than waiting for `buildPythPriceUpdateCalls`'s own
276
- // per-call guard to fire after the off-chain fetches already ran.
277
- if (!opts.feeSource && groups.some((group) => group.rule.requiresFeeSource)) {
278
- throw new OracleFeeSourceUnavailableError();
256
+ const { servable: refreshed, unservable: skipped } = partitionServableTickers(host, tickers, covered);
257
+ // Credential pre-check, hoisted ABOVE the oracle fetches and PTB build below
258
+ // (the position the retired fee-source pre-check held). It consults only
259
+ // `rule.credential` known without fetching anything — so a keyless build
260
+ // against an auth-first source (Lazer) in the fed set throws with ZERO
261
+ // wasted ORACLE calls and zero PTB commands, rather than waiting for that
262
+ // group's own fetch guard to fire after sibling groups' fetches already ran.
263
+ // Fully generic: the kind→value mapping is the port's
264
+ // (`oracleCredentialsFromHost`) and the ERROR is the rule's own, so this
265
+ // loop names neither a credential kind nor a rule.
266
+ //
267
+ // It does NOT run before absolutely everything, and cannot: scoping it to
268
+ // the groups that still need to fetch means knowing which ones the cache
269
+ // served, and `UpdateDataProvider` is consumer-implemented, so its lookup
270
+ // may do I/O. That is the trade — a provider round trip may precede the
271
+ // throw, an oracle fetch never does — and it buys the scoping below.
272
+ //
273
+ // The check is scoped to the groups that will actually FETCH. An
274
+ // `updateDataProvider` is a per-SOURCE cache (`get(source, tickers)`), so a
275
+ // consumer holding the credential out-of-band for one source must not
276
+ // exempt the whole fed set — with a waterx-only cache and a keyless Lazer
277
+ // group, a blanket skip would let the quote-center GET fire before the
278
+ // Lazer group failed. So cache lookups (no network) run first, and only the
279
+ // groups that missed are credential-checked.
280
+ //
281
+ // Phase 0 — resolve cache hits. Cheap and network-free by contract.
282
+ const cachedByGroup = await Promise.all(groups.map((group) => resolveCachedUpdateData(host, group, opts.updateDataProvider)));
283
+ const needsFetch = groups.filter((_, i) => cachedByGroup[i] === null);
284
+ const credentials = oracleCredentialsFromHost(host);
285
+ for (const { rule } of needsFetch) {
286
+ if (rule.credential && !credentials[rule.credential.kind]) {
287
+ throw rule.credential.missing();
288
+ }
279
289
  }
280
- // Phase 1 — resolve every group's update data IN PARALLEL: the per-source
281
- // fetches (Hermes VAA / Lazer POST / quote-center GET) are independent
282
- // network calls on the tx-build money path, so a multi-source fed set must
283
- // not pay one RTT per source sequentially. ALL fetches settle before the
284
- // first PTB mutation below, so a fetch failure never strands moveCalls in a
290
+ // Phase 1 — live-fetch whatever the cache did not serve, IN PARALLEL: the
291
+ // per-source fetches (Lazer POST / quote-center GET) are independent network
292
+ // calls on the tx-build money path, so a multi-source fed set must not pay
293
+ // one RTT per source sequentially. ALL of them settle before the first PTB
294
+ // mutation below, so a fetch failure never strands moveCalls in a
285
295
  // caller-owned tx — and a failure in ANY group fails the whole build (a
286
296
  // listed source is load-bearing; silently building without it would starve
287
297
  // its weighted tickers on-chain).
288
- const dataByGroup = await Promise.all(groups.map((group) => resolveGroupUpdateData(host, group, opts.updateDataProvider)));
298
+ const fetched = new Map();
299
+ await Promise.all(needsFetch.map(async (group) => {
300
+ fetched.set(group.source, await group.rule.fetchUpdateData(host, group.tickers));
301
+ }));
302
+ const dataByGroup = groups.map((group, i) => cachedByGroup[i] ?? fetched.get(group.source) ?? null);
289
303
  // Phase 2 — build each group's update leg sequentially, in list order, so
290
304
  // PTB command order stays deterministic. The carry step below is an
291
305
  // exhaustive switch over the group's rule kind: a future source whose feed
292
306
  // leg needs per-ticker data from its update leg must decide its carry here
293
307
  // — falling through silently would starve its weighted tickers on-chain.
294
308
  const lazerUpdateByTicker = new Map();
295
- // Signed batch envelope per waterx-served ticker. Unlike Lazer's shared PTB
296
- // handle, waterx's verify+feed is bundled into `collect_batch_latest` in the
297
- // per-ticker feed leg, so its `buildUpdateCalls` emits nothing and the
298
- // envelope is carried straight from the group's fetched data.
309
+ // Signed waterx data per served ticker a per-symbol Merkle leaf normally, a
310
+ // shared batch envelope on the fallback shape. Unlike Lazer's shared PTB
311
+ // handle, waterx's verify+feed is bundled into the per-ticker collect call, so
312
+ // its `buildUpdateCalls` emits nothing and the signed data is carried straight
313
+ // from the group's fetched data.
314
+ const waterxLeafByTicker = new Map();
299
315
  const waterxEnvelopeByTicker = new Map();
300
316
  for (const [i, group] of groups.entries()) {
301
317
  const data = dataByGroup[i] ?? null;
302
- const handle = (await group.rule.buildUpdateCalls(tx, host, data, {
303
- cache: opts.cache,
304
- feeSource: opts.feeSource,
305
- })) ?? undefined;
318
+ const handle = (await group.rule.buildUpdateCalls(tx, host, data)) ?? undefined;
306
319
  switch (group.rule.kind) {
307
- case "pyth_rule":
308
- // Core's update leg wrote the PriceInfoObjects in place — the feed
309
- // leg reads them by id (`priceInfoByTicker`), nothing to carry.
310
- break;
311
320
  case "pyth_lazer_rule":
312
321
  // Route by the handle's kind discriminant — the tag exists so a
313
322
  // non-lazer handle can never be silently fed into
@@ -319,12 +328,45 @@ export async function refreshOraclePrices(tx, host, tickers, opts = {}) {
319
328
  break;
320
329
  case "waterx_rule": {
321
330
  // waterx_rule emits no shared handle (verify+feed is bundled into the
322
- // per-ticker `collect_batch_latest`), so the envelope is carried
323
- // straight from this group's fetched data to the feed leg below.
324
- const envelope = waterxEnvelopeOf(data);
325
- if (envelope) {
326
- for (const ticker of group.tickers)
327
- waterxEnvelopeByTicker.set(ticker, envelope);
331
+ // per-ticker collect call), so the signed data is carried straight from
332
+ // this group's fetched data to the feed leg below. Leaves are per-symbol
333
+ // and indexed BY symbol — never fanned out across the group like the
334
+ // envelope, since each leaf only verifies for its own symbol
335
+ // (`ECollectorSymbolMismatch`). A leaf for a symbol outside this group is
336
+ // dropped rather than carried: the feed leg is keyed by ticker anyway.
337
+ const leaves = waterxLeavesOf(data);
338
+ // `length > 0` matters: `{ leaves: [] }` is shape-valid (`[].every(...)`
339
+ // is `true`), so a bare truthiness test would TAKE this branch, carry
340
+ // nothing, and `break` past the envelope branch below.
341
+ if (leaves && leaves.length > 0) {
342
+ const served = new Set(group.tickers);
343
+ for (const leaf of leaves) {
344
+ if (served.has(leaf.symbol))
345
+ waterxLeafByTicker.set(leaf.symbol, leaf);
346
+ }
347
+ }
348
+ else {
349
+ const envelope = waterxEnvelopeOf(data);
350
+ if (envelope) {
351
+ for (const ticker of group.tickers)
352
+ waterxEnvelopeByTicker.set(ticker, envelope);
353
+ }
354
+ }
355
+ // Fail the BUILD, not the chain. Both suppliers of `data` already
356
+ // guarantee full coverage — the live fetch through `assertCoverage`, a
357
+ // cached payload through `narrowUpdateData` (which returns `null`, i.e.
358
+ // "miss → live fetch", rather than a partial) — so reaching here with a
359
+ // ticker uncarried means one of those invariants broke. Left alone it
360
+ // emits a collector with no waterx leg, which surfaces MUCH later as an
361
+ // opaque on-chain `EMissingPriceSource` (or, if the rule is unweighted
362
+ // for that ticker, as a silently thinner weighted set). This names the
363
+ // tickers instead.
364
+ const uncarried = group.tickers.filter((t) => !waterxLeafByTicker.has(t) && !waterxEnvelopeByTicker.has(t));
365
+ if (uncarried.length > 0) {
366
+ throw new Error(`waterx_rule update data carries no signed price for ticker(s): ${uncarried.join(", ")}. ` +
367
+ "Expected one signed leaf per ticker (or a batch envelope covering all of " +
368
+ "them) — a payload that serves none of a group's tickers must be reported " +
369
+ "as a miss, not fed.");
328
370
  }
329
371
  break;
330
372
  }
@@ -334,13 +376,16 @@ export async function refreshOraclePrices(tx, host, tickers, opts = {}) {
334
376
  }
335
377
  }
336
378
  }
337
- // Aggregate each ticker, feeding whichever rules it is configured for.
338
- for (const ticker of tickers) {
379
+ // Aggregate each REFRESHED ticker, feeding whichever rules it is configured
380
+ // for. A skipped ticker gets no collector at all — aggregating one with no
381
+ // feed leg would write an empty collector and abort `EMissingPriceSource`.
382
+ for (const ticker of refreshed) {
339
383
  aggregateTicker(tx, host, {
340
384
  ticker,
341
- priceInfoObjectId: priceInfoByTicker.get(ticker),
342
385
  lazerUpdate: lazerUpdateByTicker.get(ticker),
386
+ waterxLeaf: waterxLeafByTicker.get(ticker),
343
387
  waterxEnvelope: waterxEnvelopeByTicker.get(ticker),
344
388
  });
345
389
  }
390
+ return { refreshed, skipped };
346
391
  }