@waterx/sdk 4.1.0 → 4.3.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 (80) hide show
  1. package/README.md +29 -19
  2. package/dist/cjs/src/account/config.d.ts +3 -2
  3. package/dist/cjs/src/generated/waterx_rule/waterx_rule.d.ts +7 -134
  4. package/dist/cjs/src/generated/waterx_rule/waterx_rule.js +8 -154
  5. package/dist/cjs/src/oracle/aggregate.d.ts +21 -21
  6. package/dist/cjs/src/oracle/aggregate.js +121 -71
  7. package/dist/cjs/src/oracle/config.d.ts +38 -72
  8. package/dist/cjs/src/oracle/config.js +0 -56
  9. package/dist/cjs/src/oracle/host.d.ts +24 -20
  10. package/dist/cjs/src/oracle/index.d.ts +7 -3
  11. package/dist/cjs/src/oracle/index.js +41 -8
  12. package/dist/cjs/src/oracle/price-update-rule.d.ts +32 -11
  13. package/dist/cjs/src/oracle/price-update-rule.js +23 -3
  14. package/dist/cjs/src/oracle/pyth.d.ts +44 -0
  15. package/dist/cjs/src/oracle/pyth.js +48 -6
  16. package/dist/cjs/src/oracle/read-plane.d.ts +70 -0
  17. package/dist/cjs/src/oracle/read-plane.js +85 -0
  18. package/dist/cjs/src/oracle/rule-registry.d.ts +6 -4
  19. package/dist/cjs/src/oracle/rule-registry.js +6 -4
  20. package/dist/cjs/src/oracle/rules/pyth-core-rule.js +6 -5
  21. package/dist/cjs/src/oracle/rules/pyth-lazer-rule.d.ts +21 -0
  22. package/dist/cjs/src/oracle/rules/pyth-lazer-rule.js +34 -6
  23. package/dist/cjs/src/oracle/rules/pyth-rule.js +5 -4
  24. package/dist/cjs/src/oracle/rules/waterx-rule.d.ts +42 -5
  25. package/dist/cjs/src/oracle/rules/waterx-rule.js +61 -18
  26. package/dist/cjs/src/oracle/source-list.d.ts +36 -0
  27. package/dist/cjs/src/oracle/source-list.js +57 -0
  28. package/dist/cjs/src/perp/client.d.ts +41 -28
  29. package/dist/cjs/src/perp/client.js +32 -20
  30. package/dist/cjs/src/perp/config-view.js +7 -6
  31. package/dist/cjs/src/perp/config.d.ts +6 -6
  32. package/dist/cjs/src/perp/config.js +12 -12
  33. package/dist/cjs/src/perp/index.d.ts +3 -3
  34. package/dist/cjs/src/perp/index.js +11 -4
  35. package/dist/cjs/src/perp/user/staking.js +2 -1
  36. package/dist/cjs/src/unified-client.d.ts +14 -9
  37. package/dist/cjs/src/unified-client.js +2 -2
  38. package/dist/cjs/src/utils/config.js +2 -1
  39. package/dist/cjs/src/utils/record.d.ts +12 -0
  40. package/dist/cjs/src/utils/record.js +22 -0
  41. package/dist/src/account/config.d.ts +3 -2
  42. package/dist/src/generated/waterx_rule/waterx_rule.d.ts +7 -134
  43. package/dist/src/generated/waterx_rule/waterx_rule.js +7 -143
  44. package/dist/src/oracle/aggregate.d.ts +21 -21
  45. package/dist/src/oracle/aggregate.js +121 -71
  46. package/dist/src/oracle/config.d.ts +38 -72
  47. package/dist/src/oracle/config.js +1 -55
  48. package/dist/src/oracle/host.d.ts +24 -20
  49. package/dist/src/oracle/index.d.ts +7 -3
  50. package/dist/src/oracle/index.js +32 -10
  51. package/dist/src/oracle/price-update-rule.d.ts +32 -11
  52. package/dist/src/oracle/price-update-rule.js +22 -3
  53. package/dist/src/oracle/pyth.d.ts +44 -0
  54. package/dist/src/oracle/pyth.js +45 -5
  55. package/dist/src/oracle/read-plane.d.ts +70 -0
  56. package/dist/src/oracle/read-plane.js +81 -0
  57. package/dist/src/oracle/rule-registry.d.ts +6 -4
  58. package/dist/src/oracle/rule-registry.js +6 -4
  59. package/dist/src/oracle/rules/pyth-core-rule.js +7 -6
  60. package/dist/src/oracle/rules/pyth-lazer-rule.d.ts +21 -0
  61. package/dist/src/oracle/rules/pyth-lazer-rule.js +33 -5
  62. package/dist/src/oracle/rules/pyth-rule.js +5 -4
  63. package/dist/src/oracle/rules/waterx-rule.d.ts +42 -5
  64. package/dist/src/oracle/rules/waterx-rule.js +57 -15
  65. package/dist/src/oracle/source-list.d.ts +36 -0
  66. package/dist/src/oracle/source-list.js +53 -0
  67. package/dist/src/perp/client.d.ts +41 -28
  68. package/dist/src/perp/client.js +33 -21
  69. package/dist/src/perp/config-view.js +7 -6
  70. package/dist/src/perp/config.d.ts +6 -6
  71. package/dist/src/perp/config.js +11 -9
  72. package/dist/src/perp/index.d.ts +3 -3
  73. package/dist/src/perp/index.js +2 -2
  74. package/dist/src/perp/user/staking.js +2 -1
  75. package/dist/src/unified-client.d.ts +14 -9
  76. package/dist/src/unified-client.js +2 -2
  77. package/dist/src/utils/config.js +2 -1
  78. package/dist/src/utils/record.d.ts +12 -0
  79. package/dist/src/utils/record.js +19 -0
  80. package/package.json +1 -1
@@ -41,9 +41,6 @@ export const BatchPriceItem = new MoveStruct({ name: `${$moduleName}::BatchPrice
41
41
  export const BatchPricePayload = new MoveStruct({ name: `${$moduleName}::BatchPricePayload`, fields: {
42
42
  items: bcs.vector(BatchPriceItem)
43
43
  } });
44
- export const MerkleRoot = new MoveStruct({ name: `${$moduleName}::MerkleRoot`, fields: {
45
- root: bcs.vector(bcs.u8())
46
- } });
47
44
  export const FeedConfig = new MoveStruct({ name: `${$moduleName}::FeedConfig`, fields: {
48
45
  ticker: bcs.string(),
49
46
  sources: bcs.vector(bcs.u64()),
@@ -129,17 +126,15 @@ export function feedBatchLatest(options) {
129
126
  * and so aborts on any stale item, this path runs alongside Pyth/Supra. To let
130
127
  * those cover a lagging TEE — the blast-radius bound in the GCP ADR — a
131
128
  * _freshness_ miss ABSTAINS (records `none`, which `aggregator::remove_outliers`
132
- * drops before the `weight_threshold` fail-closed check), and so does a _replayed_
133
- * signed timestamp (the chain already accepted that snapshot the on-chain price
134
- * is at least as fresh, and two concurrent keeper PTBs sharing one envelope must
135
- * not kill each other). A _config/integrity_ mismatch (source / ticker / method /
136
- * confidence / deviation) still ABORTS: an enclave whose signed payload disagrees
137
- * with the on-chain feed config is a red flag, not a liveness blip. Bad signature,
138
- * malformed batch, and a future timestamp always abort.
129
+ * drops before the `weight_threshold` fail-closed check). A _config/integrity_
130
+ * mismatch (source / ticker / method / confidence / deviation) still ABORTS: an
131
+ * enclave whose signed payload disagrees with the on-chain feed config is a red
132
+ * flag, not a liveness blip. Bad signature, malformed batch, a future timestamp,
133
+ * and replay always abort.
139
134
  *
140
135
  * Every gate is identical to `feed_batch_item_latest`; only the abort-vs-abstain
141
- * disposition of the freshness + replay gates differs, and the sink is the
142
- * caller's collector rather than a fresh single-rule one.
136
+ * disposition of the two freshness gates differs, and the sink is the caller's
137
+ * collector rather than a fresh single-rule one.
143
138
  */
144
139
  export function collectBatchLatest(options) {
145
140
  const packageAddress = options.package ?? '@waterx/rule';
@@ -161,63 +156,6 @@ export function collectBatchLatest(options) {
161
156
  arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames),
162
157
  });
163
158
  }
164
- /**
165
- * Streaming per-symbol sibling of `feed_batch_latest`. Verifies ONE enclave
166
- * signature over a Merkle `root`, checks `item` is a leaf of that root via
167
- * `proof`, then runs the identical per-item gates + `oracle::aggregate`. Use for a
168
- * WaterxRule-only aggregator; for a Pyth-coexisting aggregator use
169
- * `collect_single_with_proof`.
170
- */
171
- export function feedSingleWithProof(options) {
172
- const packageAddress = options.package ?? '@waterx/rule';
173
- const argumentsTypes = [
174
- null,
175
- null,
176
- '0x2::clock::Clock',
177
- null,
178
- null,
179
- 'u64',
180
- null,
181
- 'vector<vector<u8>>',
182
- 'vector<u8>'
183
- ];
184
- const parameterNames = ["oracleObj", "config", "enclaveConfig", "enclave", "timestampMs", "item", "proof", "sig"];
185
- return (tx) => tx.moveCall({
186
- package: packageAddress,
187
- module: 'waterx_rule',
188
- function: 'feed_single_with_proof',
189
- arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames),
190
- });
191
- }
192
- /**
193
- * Collect-only sibling of `feed_single_with_proof` for the dual-rule latest path
194
- * (WaterX alongside Pyth on one aggregator), mirroring `collect_batch_latest`:
195
- * verifies root + proof, feeds the leaf into the caller's `PriceCollector` WITHOUT
196
- * calling `oracle::aggregate`. Abort vs abstain matches `collect_batch_latest`
197
- * exactly — a config/integrity mismatch aborts, a freshness miss abstains so Pyth
198
- * can cover a lagging TEE.
199
- */
200
- export function collectSingleWithProof(options) {
201
- const packageAddress = options.package ?? '@waterx/rule';
202
- const argumentsTypes = [
203
- null,
204
- null,
205
- '0x2::clock::Clock',
206
- null,
207
- null,
208
- 'u64',
209
- null,
210
- 'vector<vector<u8>>',
211
- 'vector<u8>'
212
- ];
213
- const parameterNames = ["collector", "config", "enclaveConfig", "enclave", "timestampMs", "item", "proof", "sig"];
214
- return (tx) => tx.moveCall({
215
- package: packageAddress,
216
- module: 'waterx_rule',
217
- function: 'collect_single_with_proof',
218
- arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames),
219
- });
220
- }
221
159
  /**
222
160
  * Verifies a historical quote without touching `waterx_oracle`.
223
161
  *
@@ -409,32 +347,6 @@ export function batchPriceIntent(options = {}) {
409
347
  function: 'batch_price_intent',
410
348
  });
411
349
  }
412
- export function merkleRootIntent(options = {}) {
413
- const packageAddress = options.package ?? '@waterx/rule';
414
- return (tx) => tx.moveCall({
415
- package: packageAddress,
416
- module: 'waterx_rule',
417
- function: 'merkle_root_intent',
418
- });
419
- }
420
- /**
421
- * The keccak256 leaf hash for `item` — `keccak256(0x00 || BCS(item))`. Exposed so
422
- * the off-chain builder / SDK can be pinned against the on-chain encoding in a
423
- * cross-implementation golden test.
424
- */
425
- export function leafHashOf(options) {
426
- const packageAddress = options.package ?? '@waterx/rule';
427
- const argumentsTypes = [
428
- null
429
- ];
430
- const parameterNames = ["item"];
431
- return (tx) => tx.moveCall({
432
- package: packageAddress,
433
- module: 'waterx_rule',
434
- function: 'leaf_hash_of',
435
- arguments: normalizeMoveArguments(options.arguments, argumentsTypes, parameterNames),
436
- });
437
- }
438
350
  export function maxBatchSize(options = {}) {
439
351
  const packageAddress = options.package ?? '@waterx/rule';
440
352
  return (tx) => tx.moveCall({
@@ -821,54 +733,6 @@ export function sourceGataUsdtPerpWs(options = {}) {
821
733
  function: 'source_gata_usdt_perp_ws',
822
734
  });
823
735
  }
824
- export function sourceBybitSpotWs(options = {}) {
825
- const packageAddress = options.package ?? '@waterx/rule';
826
- return (tx) => tx.moveCall({
827
- package: packageAddress,
828
- module: 'waterx_rule',
829
- function: 'source_bybit_spot_ws',
830
- });
831
- }
832
- export function sourceXstockEquityRest(options = {}) {
833
- const packageAddress = options.package ?? '@waterx/rule';
834
- return (tx) => tx.moveCall({
835
- package: packageAddress,
836
- module: 'waterx_rule',
837
- function: 'source_xstock_equity_rest',
838
- });
839
- }
840
- export function sourceOkxSpotWs(options = {}) {
841
- const packageAddress = options.package ?? '@waterx/rule';
842
- return (tx) => tx.moveCall({
843
- package: packageAddress,
844
- module: 'waterx_rule',
845
- function: 'source_okx_spot_ws',
846
- });
847
- }
848
- export function sourceHyperliquidPerpWs(options = {}) {
849
- const packageAddress = options.package ?? '@waterx/rule';
850
- return (tx) => tx.moveCall({
851
- package: packageAddress,
852
- module: 'waterx_rule',
853
- function: 'source_hyperliquid_perp_ws',
854
- });
855
- }
856
- export function sourceGateioSpotWs(options = {}) {
857
- const packageAddress = options.package ?? '@waterx/rule';
858
- return (tx) => tx.moveCall({
859
- package: packageAddress,
860
- module: 'waterx_rule',
861
- function: 'source_gateio_spot_ws',
862
- });
863
- }
864
- export function sourceKrakenSpotWs(options = {}) {
865
- const packageAddress = options.package ?? '@waterx/rule';
866
- return (tx) => tx.moveCall({
867
- package: packageAddress,
868
- module: 'waterx_rule',
869
- function: 'source_kraken_spot_ws',
870
- });
871
- }
872
736
  export function methodDirect(options = {}) {
873
737
  const packageAddress = options.package ?? '@waterx/rule';
874
738
  return (tx) => tx.moveCall({
@@ -19,7 +19,8 @@
19
19
  *
20
20
  * `refreshOraclePrices` additionally routes the on-chain price *update* leg
21
21
  * (the fetch + verify/push step, before any of the above feeding) through the
22
- * `PriceUpdateRule` selected by `host.oracleSource` — see `rule-registry.ts`.
22
+ * `PriceUpdateRule` of EVERY source in the `host.oracleSources` fed set — see
23
+ * `rule-registry.ts`.
23
24
  */
24
25
  import type { Transaction, TransactionArgument } from "@mysten/sui/transactions";
25
26
  import type { OracleHost } from "./host.ts";
@@ -84,27 +85,26 @@ export declare function aggregateTickerWithConstant(tx: Transaction, host: Oracl
84
85
  * entry, Lazer if the lazer update leg served it — see below — Supra when
85
86
  * enabled, Constant when it's a constant ticker).
86
87
  *
87
- * Before that, the on-chain price *update* leg is routed by `host.oracleSource`
88
- * (see `rule-registry.ts`): the ONE selected source serves every ticker in its
89
- * `supportedTickers(host)`. There is **no cross-source fallback** — a requested
90
- * ticker the selected source does not serve, and that is not a constant-only
91
- * ticker (which needs no price-update leg), fails the build immediately with a
92
- * clear error naming the ticker and source. That is the deliberate "fail the
93
- * tx-build, don't silently reroute" contract: a wrong-but-present feed id is
94
- * NOT validated here (it surfaces on-chain at dry-run); a MISSING feed for the
95
- * selected source is caught here. When the selected source's feed exists but is
96
- * wrong, this function does nothing special — the on-chain aggregate aborts at
97
- * dry-run, which is correct.
88
+ * Before that, the on-chain price *update* leg is routed by the
89
+ * `host.oracleSources` fed set (see `rule-registry.ts`): EVERY listed source
90
+ * updates the tickers its own `supportedTickers(host)` serves, all in this one
91
+ * PTB. There is **no cross-source fallback** a requested ticker NO listed
92
+ * source serves, and that is not a constant-only ticker (which needs no
93
+ * price-update leg), fails the build immediately with a clear error naming
94
+ * the ticker and the list. That is the deliberate "fail the tx-build, don't
95
+ * silently reroute" contract: a wrong-but-present feed id is NOT validated
96
+ * here (it surfaces on-chain at dry-run); a ticker MISSING from every listed
97
+ * source's feeds is caught here.
98
98
  *
99
- * The selected source's fetch + build runs against its own infra, guaranteeing
100
- * per-rule PTB atomicity. A fee-source pre-check runs first (the source's
101
- * `requiresFeeSource`) BEFORE any off-chain fetch or PTB mutation — so a
102
- * fee-charging source with no `opts.feeSource` throws
99
+ * Each source's fetch + build runs against its own infra, guaranteeing
100
+ * per-rule PTB atomicity. A fee-source pre-check runs first (any listed
101
+ * source's `requiresFeeSource`) BEFORE any off-chain fetch or PTB mutation —
102
+ * so a fee-charging source with no `opts.feeSource` throws
103
103
  * `OracleFeeSourceUnavailable` with zero wasted network calls and zero stray
104
- * moveCalls. Only once that check passes does the off-chain fetch run and
105
- * complete before any PTB mutation; on-chain reads inside `buildUpdateCalls`
106
- * can still fail mid-append for other reasons — callers discard the tx on any
107
- * throw.
104
+ * moveCalls. Only once that check passes do the off-chain fetches run — in
105
+ * parallel across sources — and ALL settle before the first PTB mutation;
106
+ * on-chain reads inside `buildUpdateCalls` can still fail mid-append for
107
+ * other reasons — callers discard the tx on any throw.
108
108
  *
109
109
  * **Collector-feed leg is rule-aware:** a lazer-served group's
110
110
  * `buildUpdateCalls` returns the verified `Update` PTB value
@@ -139,7 +139,7 @@ export declare function refreshOraclePrices(tx: Transaction, host: OracleHost, t
139
139
  /**
140
140
  * @internal Test-only: layer fake `PriceUpdateRule`s on top of the
141
141
  * production registry (see `rule-registry.ts`'s `resolveOracleRule`).
142
- * Production callers never set this — routing is by `host.oracleSource`
142
+ * Production callers never set this — routing is by `host.oracleSources`
143
143
  * alone.
144
144
  */
145
145
  ruleOverrides?: Partial<Record<OracleSource, PriceUpdateRule>>;
@@ -19,9 +19,11 @@
19
19
  *
20
20
  * `refreshOraclePrices` additionally routes the on-chain price *update* leg
21
21
  * (the fetch + verify/push step, before any of the above feeding) through the
22
- * `PriceUpdateRule` selected by `host.oracleSource` — see `rule-registry.ts`.
22
+ * `PriceUpdateRule` of EVERY source in the `host.oracleSources` fed set — see
23
+ * `rule-registry.ts`.
23
24
  */
24
25
  import { aggregate as aggregateCall, newCollector } from "../generated/waterx_oracle/oracle.js";
26
+ import { ownEntry } from "../utils/record.js";
25
27
  import { OracleFeeSourceUnavailableError } from "./pyth.js";
26
28
  import { resolveOracleRule } from "./rule-registry.js";
27
29
  import { feedConstantRule } from "./rules/constant-rule.js";
@@ -115,7 +117,13 @@ export function aggregateTicker(tx, host, args) {
115
117
  // waterx_rule::collect_batch_latest verifies the batch signature and feeds
116
118
  // this collector's symbol from the batch. If the ticker's aggregator does
117
119
  // not (yet) weight `WaterxRule`, the contribution is silently dropped
118
- // on-chain — feeding ahead of the weight migration is harmless.
120
+ // on-chain — feeding ahead of the weight migration is safe for THIS tx.
121
+ // CAVEAT (unlike lazer): the feed call records a per-symbol signed-
122
+ // timestamp high-water mark REGARDLESS of weights, and a replayed
123
+ // timestamp ABORTS (`EReplayedSignature`, audit F-014) — so two PTBs
124
+ // carrying the same envelope for the same symbol cannot both land; the
125
+ // second aborts even where waterx is unweighted. See WaterxRule's module
126
+ // header.
119
127
  feedWaterxRule(tx, host, collector, args.waterxEnvelope);
120
128
  fed = true;
121
129
  }
@@ -156,7 +164,7 @@ export function aggregateTickerWithPyth(tx, host, args) {
156
164
  * {@link refreshOraclePrices}), which feeds both.
157
165
  */
158
166
  export function aggregateTickerWithConstant(tx, host, args) {
159
- if (host.config.packages.pyth_rule?.feeds?.[args.ticker] !== undefined) {
167
+ if (ownEntry(host.config.packages.pyth_rule?.feeds, args.ticker) !== undefined) {
160
168
  throw new Error(`'${args.ticker}' is in pyth_rule.feeds (dual-feed) — feed both via aggregateTicker({ priceInfoObjectId }) / refreshOraclePrices, not aggregateTickerWithConstant`);
161
169
  }
162
170
  aggregateTicker(tx, host, { ticker: args.ticker });
@@ -167,27 +175,26 @@ export function aggregateTickerWithConstant(tx, host, args) {
167
175
  * entry, Lazer if the lazer update leg served it — see below — Supra when
168
176
  * enabled, Constant when it's a constant ticker).
169
177
  *
170
- * Before that, the on-chain price *update* leg is routed by `host.oracleSource`
171
- * (see `rule-registry.ts`): the ONE selected source serves every ticker in its
172
- * `supportedTickers(host)`. There is **no cross-source fallback** — a requested
173
- * ticker the selected source does not serve, and that is not a constant-only
174
- * ticker (which needs no price-update leg), fails the build immediately with a
175
- * clear error naming the ticker and source. That is the deliberate "fail the
176
- * tx-build, don't silently reroute" contract: a wrong-but-present feed id is
177
- * NOT validated here (it surfaces on-chain at dry-run); a MISSING feed for the
178
- * selected source is caught here. When the selected source's feed exists but is
179
- * wrong, this function does nothing special — the on-chain aggregate aborts at
180
- * dry-run, which is correct.
178
+ * Before that, the on-chain price *update* leg is routed by the
179
+ * `host.oracleSources` fed set (see `rule-registry.ts`): EVERY listed source
180
+ * updates the tickers its own `supportedTickers(host)` serves, all in this one
181
+ * PTB. There is **no cross-source fallback** a requested ticker NO listed
182
+ * source serves, and that is not a constant-only ticker (which needs no
183
+ * price-update leg), fails the build immediately with a clear error naming
184
+ * the ticker and the list. That is the deliberate "fail the tx-build, don't
185
+ * silently reroute" contract: a wrong-but-present feed id is NOT validated
186
+ * here (it surfaces on-chain at dry-run); a ticker MISSING from every listed
187
+ * source's feeds is caught here.
181
188
  *
182
- * The selected source's fetch + build runs against its own infra, guaranteeing
183
- * per-rule PTB atomicity. A fee-source pre-check runs first (the source's
184
- * `requiresFeeSource`) BEFORE any off-chain fetch or PTB mutation — so a
185
- * fee-charging source with no `opts.feeSource` throws
189
+ * Each source's fetch + build runs against its own infra, guaranteeing
190
+ * per-rule PTB atomicity. A fee-source pre-check runs first (any listed
191
+ * source's `requiresFeeSource`) BEFORE any off-chain fetch or PTB mutation —
192
+ * so a fee-charging source with no `opts.feeSource` throws
186
193
  * `OracleFeeSourceUnavailable` with zero wasted network calls and zero stray
187
- * moveCalls. Only once that check passes does the off-chain fetch run and
188
- * complete before any PTB mutation; on-chain reads inside `buildUpdateCalls`
189
- * can still fail mid-append for other reasons — callers discard the tx on any
190
- * throw.
194
+ * moveCalls. Only once that check passes do the off-chain fetches run — in
195
+ * parallel across sources — and ALL settle before the first PTB mutation;
196
+ * on-chain reads inside `buildUpdateCalls` can still fail mid-append for
197
+ * other reasons — callers discard the tx on any throw.
191
198
  *
192
199
  * **Collector-feed leg is rule-aware:** a lazer-served group's
193
200
  * `buildUpdateCalls` returns the verified `Update` PTB value
@@ -208,78 +215,121 @@ export function aggregateTickerWithConstant(tx, host, args) {
208
215
  export async function refreshOraclePrices(tx, host, tickers, opts = {}) {
209
216
  if (tickers.length === 0)
210
217
  return;
218
+ // Dedupe the caller's list (order-preserving): a repeated ticker would
219
+ // otherwise aggregate TWICE in this one PTB — wasted gas for every rule,
220
+ // and a hard ABORT under waterx: the second `collect_batch_latest` carries
221
+ // the same envelope, and the on-chain per-symbol replay guard rejects an
222
+ // already-accepted signed timestamp (`EReplayedSignature`, F-014) even
223
+ // inside a single transaction.
224
+ tickers = [...new Set(tickers)];
211
225
  // price_info_object lookup for every ticker with a pyth_rule.feeds entry —
212
226
  // needed by aggregateTicker's (unchanged) Pyth feed step below regardless of
213
227
  // which rule performed the on-chain update for that ticker.
214
- const pythTickers = tickers.filter((t) => host.config.packages.pyth_rule?.feeds?.[t] !== undefined);
228
+ const pythTickers = tickers.filter((t) => ownEntry(host.config.packages.pyth_rule?.feeds, t) !== undefined);
215
229
  const priceInfoByTicker = new Map();
216
230
  pythTickers.forEach((t) => priceInfoByTicker.set(t, host.getPythFeed(t).price_info_object));
217
- // ONE source, no fallback. The selected source serves the tickers in its
218
- // `supportedTickers(host)`; `source` is tracked alongside the group (rather
219
- // than read back off `rule.kind`, typed as the broader PriceUpdateRuleKind)
220
- // so the provider lookup below has an OracleSource to key on without a cast.
221
- const selectedRule = resolveOracleRule(host.oracleSource, opts.ruleOverrides);
222
- const selectedSupported = new Set(selectedRule.supportedTickers(host));
223
- // Fail the tx-build (NOT client init, NOT a silent reroute) when the selected
224
- // source has no feed for a requested ticker that actually needs a price
231
+ // The fed set is a LIST (`host.oracleSources`, normalized + deduped at
232
+ // client creation): ONE build carries every listed source's data, and the
233
+ // chain's per-ticker weight tables decide which contributions count
234
+ // feeding an unweighted rule's PRICE is dropped on-chain, while starving a
235
+ // weighted one aborts. That asymmetry is what makes weight migrations
236
+ // safe: flip weights per ticker at any time while the fed set stays a
237
+ // superset of every ticker's weighted set. (One caveat: waterx's feed call
238
+ // burns a per-symbol signed-timestamp high-water mark regardless of
239
+ // weights — see aggregateTicker's waterx branch.) Still NO fallback
240
+ // BETWEEN sources: each group serves only the tickers its own feeds list.
241
+ // Zero-ticker groups are dropped here so everything downstream (fee check,
242
+ // fetch fan-out, update-leg build) can assume every group has work.
243
+ const groups = host.oracleSources
244
+ .map((source) => {
245
+ const rule = resolveOracleRule(source, opts.ruleOverrides);
246
+ const supported = new Set(rule.supportedTickers(host));
247
+ return { source, rule, tickers: tickers.filter((t) => supported.has(t)) };
248
+ })
249
+ .filter((group) => group.tickers.length > 0);
250
+ // Fail the tx-build (NOT client init, NOT a silent reroute) when NO listed
251
+ // source has a feed for a requested ticker that actually needs a price
225
252
  // update. Only a CONSTANT-ONLY ticker is exempt — priced entirely by
226
253
  // `constant_rule`, it needs no update leg from any source. A DUAL-FEED ticker
227
254
  // (constant AND pyth) still needs its Pyth leg refreshed, so `isConstantTicker`
228
- // alone must NOT exempt it: under a source that can't serve it, with no
229
- // fallback, feeding an unrefreshed Pyth leg would price it stale (or abort on a
230
- // missing weighted source). `priceInfoByTicker.has(t)` ⇔ the ticker has a
231
- // `pyth_rule.feeds` entry, so `constant && !hasPyth` is exactly constant-only.
232
- // This catches a MISSING feed; a present-but-WRONG feed id is deliberately not
233
- // validated here (it aborts on-chain at dry-run).
255
+ // alone must NOT exempt it: with no source able to serve it, feeding an
256
+ // unrefreshed Pyth leg would price it stale (or abort on a missing weighted
257
+ // source). `priceInfoByTicker.has(t)` ⇔ the ticker has a `pyth_rule.feeds`
258
+ // entry, so `constant && !hasPyth` is exactly constant-only. This catches a
259
+ // MISSING feed; a present-but-WRONG feed id is deliberately not validated
260
+ // here (it aborts on-chain at dry-run).
261
+ const covered = new Set(groups.flatMap((group) => group.tickers));
234
262
  const isConstantOnly = (t) => host.isConstantTicker(t) && !priceInfoByTicker.has(t);
235
- const unservable = tickers.filter((t) => !selectedSupported.has(t) && !isConstantOnly(t));
263
+ const unservable = tickers.filter((t) => !covered.has(t) && !isConstantOnly(t));
236
264
  if (unservable.length > 0) {
237
- throw new Error(`oracleSource '${host.oracleSource}' has no feed configured for ticker(s): ` +
265
+ const sources = host.oracleSources.join(", ");
266
+ throw new Error(`oracleSource [${sources}] has no feed configured for ticker(s): ` +
238
267
  `${unservable.join(", ")}. Sources are self-contained with no fallback — add ` +
239
- `${host.oracleSource} feeds for them, or select a source that serves them.`);
268
+ `feeds for them under a listed source, or list a source that serves them.`);
240
269
  }
241
- const selectedGroup = tickers.filter((t) => selectedSupported.has(t));
242
- // Fee-source pre-check, hoisted ABOVE the off-chain fetch and PTB build below.
243
- // It consults only `rule.requiresFeeSource` known before any fetch or PTB
244
- // mutation — so a fee-charging source with no `feeSource` throws with ZERO
245
- // wasted network calls and zero PTB commands, rather than waiting for
246
- // `buildPythPriceUpdateCalls`'s own per-call guard to fire after the off-chain
247
- // fetch already ran.
248
- if (selectedGroup.length > 0 && !opts.feeSource && selectedRule.requiresFeeSource) {
270
+ // Fee-source pre-check, hoisted ABOVE the off-chain fetches and PTB build
271
+ // below. It consults only `rule.requiresFeeSource` known before any fetch
272
+ // or PTB mutationso a fee-charging source (Pyth Core) in the fed set with
273
+ // no `feeSource` throws with ZERO wasted network calls and zero PTB
274
+ // commands, rather than waiting for `buildPythPriceUpdateCalls`'s own
275
+ // per-call guard to fire after the off-chain fetches already ran.
276
+ if (!opts.feeSource && groups.some((group) => group.rule.requiresFeeSource)) {
249
277
  throw new OracleFeeSourceUnavailableError();
250
278
  }
251
- // Resolve + build the selected source's update leg. The off-chain fetch
252
- // settles before the first PTB mutation, so a fetch failure never strands
253
- // moveCalls in a caller-owned tx. Map each lazer-served ticker to the one
254
- // verified `Update` PTB value for the collector-feed leg below.
279
+ // Phase 1 resolve every group's update data IN PARALLEL: the per-source
280
+ // fetches (Hermes VAA / Lazer POST / quote-center GET) are independent
281
+ // network calls on the tx-build money path, so a multi-source fed set must
282
+ // not pay one RTT per source sequentially. ALL fetches settle before the
283
+ // first PTB mutation below, so a fetch failure never strands moveCalls in a
284
+ // caller-owned tx — and a failure in ANY group fails the whole build (a
285
+ // listed source is load-bearing; silently building without it would starve
286
+ // its weighted tickers on-chain).
287
+ const dataByGroup = await Promise.all(groups.map((group) => resolveGroupUpdateData(host, group, opts.updateDataProvider)));
288
+ // Phase 2 — build each group's update leg sequentially, in list order, so
289
+ // PTB command order stays deterministic. The carry step below is an
290
+ // exhaustive switch over the group's rule kind: a future source whose feed
291
+ // leg needs per-ticker data from its update leg must decide its carry here
292
+ // — falling through silently would starve its weighted tickers on-chain.
255
293
  const lazerUpdateByTicker = new Map();
256
294
  // Signed batch envelope per waterx-served ticker. Unlike Lazer's shared PTB
257
295
  // handle, waterx's verify+feed is bundled into `collect_batch_latest` in the
258
296
  // per-ticker feed leg, so its `buildUpdateCalls` emits nothing and the
259
297
  // envelope is carried straight from the group's fetched data.
260
298
  const waterxEnvelopeByTicker = new Map();
261
- if (selectedGroup.length > 0) {
262
- const group = { source: host.oracleSource, rule: selectedRule, tickers: selectedGroup };
263
- const data = await resolveGroupUpdateData(host, group, opts.updateDataProvider);
264
- const handle = (await selectedRule.buildUpdateCalls(tx, host, data, {
299
+ for (const [i, group] of groups.entries()) {
300
+ const data = dataByGroup[i] ?? null;
301
+ const handle = (await group.rule.buildUpdateCalls(tx, host, data, {
265
302
  cache: opts.cache,
266
303
  feeSource: opts.feeSource,
267
304
  })) ?? undefined;
268
- // Route by the handle's kind discriminant — the one site the tag exists to
269
- // protect: a future non-lazer handle must never be silently fed into
270
- // pyth_lazer_rule::feed.
271
- if (handle?.kind === "pyth_lazer_rule") {
272
- for (const ticker of selectedGroup)
273
- lazerUpdateByTicker.set(ticker, handle.update);
274
- }
275
- // waterx_rule emits no shared handle (verify+feed is bundled into the
276
- // per-ticker `collect_batch_latest`), so the envelope is carried straight
277
- // from this group's fetched data to the feed leg below.
278
- if (selectedRule.kind === "waterx_rule") {
279
- const envelope = waterxEnvelopeOf(data);
280
- if (envelope) {
281
- for (const ticker of selectedGroup)
282
- waterxEnvelopeByTicker.set(ticker, envelope);
305
+ switch (group.rule.kind) {
306
+ case "pyth_rule":
307
+ // Core's update leg wrote the PriceInfoObjects in place — the feed
308
+ // leg reads them by id (`priceInfoByTicker`), nothing to carry.
309
+ break;
310
+ case "pyth_lazer_rule":
311
+ // Route by the handle's kind discriminant — the tag exists so a
312
+ // non-lazer handle can never be silently fed into
313
+ // pyth_lazer_rule::feed.
314
+ if (handle?.kind === "pyth_lazer_rule") {
315
+ for (const ticker of group.tickers)
316
+ lazerUpdateByTicker.set(ticker, handle.update);
317
+ }
318
+ break;
319
+ case "waterx_rule": {
320
+ // waterx_rule emits no shared handle (verify+feed is bundled into the
321
+ // per-ticker `collect_batch_latest`), so the envelope is carried
322
+ // straight from this group's fetched data to the feed leg below.
323
+ const envelope = waterxEnvelopeOf(data);
324
+ if (envelope) {
325
+ for (const ticker of group.tickers)
326
+ waterxEnvelopeByTicker.set(ticker, envelope);
327
+ }
328
+ break;
329
+ }
330
+ default: {
331
+ const exhausted = group.rule.kind;
332
+ throw new Error(`refreshOraclePrices: unhandled rule kind '${String(exhausted)}'`);
283
333
  }
284
334
  }
285
335
  }