@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
@@ -0,0 +1,70 @@
1
+ /**
2
+ * `read-plane.ts` — per-source READ-plane resolution: which of a caller's
3
+ * tickers a source can PRICE off-chain, and with which ids. The write plane
4
+ * (`PriceUpdateRule` + each source's `feeds` block) and the read plane are
5
+ * DIFFERENT namespaces for the pyth sources: Lazer WRITES with the integer
6
+ * ids in `pyth_lazer_rule.feeds`, but every Hermes-compatible price READ is
7
+ * keyed by the hex ids only `pyth_rule.feeds` carries. That cross-block
8
+ * dependency is a config invariant ("a lazer-fed ticker must also carry a
9
+ * Core hex entry to be read-visible"), and it lives HERE, once — consumers
10
+ * (FE/BE price facades) resolve through this instead of hardcoding which
11
+ * sources share which feeds namespace.
12
+ */
13
+ import type { Network } from "../constants.ts";
14
+ import type { OracleHost } from "./host.ts";
15
+ import type { OracleSource } from "./price-update-rule.ts";
16
+ /**
17
+ * One source's read plan for a requested ticker set.
18
+ *
19
+ * - `plane: "hermes"` (pyth sources) — price via a Hermes-compatible REST
20
+ * endpoint, one entry per servable ticker mapped to its HEX feed id. The
21
+ * endpoint to execute against is {@link resolveHermesReadEndpoint} — the
22
+ * plan (ids) plus that resolver (host) is the complete read contract.
23
+ * - `plane: "quote_center"` (waterx) — price via the quote-center symbols
24
+ * api, keyed by ticker; served set = the `waterx_rule.feeds` block. An
25
+ * ABSENT block (source listed, package missing from the loaded config)
26
+ * serves NOTHING: claiming tickers would silently reroute reads to the
27
+ * quote-center — it happily serves symbols regardless of on-chain config —
28
+ * and swallow tickers a later-listed source could price. The
29
+ * misconfiguration is caught loudly by the consumer's feeds assert at
30
+ * client creation instead.
31
+ * - `unreadable` — requested tickers this source WRITES on-chain (its update
32
+ * leg serves them) but its read plane cannot price: the silent-invisibility
33
+ * trap (e.g. a lazer-fed ticker with no `pyth_rule.feeds` hex entry).
34
+ * Callers should surface these loudly; empty for sources whose write and
35
+ * read namespaces coincide.
36
+ */
37
+ export type OracleReadPlan = {
38
+ unreadable: string[];
39
+ } & ({
40
+ plane: "hermes";
41
+ feedIdByTicker: Map<string, string>;
42
+ } | {
43
+ plane: "quote_center";
44
+ tickers: string[];
45
+ });
46
+ /**
47
+ * Resolve `source`'s read plan for `tickers`. Pure config lookup — no
48
+ * network, no endpoint resolution (endpoints come from
49
+ * `pythCoreHermesEndpoint` / `waterxQuoteCenterEndpoint` / the deployment's
50
+ * own env). A ticker absent from the returned plan is simply not servable by
51
+ * THIS source's read plane — callers decide how to degrade (typically: ask
52
+ * the next source in their `ORACLE_SOURCE` list, then omit).
53
+ */
54
+ /**
55
+ * The Hermes-compatible REST base a deployment's hermes-plane read plans
56
+ * execute against — the endpoint half of the read contract
57
+ * ({@link resolveOracleReadPlan} is the ids half):
58
+ *
59
+ * - `pyth_rule` in the fed set → the Core source's own keyless endpoint
60
+ * (`pythCoreHermesEndpoint(network)`).
61
+ * - otherwise → `override` when the deployment set one (a proxy or
62
+ * self-hosted mirror), else the documented Pyth Pro base
63
+ * (`pythProHermesEndpoint()` — identical for every subscriber; auth is the
64
+ * caller's `pythApiKey` Bearer, not a per-deployment URL).
65
+ *
66
+ * Total — never throws, never falls back Core-ward: a fed set without
67
+ * `pyth_rule` reads Pro (or the override), full stop.
68
+ */
69
+ export declare function resolveHermesReadEndpoint(network: Network, sources: readonly OracleSource[], override?: string): string;
70
+ export declare function resolveOracleReadPlan(host: OracleHost, source: OracleSource, tickers: string[]): OracleReadPlan;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * `read-plane.ts` — per-source READ-plane resolution: which of a caller's
3
+ * tickers a source can PRICE off-chain, and with which ids. The write plane
4
+ * (`PriceUpdateRule` + each source's `feeds` block) and the read plane are
5
+ * DIFFERENT namespaces for the pyth sources: Lazer WRITES with the integer
6
+ * ids in `pyth_lazer_rule.feeds`, but every Hermes-compatible price READ is
7
+ * keyed by the hex ids only `pyth_rule.feeds` carries. That cross-block
8
+ * dependency is a config invariant ("a lazer-fed ticker must also carry a
9
+ * Core hex entry to be read-visible"), and it lives HERE, once — consumers
10
+ * (FE/BE price facades) resolve through this instead of hardcoding which
11
+ * sources share which feeds namespace.
12
+ */
13
+ import { ownEntry } from "../utils/record.js";
14
+ import { pythCoreHermesEndpoint, pythProHermesEndpoint } from "./pyth.js";
15
+ /**
16
+ * Resolve `source`'s read plan for `tickers`. Pure config lookup — no
17
+ * network, no endpoint resolution (endpoints come from
18
+ * `pythCoreHermesEndpoint` / `waterxQuoteCenterEndpoint` / the deployment's
19
+ * own env). A ticker absent from the returned plan is simply not servable by
20
+ * THIS source's read plane — callers decide how to degrade (typically: ask
21
+ * the next source in their `ORACLE_SOURCE` list, then omit).
22
+ */
23
+ /**
24
+ * The Hermes-compatible REST base a deployment's hermes-plane read plans
25
+ * execute against — the endpoint half of the read contract
26
+ * ({@link resolveOracleReadPlan} is the ids half):
27
+ *
28
+ * - `pyth_rule` in the fed set → the Core source's own keyless endpoint
29
+ * (`pythCoreHermesEndpoint(network)`).
30
+ * - otherwise → `override` when the deployment set one (a proxy or
31
+ * self-hosted mirror), else the documented Pyth Pro base
32
+ * (`pythProHermesEndpoint()` — identical for every subscriber; auth is the
33
+ * caller's `pythApiKey` Bearer, not a per-deployment URL).
34
+ *
35
+ * Total — never throws, never falls back Core-ward: a fed set without
36
+ * `pyth_rule` reads Pro (or the override), full stop.
37
+ */
38
+ export function resolveHermesReadEndpoint(network, sources, override) {
39
+ if (sources.includes("pyth_rule"))
40
+ return pythCoreHermesEndpoint(network);
41
+ return override ?? pythProHermesEndpoint();
42
+ }
43
+ export function resolveOracleReadPlan(host, source, tickers) {
44
+ switch (source) {
45
+ case "pyth_rule":
46
+ case "pyth_lazer_rule": {
47
+ // All ticker lookups go through `ownEntry` (own-keys-only): a ticker
48
+ // named like an Object.prototype key ("toString", "constructor", …)
49
+ // must read as not-listed, not as an inherited Function.
50
+ const hexFeeds = host.config.packages.pyth_rule?.feeds;
51
+ const feedIdByTicker = new Map();
52
+ for (const ticker of tickers) {
53
+ const feedId = ownEntry(hexFeeds, ticker)?.feed_id;
54
+ if (feedId !== undefined)
55
+ feedIdByTicker.set(ticker, feedId);
56
+ }
57
+ // For pyth_rule the write and read namespaces coincide, so `unreadable`
58
+ // is always empty; for lazer it is exactly the hex-entry gap.
59
+ const writeFeeds = source === "pyth_lazer_rule" ? host.config.packages.pyth_lazer_rule?.feeds : hexFeeds;
60
+ const unreadable = tickers.filter((ticker) => ownEntry(writeFeeds, ticker) !== undefined && !feedIdByTicker.has(ticker));
61
+ return { plane: "hermes", feedIdByTicker, unreadable };
62
+ }
63
+ case "waterx_rule": {
64
+ // Absent feeds block ⇒ serves nothing (see the OracleReadPlan doc) —
65
+ // never claim tickers the config doesn't name. `ownEntry` (own-keys-
66
+ // only, never the `in` operator or a bare bracket read) so a
67
+ // prototype-key ticker can't count as feeds-listed and poison the
68
+ // quote-center batch (which 404s whole batches on unknown symbols).
69
+ const feeds = host.config.packages.waterx_rule?.feeds;
70
+ return {
71
+ plane: "quote_center",
72
+ tickers: tickers.filter((ticker) => ownEntry(feeds, ticker) !== undefined),
73
+ unreadable: [],
74
+ };
75
+ }
76
+ default: {
77
+ const exhausted = source;
78
+ throw new Error(`resolveOracleReadPlan: unhandled OracleSource '${String(exhausted)}'`);
79
+ }
80
+ }
81
+ }
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * `rule-registry.ts` — maps a client-selected `OracleSource` to its concrete
3
- * `PriceUpdateRule` implementation. `refreshOraclePrices` (`aggregate.ts`) is
4
- * the only production caller; this is the one place `OracleSource` values are
5
- * wired to a rule instance. Selection is driven purely by the value passed in
6
- * (ultimately `OracleHost.oracleSource`, the `oracleSource` client create
3
+ * `PriceUpdateRule` implementation; the ONE place `OracleSource` values are
4
+ * wired to a rule instance. `refreshOraclePrices` (`aggregate.ts`) resolves
5
+ * every `host.oracleSources` entry through it, and consumers (e.g. a BE
6
+ * prefetch cache keying per source) import `resolveOracleRule` rather than
7
+ * hand-mirroring this map. Selection is driven purely by the value passed in
8
+ * (ultimately `OracleHost.oracleSources`, the `oracleSource` client create
7
9
  * option) — never by a config JSON `enabled` flag and never by `process.env`.
8
10
  *
9
11
  * Each source is self-contained: it owns its own infra + config and does NOT
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * `rule-registry.ts` — maps a client-selected `OracleSource` to its concrete
3
- * `PriceUpdateRule` implementation. `refreshOraclePrices` (`aggregate.ts`) is
4
- * the only production caller; this is the one place `OracleSource` values are
5
- * wired to a rule instance. Selection is driven purely by the value passed in
6
- * (ultimately `OracleHost.oracleSource`, the `oracleSource` client create
3
+ * `PriceUpdateRule` implementation; the ONE place `OracleSource` values are
4
+ * wired to a rule instance. `refreshOraclePrices` (`aggregate.ts`) resolves
5
+ * every `host.oracleSources` entry through it, and consumers (e.g. a BE
6
+ * prefetch cache keying per source) import `resolveOracleRule` rather than
7
+ * hand-mirroring this map. Selection is driven purely by the value passed in
8
+ * (ultimately `OracleHost.oracleSources`, the `oracleSource` client create
7
9
  * option) — never by a config JSON `enabled` flag and never by `process.env`.
8
10
  *
9
11
  * Each source is self-contained: it owns its own infra + config and does NOT
@@ -6,8 +6,9 @@
6
6
  * across rules by `kind`. Mechanical wrap only — no on-chain/off-chain logic
7
7
  * changes vs `../pyth.ts` / `./pyth-rule.ts`.
8
8
  */
9
+ import { ownEntry } from "../../utils/record.js";
9
10
  import { assertRuleUpdateData, } from "../price-update-rule.js";
10
- import { buildPythPriceUpdateCalls, endpointSupportedFeedIds, fetchPriceFeedsUpdateData, } from "../pyth.js";
11
+ import { buildPythPriceUpdateCalls, endpointSupportedFeedIds, fetchPriceFeedsUpdateData, pythCoreHermesEndpoint, } from "../pyth.js";
11
12
  /**
12
13
  * Shape check ONLY — the `kind` discriminant is checked separately by the
13
14
  * caller before this runs, since a same-shaped payload from a different rule
@@ -33,10 +34,10 @@ export const PythCoreRule = {
33
34
  async fetchUpdateData(host, tickers) {
34
35
  if (tickers.length === 0)
35
36
  return null;
36
- // `host.pyth` is this source's own infra — the fixed per-network Core
37
- // Pyth block plus the caller's api_key/fetch. Endpoint, credential and
38
- // retry/timeout policy all come from it.
39
- const endpoint = host.pyth.hermes_endpoint;
37
+ // Endpoint is this source's own per-network infra (`PYTH_CORE_INFRA`);
38
+ // credential + retry/timeout policy are the caller-supplied `client.pyth`
39
+ // access slice.
40
+ const endpoint = pythCoreHermesEndpoint(host.network);
40
41
  const feedIds = tickers.map((ticker) => host.getPythFeed(ticker).feed_id);
41
42
  const updates = await fetchPriceFeedsUpdateData(endpoint, feedIds, {
42
43
  apiKey: host.pyth.api_key,
@@ -76,7 +77,7 @@ export const PythCoreRule = {
76
77
  for (const ticker of tickers) {
77
78
  // Same lookup as `host.getPythFeed(ticker)` minus its throw — an
78
79
  // unlisted ticker is a miss here, not an error.
79
- const feedId = host.config.packages.pyth_rule?.feeds?.[ticker]?.feed_id;
80
+ const feedId = ownEntry(host.config.packages.pyth_rule?.feeds, ticker)?.feed_id;
80
81
  if (feedId === undefined || !packedFeedIds.has(feedId))
81
82
  return null;
82
83
  feedIds.push(feedId);
@@ -8,8 +8,29 @@
8
8
  * `Update` PTB value back through a `RuleUpdateHandle` for the feed calls.
9
9
  */
10
10
  import type { Transaction, TransactionArgument } from "@mysten/sui/transactions";
11
+ import type { Network } from "../../constants.ts";
11
12
  import type { OracleHost } from "../host.ts";
12
13
  import { type PriceUpdateRule } from "../price-update-rule.ts";
14
+ /**
15
+ * Pyth Lazer (Pyth Pro) external infra — owned by THIS source, by network.
16
+ * Per-network constants for infrastructure Pyth operates (not part of the
17
+ * `waterx-config` JSON), co-located with the only rule that reads them —
18
+ * no other oracle source ever touches a Lazer endpoint or verifier.
19
+ *
20
+ * - `endpoint` — Lazer HTTP API base; signed updates come from
21
+ * `POST /v1/latest_price` (Bearer-authenticated). The service is
22
+ * network-agnostic (one signed payload verifies on any chain that trusts the
23
+ * Lazer signers), so both networks share the production host.
24
+ * - `verifier_package` — the Sui package carrying
25
+ * `pyth_lazer::parse_and_verify_le_ecdsa_update`. Per-network: testnet is
26
+ * still the original v1 publish; mainnet is the v2-upgraded package (which
27
+ * still exposes the v1 entry `pyth_lazer_rule` binds). Values mirror the
28
+ * contract repo's `pyth_lazer_rule/Move.toml` published-at pins.
29
+ */
30
+ export declare const LAZER_INFRA: Record<Network, {
31
+ endpoint: string;
32
+ verifier_package: string;
33
+ }>;
13
34
  /** `pyth_lazer_rule`'s narrowed `RuleUpdateData.payload` shape. */
14
35
  export interface PythLazerUpdatePayload {
15
36
  /** One signed `leEcdsa` message carrying every requested feed. */
@@ -8,9 +8,35 @@
8
8
  * `Update` PTB value back through a `RuleUpdateHandle` for the feed calls.
9
9
  */
10
10
  import { fromHex } from "@mysten/bcs";
11
- import { LAZER_DEFAULTS } from "../config.js";
11
+ import { ownEntry } from "../../utils/record.js";
12
12
  import { assertRuleUpdateData, } from "../price-update-rule.js";
13
13
  import { fetchWithPolicy, joinEndpointPath, rethrowExhaustedFetch } from "../update-fetch.js";
14
+ /**
15
+ * Pyth Lazer (Pyth Pro) external infra — owned by THIS source, by network.
16
+ * Per-network constants for infrastructure Pyth operates (not part of the
17
+ * `waterx-config` JSON), co-located with the only rule that reads them —
18
+ * no other oracle source ever touches a Lazer endpoint or verifier.
19
+ *
20
+ * - `endpoint` — Lazer HTTP API base; signed updates come from
21
+ * `POST /v1/latest_price` (Bearer-authenticated). The service is
22
+ * network-agnostic (one signed payload verifies on any chain that trusts the
23
+ * Lazer signers), so both networks share the production host.
24
+ * - `verifier_package` — the Sui package carrying
25
+ * `pyth_lazer::parse_and_verify_le_ecdsa_update`. Per-network: testnet is
26
+ * still the original v1 publish; mainnet is the v2-upgraded package (which
27
+ * still exposes the v1 entry `pyth_lazer_rule` binds). Values mirror the
28
+ * contract repo's `pyth_lazer_rule/Move.toml` published-at pins.
29
+ */
30
+ export const LAZER_INFRA = {
31
+ MAINNET: {
32
+ endpoint: "https://pyth-lazer.dourolabs.app",
33
+ verifier_package: "0xefbfd064480777699fd9c557a5804d72ace7bc82661fdc8d1f1a44ea6d92ee10",
34
+ },
35
+ TESTNET: {
36
+ endpoint: "https://pyth-lazer.dourolabs.app",
37
+ verifier_package: "0xf5bd2141967507050a91b58de3d95e77c432cd90d1799ee46effc27430a68c21",
38
+ },
39
+ };
14
40
  /**
15
41
  * Signed-update request pins, mirroring what the on-chain rule consumes:
16
42
  * - `properties` — `price` + `exponent` are REQUIRED by
@@ -137,7 +163,9 @@ export const PythLazerRule = {
137
163
  // not fail per ticker as if only that feed were missing.
138
164
  const { feeds } = requireLazerPackage(host);
139
165
  const feedIds = tickers.map((ticker) => {
140
- const feedId = feeds[ticker];
166
+ // ownEntry: a prototype-key ticker ("toString") must throw here as
167
+ // unlisted, not turn an inherited Function into a "feed id".
168
+ const feedId = ownEntry(feeds, ticker);
141
169
  if (feedId === undefined) {
142
170
  throw new Error(`No pyth_lazer_rule feed listed for ticker: ${ticker}`);
143
171
  }
@@ -147,7 +175,7 @@ export const PythLazerRule = {
147
175
  if (!apiKey) {
148
176
  throw new LazerApiKeyMissingError();
149
177
  }
150
- const update = await fetchLazerSignedUpdate(LAZER_DEFAULTS[host.network].endpoint, apiKey, feedIds, host.pyth.fetch);
178
+ const update = await fetchLazerSignedUpdate(LAZER_INFRA[host.network].endpoint, apiKey, feedIds, host.pyth.fetch);
151
179
  return { kind: "pyth_lazer_rule", payload: { update, feedIds } };
152
180
  },
153
181
  /**
@@ -165,7 +193,7 @@ export const PythLazerRule = {
165
193
  return null;
166
194
  const packedFeedIds = new Set(payload.feedIds);
167
195
  for (const ticker of tickers) {
168
- const feedId = host.config.packages.pyth_lazer_rule?.feeds?.[ticker];
196
+ const feedId = ownEntry(host.config.packages.pyth_lazer_rule?.feeds, ticker);
169
197
  if (feedId === undefined || !packedFeedIds.has(feedId))
170
198
  return null;
171
199
  }
@@ -184,7 +212,7 @@ export const PythLazerRule = {
184
212
  return undefined;
185
213
  const lazer = requireLazerPackage(host);
186
214
  const [update] = tx.moveCall({
187
- target: `${LAZER_DEFAULTS[host.network].verifier_package}::pyth_lazer::parse_and_verify_le_ecdsa_update`,
215
+ target: `${LAZER_INFRA[host.network].verifier_package}::pyth_lazer::parse_and_verify_le_ecdsa_update`,
188
216
  arguments: [tx.object(lazer.state), tx.object.clock(), tx.pure.vector("u8", payload.update)],
189
217
  });
190
218
  return { kind: "pyth_lazer_rule", update };
@@ -7,6 +7,7 @@
7
7
  * `PriceInfoObject` is fresh.
8
8
  */
9
9
  import { feed as pythRuleFeed } from "../../generated/waterx_pyth_rule/pyth_rule.js";
10
+ import { PYTH_CORE_INFRA } from "../pyth.js";
10
11
  export function feedPythRule(tx, host, collector, priceInfoObjectId) {
11
12
  pythRuleFeed({
12
13
  package: host.config.packages.pyth_rule.published_at,
@@ -15,10 +16,10 @@ export function feedPythRule(tx, host, collector, priceInfoObjectId) {
15
16
  config: tx.object(host.config.packages.pyth_rule.config),
16
17
  // The deployed pyth_rule package is compiled against the Core pyth
17
18
  // dependency, so its `&PythState` parameter is the Core-package-qualified
18
- // type and `host.pyth` (the fixed per-network Core infra) is always the
19
- // right state to pass. The config's price_info_object entries are Core
20
- // objects to match.
21
- pythState: tx.object(host.pyth.state_id),
19
+ // type and the Core source's own per-network state (`PYTH_CORE_INFRA`)
20
+ // is always the right state to pass. The config's price_info_object
21
+ // entries are Core objects to match.
22
+ pythState: tx.object(PYTH_CORE_INFRA[host.network].state_id),
22
23
  pythPriceInfo: tx.object(priceInfoObjectId),
23
24
  },
24
25
  })(tx);
@@ -4,7 +4,7 @@
4
4
  * `aggregateTicker` appends per waterx-routed ticker. Pulls one enclave-signed
5
5
  * batch envelope covering every requested ticker from the quote-center
6
6
  * (`GET /v1/quotes/update?symbols=…`, endpoint from `host.waterx` — the
7
- * `waterxEndpoint`/`waterxFetch` create options — else `WATERX_DEFAULTS`), then —
7
+ * `waterxEndpoint`/`waterxFetch` create options — else its own `WATERX_INFRA`), then —
8
8
  * unlike Pyth Lazer, whose verify is a single shared PTB step — verifies AND
9
9
  * feeds in ONE `waterx_rule::collect_batch_latest` call per collector (the Move
10
10
  * API bundles the two). So `buildUpdateCalls` emits nothing and the signed
@@ -13,12 +13,47 @@
13
13
  * `collect_batch_latest` is the dual-rule path: it feeds the item matching
14
14
  * `collector.symbol()` WITHOUT aggregating, so a waterx-routed ticker composes
15
15
  * onto the same collector as Pyth/Supra (compose-then-aggregate). On-chain a
16
- * freshness miss / replayed timestamp ABSTAINS (the other weighted rules
17
- * cover); a config/integrity mismatch or bad signature aborts.
16
+ * FRESHNESS miss ABSTAINS (the other weighted rules cover); a config/
17
+ * integrity mismatch, bad signature, future timestamp — or a REPLAYED signed
18
+ * timestamp — ABORTS (`EReplayedSignature`, audit F-014: a signed tuple is
19
+ * single-use per symbol, enforced by a per-symbol high-water mark BEFORE any
20
+ * weight arbitration). Consequence for concurrent builds: two PTBs carrying
21
+ * the same envelope for the same symbol cannot both land — the second aborts
22
+ * even if the rule is unweighted for that ticker. Never share one fetched
23
+ * envelope across builds that may execute concurrently for the same symbol.
18
24
  */
19
25
  import type { Transaction, TransactionArgument } from "@mysten/sui/transactions";
26
+ import type { Network } from "../../constants.ts";
20
27
  import type { OracleHost } from "../host.ts";
21
28
  import { type PriceUpdateRule, type RuleUpdateData } from "../price-update-rule.ts";
29
+ /** The single signing intent the quote-center emits — exported so read-plane
30
+ * consumers can mirror the rule's own envelope intent check (a mispointed
31
+ * endpoint must be rejected by reads exactly as tx-builds reject it). */
32
+ export declare const BATCH_PRICE_INTENT = 1;
33
+ /**
34
+ * WaterX quote-center external infra — owned by THIS source, by network.
35
+ * Mirrors `PYTH_CORE_INFRA` (oracle/pyth.ts) and `LAZER_INFRA`
36
+ * (rules/pyth-lazer-rule.ts): per-network constants for infrastructure the
37
+ * source's operator runs, co-located with the only rule that reads them — no
38
+ * other oracle source ever touches a quote-center endpoint. Public read (no
39
+ * auth), so there is no api_key. `endpoint` has no trailing slash — the rule
40
+ * appends the path.
41
+ *
42
+ * These are the DEFAULTS behind the caller's `client.waterx` access slice
43
+ * (`waterxEndpoint` / `waterxFetch` create options) — the browser-CORS proxy
44
+ * hook, since this is the one source fetched from the page.
45
+ */
46
+ export declare const WATERX_INFRA: Record<Network, {
47
+ endpoint: string;
48
+ }>;
49
+ /**
50
+ * The waterx source's quote-center base for `network` — the ONE accessor
51
+ * consumers (BE/FE read planes) use when, and only when, their own
52
+ * `ORACLE_SOURCE` resolves to `'waterx_rule'`. Mirrors
53
+ * `pythCoreHermesEndpoint`. Under any other source the read endpoint is that
54
+ * source's own configuration — never this one.
55
+ */
56
+ export declare function waterxQuoteCenterEndpoint(network: Network): string;
22
57
  /**
23
58
  * One item inside a signed batch payload, mirroring the quote-center
24
59
  * `/v1/quotes/update` JSON 1:1 (snake_case). The u64 integer fields are the
@@ -82,8 +117,10 @@ export declare function waterxEnvelopeOf(data: RuleUpdateData): WaterxSignedEnve
82
117
  * per item, the exact shape the enclave signed) and contribute the price for
83
118
  * `collector.symbol()` to the collector. One collect call re-verifies the batch
84
119
  * signature and picks this collector's symbol out of the batch; on-chain it
85
- * abstains (records `none`) instead of aborting when the symbol is stale,
86
- * absent from the batch, or its timestamp was already accepted (replay).
120
+ * abstains (records `none`) when the symbol is stale or absent from the batch,
121
+ * but ABORTS `EReplayedSignature` when the symbol's signed timestamp was
122
+ * already accepted (per-symbol high-water mark, audit F-014) — see the module
123
+ * header for the concurrent-build consequence.
87
124
  */
88
125
  export declare function feedWaterxRule(tx: Transaction, host: OracleHost, collector: TransactionArgument, envelope: WaterxSignedEnvelope): void;
89
126
  export declare const WaterxRule: PriceUpdateRule;
@@ -4,7 +4,7 @@
4
4
  * `aggregateTicker` appends per waterx-routed ticker. Pulls one enclave-signed
5
5
  * batch envelope covering every requested ticker from the quote-center
6
6
  * (`GET /v1/quotes/update?symbols=…`, endpoint from `host.waterx` — the
7
- * `waterxEndpoint`/`waterxFetch` create options — else `WATERX_DEFAULTS`), then —
7
+ * `waterxEndpoint`/`waterxFetch` create options — else its own `WATERX_INFRA`), then —
8
8
  * unlike Pyth Lazer, whose verify is a single shared PTB step — verifies AND
9
9
  * feeds in ONE `waterx_rule::collect_batch_latest` call per collector (the Move
10
10
  * API bundles the two). So `buildUpdateCalls` emits nothing and the signed
@@ -13,16 +13,51 @@
13
13
  * `collect_batch_latest` is the dual-rule path: it feeds the item matching
14
14
  * `collector.symbol()` WITHOUT aggregating, so a waterx-routed ticker composes
15
15
  * onto the same collector as Pyth/Supra (compose-then-aggregate). On-chain a
16
- * freshness miss / replayed timestamp ABSTAINS (the other weighted rules
17
- * cover); a config/integrity mismatch or bad signature aborts.
16
+ * FRESHNESS miss ABSTAINS (the other weighted rules cover); a config/
17
+ * integrity mismatch, bad signature, future timestamp — or a REPLAYED signed
18
+ * timestamp — ABORTS (`EReplayedSignature`, audit F-014: a signed tuple is
19
+ * single-use per symbol, enforced by a per-symbol high-water mark BEFORE any
20
+ * weight arbitration). Consequence for concurrent builds: two PTBs carrying
21
+ * the same envelope for the same symbol cannot both land — the second aborts
22
+ * even if the rule is unweighted for that ticker. Never share one fetched
23
+ * envelope across builds that may execute concurrently for the same symbol.
18
24
  */
19
25
  import { fromHex } from "@mysten/bcs";
20
26
  import { collectBatchLatest, newBatchItem, newBatchPayload, pushBatchItem, } from "../../generated/waterx_rule/waterx_rule.js";
21
- import { WATERX_DEFAULTS } from "../config.js";
27
+ import { ownEntry } from "../../utils/record.js";
22
28
  import { assertRuleUpdateData, } from "../price-update-rule.js";
23
29
  import { FetchPolicyError, fetchWithPolicy, joinEndpointPath, } from "../update-fetch.js";
24
- /** The single signing intent (`BATCH_PRICE_INTENT`) the quote-center emits. */
25
- const BATCH_PRICE_INTENT = 1;
30
+ /** The single signing intent the quote-center emits — exported so read-plane
31
+ * consumers can mirror the rule's own envelope intent check (a mispointed
32
+ * endpoint must be rejected by reads exactly as tx-builds reject it). */
33
+ export const BATCH_PRICE_INTENT = 1;
34
+ /**
35
+ * WaterX quote-center external infra — owned by THIS source, by network.
36
+ * Mirrors `PYTH_CORE_INFRA` (oracle/pyth.ts) and `LAZER_INFRA`
37
+ * (rules/pyth-lazer-rule.ts): per-network constants for infrastructure the
38
+ * source's operator runs, co-located with the only rule that reads them — no
39
+ * other oracle source ever touches a quote-center endpoint. Public read (no
40
+ * auth), so there is no api_key. `endpoint` has no trailing slash — the rule
41
+ * appends the path.
42
+ *
43
+ * These are the DEFAULTS behind the caller's `client.waterx` access slice
44
+ * (`waterxEndpoint` / `waterxFetch` create options) — the browser-CORS proxy
45
+ * hook, since this is the one source fetched from the page.
46
+ */
47
+ export const WATERX_INFRA = {
48
+ MAINNET: { endpoint: "https://quote-center.waterx.app" },
49
+ TESTNET: { endpoint: "https://quote-center-staging.waterx.app" },
50
+ };
51
+ /**
52
+ * The waterx source's quote-center base for `network` — the ONE accessor
53
+ * consumers (BE/FE read planes) use when, and only when, their own
54
+ * `ORACLE_SOURCE` resolves to `'waterx_rule'`. Mirrors
55
+ * `pythCoreHermesEndpoint`. Under any other source the read endpoint is that
56
+ * source's own configuration — never this one.
57
+ */
58
+ export function waterxQuoteCenterEndpoint(network) {
59
+ return WATERX_INFRA[network].endpoint;
60
+ }
26
61
  /**
27
62
  * Shape check ONLY — the `kind` discriminant is checked separately by the
28
63
  * caller before this runs (mirrors the other rules' guard split), so a
@@ -84,18 +119,21 @@ function requireWaterxPackage(host) {
84
119
  return entry;
85
120
  }
86
121
  /**
87
- * Resolve the quote-center infra for this host: the `waterxEndpoint` /
88
- * `waterxFetch` create options when the client carries them, else the network
89
- * default. The fetch policy falls back to the shared `pyth.fetch` policy so a
90
- * consumer that already tuned timeouts/retries once keeps them here.
122
+ * Resolve the quote-center infra for this host: each field independently from
123
+ * the caller's `client.waterx` access slice (`waterxEndpoint` / `waterxFetch`
124
+ * create options) when set, else this source's own `WATERX_INFRA` default /
125
+ * `fetchWithPolicy`'s built-ins. Deliberately NO fallback onto `pyth.fetch`
126
+ * or any other source's policy — sources stay fully independent.
91
127
  *
92
128
  * This is the seam a browser consumer needs: the envelope is fetched FROM THE
93
129
  * PAGE, so a front end whose origin the quote-center does not allow (CORS)
94
130
  * points `endpoint` at a same-origin proxy, or supplies its own `fetchImpl`.
95
131
  */
96
132
  function resolveWaterxInfra(host) {
97
- const infra = host.waterx ?? WATERX_DEFAULTS[host.network];
98
- return { endpoint: infra.endpoint, fetch: infra.fetch ?? host.pyth.fetch };
133
+ return {
134
+ endpoint: host.waterx?.endpoint ?? WATERX_INFRA[host.network].endpoint,
135
+ fetch: host.waterx?.fetch,
136
+ };
99
137
  }
100
138
  /**
101
139
  * Pull one enclave-signed batch envelope covering `symbols` from the
@@ -151,8 +189,10 @@ function decodeSig(hex) {
151
189
  * per item, the exact shape the enclave signed) and contribute the price for
152
190
  * `collector.symbol()` to the collector. One collect call re-verifies the batch
153
191
  * signature and picks this collector's symbol out of the batch; on-chain it
154
- * abstains (records `none`) instead of aborting when the symbol is stale,
155
- * absent from the batch, or its timestamp was already accepted (replay).
192
+ * abstains (records `none`) when the symbol is stale or absent from the batch,
193
+ * but ABORTS `EReplayedSignature` when the symbol's signed timestamp was
194
+ * already accepted (per-symbol high-water mark, audit F-014) — see the module
195
+ * header for the concurrent-build consequence.
156
196
  */
157
197
  export function feedWaterxRule(tx, host, collector, envelope) {
158
198
  const wr = requireWaterxPackage(host);
@@ -221,7 +261,9 @@ export const WaterxRule = {
221
261
  // not fail per ticker as if only that feed were missing.
222
262
  const { feeds } = requireWaterxPackage(host);
223
263
  for (const ticker of tickers) {
224
- if (feeds[ticker] === undefined) {
264
+ // ownEntry: a prototype-key ticker ("toString") must throw here as
265
+ // unlisted, not pass as an inherited Function and reach the network.
266
+ if (ownEntry(feeds, ticker) === undefined) {
225
267
  throw new Error(`No waterx_rule feed listed for ticker: ${ticker}`);
226
268
  }
227
269
  }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * `source-list.ts` — THE parser for a consumer's `ORACLE_SOURCE` env string
3
+ * (comma list of `OracleSource` values → the fed set). The FE and BE
4
+ * previously carried twin hand-written parsers whose semantics drifted once
5
+ * in review (a trailing comma booted one deployment green and 500'd the
6
+ * other); this canonical behavior is what both fold onto:
7
+ *
8
+ * - split on `,`, trim entries, DROP empties (trailing/doubled commas are
9
+ * the most common env typo, never a boot failure)
10
+ * - validate every entry against {@link ORACLE_SOURCES}
11
+ * - dedupe, order-preserving (list order is consumer read-plane policy —
12
+ * the SDK's own fed-set build treats the list as a set)
13
+ * - throw an operator-actionable error on empty/unset/invalid input —
14
+ * there is NO default oracle source
15
+ *
16
+ * The SDK still never reads `process.env` — callers pass the raw string.
17
+ *
18
+ * STRICTER than the consumers' previous `in`-operator checks: a value named
19
+ * like an `Object.prototype` key (`toString`, `constructor`, …) passed those
20
+ * and died deep in the stack; `Set.has` rejects it here at parse.
21
+ *
22
+ * Zod adopters: this THROWS a plain Error. Inside a zod `.transform()` a
23
+ * throw escapes `schema.parse()` un-aggregated and masks sibling issues —
24
+ * wrap it: `try { return parseOracleSourceList(raw); } catch (e) {
25
+ * ctx.addIssue({ code: "custom", message: (e as Error).message }); return
26
+ * z.NEVER; }`.
27
+ */
28
+ import { type OracleSource } from "./price-update-rule.ts";
29
+ /**
30
+ * THE runtime membership check for {@link ORACLE_SOURCES} — the parser below
31
+ * and `PerpClient`'s ctor validation both use this one predicate, so the env
32
+ * parser and the create-option front door can never disagree. `Set.has`,
33
+ * never `in`/bracket reads (prototype-chain safe by construction).
34
+ */
35
+ export declare function isOracleSource(value: string): value is OracleSource;
36
+ export declare function parseOracleSourceList(raw: string | null | undefined): OracleSource[];
@@ -0,0 +1,53 @@
1
+ /**
2
+ * `source-list.ts` — THE parser for a consumer's `ORACLE_SOURCE` env string
3
+ * (comma list of `OracleSource` values → the fed set). The FE and BE
4
+ * previously carried twin hand-written parsers whose semantics drifted once
5
+ * in review (a trailing comma booted one deployment green and 500'd the
6
+ * other); this canonical behavior is what both fold onto:
7
+ *
8
+ * - split on `,`, trim entries, DROP empties (trailing/doubled commas are
9
+ * the most common env typo, never a boot failure)
10
+ * - validate every entry against {@link ORACLE_SOURCES}
11
+ * - dedupe, order-preserving (list order is consumer read-plane policy —
12
+ * the SDK's own fed-set build treats the list as a set)
13
+ * - throw an operator-actionable error on empty/unset/invalid input —
14
+ * there is NO default oracle source
15
+ *
16
+ * The SDK still never reads `process.env` — callers pass the raw string.
17
+ *
18
+ * STRICTER than the consumers' previous `in`-operator checks: a value named
19
+ * like an `Object.prototype` key (`toString`, `constructor`, …) passed those
20
+ * and died deep in the stack; `Set.has` rejects it here at parse.
21
+ *
22
+ * Zod adopters: this THROWS a plain Error. Inside a zod `.transform()` a
23
+ * throw escapes `schema.parse()` un-aggregated and masks sibling issues —
24
+ * wrap it: `try { return parseOracleSourceList(raw); } catch (e) {
25
+ * ctx.addIssue({ code: "custom", message: (e as Error).message }); return
26
+ * z.NEVER; }`.
27
+ */
28
+ import { ORACLE_SOURCES } from "./price-update-rule.js";
29
+ // Widened-annotation Set (not an assertion) so the type predicate below
30
+ // narrows by CONSTRUCTION rather than by cast.
31
+ const ORACLE_SOURCE_SET = new Set(ORACLE_SOURCES);
32
+ /**
33
+ * THE runtime membership check for {@link ORACLE_SOURCES} — the parser below
34
+ * and `PerpClient`'s ctor validation both use this one predicate, so the env
35
+ * parser and the create-option front door can never disagree. `Set.has`,
36
+ * never `in`/bracket reads (prototype-chain safe by construction).
37
+ */
38
+ export function isOracleSource(value) {
39
+ return ORACLE_SOURCE_SET.has(value);
40
+ }
41
+ export function parseOracleSourceList(raw) {
42
+ const parts = (raw ?? "")
43
+ .split(",")
44
+ .map((part) => part.trim())
45
+ .filter((part) => part !== "");
46
+ const sources = parts.filter(isOracleSource);
47
+ if (parts.length === 0 || sources.length !== parts.length) {
48
+ const got = raw == null || raw.trim() === "" ? "unset" : `'${raw}'`;
49
+ throw new Error(`ORACLE_SOURCE must be a comma-separated list of ${ORACLE_SOURCES.join(" | ")} ` +
50
+ `(got ${got}) — there is NO default oracle source; set it in the deployment's env.`);
51
+ }
52
+ return [...new Set(sources)];
53
+ }