@waterx/sdk 3.1.1 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +54 -18
  2. package/dist/cjs/src/account/config.d.ts +0 -16
  3. package/dist/cjs/src/oracle/aggregate.d.ts +96 -17
  4. package/dist/cjs/src/oracle/aggregate.js +179 -21
  5. package/dist/cjs/src/oracle/config.d.ts +85 -2
  6. package/dist/cjs/src/oracle/config.js +30 -1
  7. package/dist/cjs/src/oracle/host.d.ts +14 -1
  8. package/dist/cjs/src/oracle/index.d.ts +19 -5
  9. package/dist/cjs/src/oracle/index.js +54 -6
  10. package/dist/cjs/src/oracle/price-update-rule.d.ts +180 -0
  11. package/dist/cjs/src/oracle/price-update-rule.js +56 -0
  12. package/dist/cjs/src/oracle/pyth.d.ts +142 -11
  13. package/dist/cjs/src/oracle/pyth.js +408 -25
  14. package/dist/cjs/src/oracle/rule-registry.d.ts +41 -0
  15. package/dist/cjs/src/oracle/rule-registry.js +65 -0
  16. package/dist/cjs/src/oracle/rules/pyth-core-rule.d.ts +15 -0
  17. package/dist/cjs/src/oracle/rules/pyth-core-rule.js +99 -0
  18. package/dist/cjs/src/oracle/rules/pyth-lazer-rule.d.ts +41 -0
  19. package/dist/cjs/src/oracle/rules/pyth-lazer-rule.js +197 -0
  20. package/dist/cjs/src/oracle/rules/pyth-rule.js +5 -0
  21. package/dist/cjs/src/oracle/rules/sponsor.d.ts +11 -7
  22. package/dist/cjs/src/oracle/rules/sponsor.js +11 -7
  23. package/dist/cjs/src/oracle/update-fetch.d.ts +115 -0
  24. package/dist/cjs/src/oracle/update-fetch.js +285 -0
  25. package/dist/cjs/src/perp/client.d.ts +40 -5
  26. package/dist/cjs/src/perp/client.js +18 -3
  27. package/dist/cjs/src/perp/config.d.ts +10 -7
  28. package/dist/cjs/src/perp/config.js +77 -17
  29. package/dist/cjs/src/perp/index.d.ts +3 -2
  30. package/dist/cjs/src/perp/index.js +6 -2
  31. package/dist/cjs/src/perp/tx-builders/common.d.ts +52 -15
  32. package/dist/cjs/src/perp/tx-builders/common.js +39 -6
  33. package/dist/cjs/src/perp/tx-builders/wlp.d.ts +11 -3
  34. package/dist/cjs/src/perp/tx-builders/wlp.js +29 -3
  35. package/dist/cjs/src/perp/tx-builders.d.ts +3 -3
  36. package/dist/cjs/src/perp/tx-builders.js +3 -3
  37. package/dist/cjs/src/unified-client.d.ts +25 -0
  38. package/dist/cjs/src/unified-client.js +3 -0
  39. package/dist/src/account/config.d.ts +0 -16
  40. package/dist/src/oracle/aggregate.d.ts +96 -17
  41. package/dist/src/oracle/aggregate.js +180 -22
  42. package/dist/src/oracle/config.d.ts +85 -2
  43. package/dist/src/oracle/config.js +29 -0
  44. package/dist/src/oracle/host.d.ts +14 -1
  45. package/dist/src/oracle/index.d.ts +19 -5
  46. package/dist/src/oracle/index.js +42 -6
  47. package/dist/src/oracle/price-update-rule.d.ts +180 -0
  48. package/dist/src/oracle/price-update-rule.js +53 -0
  49. package/dist/src/oracle/pyth.d.ts +142 -11
  50. package/dist/src/oracle/pyth.js +402 -24
  51. package/dist/src/oracle/rule-registry.d.ts +41 -0
  52. package/dist/src/oracle/rule-registry.js +60 -0
  53. package/dist/src/oracle/rules/pyth-core-rule.d.ts +15 -0
  54. package/dist/src/oracle/rules/pyth-core-rule.js +96 -0
  55. package/dist/src/oracle/rules/pyth-lazer-rule.d.ts +41 -0
  56. package/dist/src/oracle/rules/pyth-lazer-rule.js +192 -0
  57. package/dist/src/oracle/rules/pyth-rule.js +5 -0
  58. package/dist/src/oracle/rules/sponsor.d.ts +11 -7
  59. package/dist/src/oracle/rules/sponsor.js +11 -7
  60. package/dist/src/oracle/update-fetch.d.ts +115 -0
  61. package/dist/src/oracle/update-fetch.js +277 -0
  62. package/dist/src/perp/client.d.ts +40 -5
  63. package/dist/src/perp/client.js +18 -3
  64. package/dist/src/perp/config.d.ts +10 -7
  65. package/dist/src/perp/config.js +77 -17
  66. package/dist/src/perp/index.d.ts +3 -2
  67. package/dist/src/perp/index.js +1 -1
  68. package/dist/src/perp/tx-builders/common.d.ts +52 -15
  69. package/dist/src/perp/tx-builders/common.js +39 -6
  70. package/dist/src/perp/tx-builders/wlp.d.ts +11 -3
  71. package/dist/src/perp/tx-builders/wlp.js +29 -3
  72. package/dist/src/perp/tx-builders.d.ts +3 -3
  73. package/dist/src/perp/tx-builders.js +3 -3
  74. package/dist/src/unified-client.d.ts +25 -0
  75. package/dist/src/unified-client.js +3 -0
  76. package/package.json +1 -1
@@ -0,0 +1,41 @@
1
+ /**
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
7
+ * option) — never by a config JSON `enabled` flag and never by `process.env`.
8
+ *
9
+ * Each source is self-contained: it owns its own infra + config and does NOT
10
+ * back-stop any other source. Both are registered: `pyth_rule` (`PythCoreRule`,
11
+ * Hermes VAA) and `pyth_lazer_rule` (`PythLazerRule`, Lazer signed updates).
12
+ * Resolving a source with no registered rule throws a clear
13
+ * `OracleSourceNotImplemented` error. There is deliberately no cross-source
14
+ * fallback and no client-creation config guard: selecting a source whose feeds
15
+ * are absent is not an error at init — it surfaces at tx-build time for the
16
+ * specific tickers that source can't serve (see `refreshOraclePrices`).
17
+ */
18
+ import type { OracleSource, PriceUpdateRule } from "./price-update-rule.ts";
19
+ /**
20
+ * Thrown by {@link resolveOracleRule} when `source` has no `PriceUpdateRule`
21
+ * registered in either the production registry or a test's `overrides` map.
22
+ * `instanceof`-able (mirrors `OracleFeeSourceUnavailableError` in `pyth.ts`)
23
+ * so a consumer can branch on the failure type directly instead of
24
+ * string-matching `error.message`.
25
+ */
26
+ export declare class OracleSourceNotImplementedError extends Error {
27
+ /** The unregistered `OracleSource` that was requested. */
28
+ readonly source: OracleSource;
29
+ constructor(source: OracleSource);
30
+ }
31
+ /**
32
+ * Resolve the `PriceUpdateRule` registered for `source`.
33
+ *
34
+ * `overrides` — test-only — layers on top of the production registry so a
35
+ * spec can inject a fake rule (e.g. a stub `pyth_lazer_rule`) without
36
+ * touching {@link DEFAULT_RULES}; production callers never pass it.
37
+ *
38
+ * Throws {@link OracleSourceNotImplementedError} (`OracleSourceNotImplemented:
39
+ * <source>`) when nothing is registered for `source` in either map.
40
+ */
41
+ export declare function resolveOracleRule(source: OracleSource, overrides?: Partial<Record<OracleSource, PriceUpdateRule>>): PriceUpdateRule;
@@ -0,0 +1,65 @@
1
+ "use strict";
2
+ /**
3
+ * `rule-registry.ts` — maps a client-selected `OracleSource` to its concrete
4
+ * `PriceUpdateRule` implementation. `refreshOraclePrices` (`aggregate.ts`) is
5
+ * the only production caller; this is the one place `OracleSource` values are
6
+ * wired to a rule instance. Selection is driven purely by the value passed in
7
+ * (ultimately `OracleHost.oracleSource`, the `oracleSource` client create
8
+ * option) — never by a config JSON `enabled` flag and never by `process.env`.
9
+ *
10
+ * Each source is self-contained: it owns its own infra + config and does NOT
11
+ * back-stop any other source. Both are registered: `pyth_rule` (`PythCoreRule`,
12
+ * Hermes VAA) and `pyth_lazer_rule` (`PythLazerRule`, Lazer signed updates).
13
+ * Resolving a source with no registered rule throws a clear
14
+ * `OracleSourceNotImplemented` error. There is deliberately no cross-source
15
+ * fallback and no client-creation config guard: selecting a source whose feeds
16
+ * are absent is not an error at init — it surfaces at tx-build time for the
17
+ * specific tickers that source can't serve (see `refreshOraclePrices`).
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.OracleSourceNotImplementedError = void 0;
21
+ exports.resolveOracleRule = resolveOracleRule;
22
+ const pyth_core_rule_ts_1 = require("./rules/pyth-core-rule.js");
23
+ const pyth_lazer_rule_ts_1 = require("./rules/pyth-lazer-rule.js");
24
+ /**
25
+ * Production registry. Frozen — tests inject a fake rule via
26
+ * `resolveOracleRule`'s `overrides` param instead of mutating this.
27
+ */
28
+ const DEFAULT_RULES = Object.freeze({
29
+ pyth_rule: pyth_core_rule_ts_1.PythCoreRule,
30
+ pyth_lazer_rule: pyth_lazer_rule_ts_1.PythLazerRule,
31
+ });
32
+ /**
33
+ * Thrown by {@link resolveOracleRule} when `source` has no `PriceUpdateRule`
34
+ * registered in either the production registry or a test's `overrides` map.
35
+ * `instanceof`-able (mirrors `OracleFeeSourceUnavailableError` in `pyth.ts`)
36
+ * so a consumer can branch on the failure type directly instead of
37
+ * string-matching `error.message`.
38
+ */
39
+ class OracleSourceNotImplementedError extends Error {
40
+ /** The unregistered `OracleSource` that was requested. */
41
+ source;
42
+ constructor(source) {
43
+ super(`OracleSourceNotImplemented: ${source}`);
44
+ this.name = "OracleSourceNotImplementedError";
45
+ this.source = source;
46
+ }
47
+ }
48
+ exports.OracleSourceNotImplementedError = OracleSourceNotImplementedError;
49
+ /**
50
+ * Resolve the `PriceUpdateRule` registered for `source`.
51
+ *
52
+ * `overrides` — test-only — layers on top of the production registry so a
53
+ * spec can inject a fake rule (e.g. a stub `pyth_lazer_rule`) without
54
+ * touching {@link DEFAULT_RULES}; production callers never pass it.
55
+ *
56
+ * Throws {@link OracleSourceNotImplementedError} (`OracleSourceNotImplemented:
57
+ * <source>`) when nothing is registered for `source` in either map.
58
+ */
59
+ function resolveOracleRule(source, overrides) {
60
+ const rule = overrides?.[source] ?? DEFAULT_RULES[source];
61
+ if (!rule) {
62
+ throw new OracleSourceNotImplementedError(source);
63
+ }
64
+ return rule;
65
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * `PythCoreRule` — `PriceUpdateRule` wrapper around the existing Pyth Core
3
+ * (Hermes VAA) source in `../pyth.ts`. Delegates to `fetchPriceFeedsUpdateData`
4
+ * / `buildPythPriceUpdateCalls` verbatim; this file only adapts them to the
5
+ * `PriceUpdateRule` port shape (fetch → build) so a future router can select
6
+ * across rules by `kind`. Mechanical wrap only — no on-chain/off-chain logic
7
+ * changes vs `../pyth.ts` / `./pyth-rule.ts`.
8
+ */
9
+ import { type PriceUpdateRule } from "../price-update-rule.ts";
10
+ /** `pyth_rule`'s narrowed `RuleUpdateData.payload` shape. */
11
+ export interface PythCoreUpdatePayload {
12
+ readonly updates: Uint8Array[];
13
+ readonly feedIds: string[];
14
+ }
15
+ export declare const PythCoreRule: PriceUpdateRule;
@@ -0,0 +1,99 @@
1
+ "use strict";
2
+ /**
3
+ * `PythCoreRule` — `PriceUpdateRule` wrapper around the existing Pyth Core
4
+ * (Hermes VAA) source in `../pyth.ts`. Delegates to `fetchPriceFeedsUpdateData`
5
+ * / `buildPythPriceUpdateCalls` verbatim; this file only adapts them to the
6
+ * `PriceUpdateRule` port shape (fetch → build) so a future router can select
7
+ * across rules by `kind`. Mechanical wrap only — no on-chain/off-chain logic
8
+ * changes vs `../pyth.ts` / `./pyth-rule.ts`.
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.PythCoreRule = void 0;
12
+ const price_update_rule_ts_1 = require("../price-update-rule.js");
13
+ const pyth_ts_1 = require("../pyth.js");
14
+ /**
15
+ * Shape check ONLY — the `kind` discriminant is checked separately by the
16
+ * caller before this runs, since a same-shaped payload from a different rule
17
+ * (e.g. a hypothetical Lazer payload also carrying `updates`/`feedIds`) must
18
+ * not silently pass as a Pyth Core VAA block.
19
+ */
20
+ function isPythCoreUpdatePayloadShape(payload) {
21
+ return (typeof payload === "object" &&
22
+ payload !== null &&
23
+ Array.isArray(payload.updates) &&
24
+ Array.isArray(payload.feedIds));
25
+ }
26
+ exports.PythCoreRule = {
27
+ kind: "pyth_rule",
28
+ // Charges a per-feed `base_update_fee` via `pyth::update_single_price_feed` —
29
+ // see `PriceUpdateRule.requiresFeeSource`.
30
+ requiresFeeSource: true,
31
+ /** Tickers with a `pyth_rule.feeds` entry (mirrors `refreshOraclePrices`'s filter). */
32
+ supportedTickers(host) {
33
+ return Object.keys(host.config.packages.pyth_rule?.feeds ?? {});
34
+ },
35
+ /** Resolves feed ids for `tickers`, then fetches their Hermes accumulator update. */
36
+ async fetchUpdateData(host, tickers) {
37
+ if (tickers.length === 0)
38
+ return null;
39
+ // `host.pyth` is this source's own infra — the fixed per-network Core
40
+ // Pyth block plus the caller's api_key/fetch. Endpoint, credential and
41
+ // retry/timeout policy all come from it.
42
+ const endpoint = host.pyth.hermes_endpoint;
43
+ const feedIds = tickers.map((ticker) => host.getPythFeed(ticker).feed_id);
44
+ const updates = await (0, pyth_ts_1.fetchPriceFeedsUpdateData)(endpoint, feedIds, {
45
+ apiKey: host.pyth.api_key,
46
+ fetch: host.pyth.fetch,
47
+ });
48
+ // `updates` covers only the feeds this endpoint actually served — the fetch
49
+ // drops (and memoizes) any it lacks, e.g. Core feeds absent from Pyth Pro
50
+ // (WTIUSD/BRENTUSD). Align `feedIds` with them: buildPythPriceUpdateCalls
51
+ // emits one moveCall per feedId and must not reference a feed the
52
+ // accumulator blob doesn't cover.
53
+ return {
54
+ kind: "pyth_rule",
55
+ payload: {
56
+ updates,
57
+ feedIds: (0, pyth_ts_1.endpointSupportedFeedIds)(endpoint, feedIds, host.pyth.api_key),
58
+ },
59
+ };
60
+ },
61
+ /**
62
+ * Subsets a (typically whole-universe) payload from {@link fetchUpdateData}
63
+ * down to exactly `tickers`. Pyth Core charges a per-feed update fee (one
64
+ * `update_single_price_feed` moveCall per `feedIds` entry — see
65
+ * `buildPythPriceUpdateCalls`), so serving a full all-registry payload for a
66
+ * 2-ticker build would multiply both the fee and the PTB size ~N× — a
67
+ * per-feed subset is valid input by construction. Narrows `feedIds` only:
68
+ * the single combined Hermes accumulator blob in `updates` already covers
69
+ * every packed feed and needs no re-slicing. A ticker with no
70
+ * `pyth_rule.feeds` entry, or whose feed id is not packed in THIS payload's
71
+ * `feedIds`, is a coverage gap → `null` (miss), never a silent partial.
72
+ */
73
+ narrowUpdateData(host, data, tickers) {
74
+ const payload = (0, price_update_rule_ts_1.assertRuleUpdateData)(data, "pyth_rule", isPythCoreUpdatePayloadShape, "{ updates: Uint8Array[]; feedIds: string[] }");
75
+ if (!payload || tickers.length === 0)
76
+ return null;
77
+ const packedFeedIds = new Set(payload.feedIds);
78
+ const feedIds = [];
79
+ for (const ticker of tickers) {
80
+ // Same lookup as `host.getPythFeed(ticker)` minus its throw — an
81
+ // unlisted ticker is a miss here, not an error.
82
+ const feedId = host.config.packages.pyth_rule?.feeds?.[ticker]?.feed_id;
83
+ if (feedId === undefined || !packedFeedIds.has(feedId))
84
+ return null;
85
+ feedIds.push(feedId);
86
+ }
87
+ return { kind: "pyth_rule", payload: { updates: payload.updates, feedIds } };
88
+ },
89
+ /** Appends the wormhole/pyth update PTB block for the payload from {@link fetchUpdateData}. */
90
+ async buildUpdateCalls(tx, host, data, opts) {
91
+ const payload = (0, price_update_rule_ts_1.assertRuleUpdateData)(data, "pyth_rule", isPythCoreUpdatePayloadShape, "{ updates: Uint8Array[]; feedIds: string[] }");
92
+ if (!payload)
93
+ return;
94
+ await (0, pyth_ts_1.buildPythPriceUpdateCalls)(tx, host, payload.updates, payload.feedIds, {
95
+ cache: opts?.cache,
96
+ feeSource: opts?.feeSource,
97
+ });
98
+ },
99
+ };
@@ -0,0 +1,41 @@
1
+ /**
2
+ * `PythLazerRule` — `PriceUpdateRule` for Pyth Lazer (Pyth Pro) signed
3
+ * updates, plus `feedLazerRule`, the collector-feed leg `aggregateTicker`
4
+ * appends per lazer-routed ticker. Fetches one `leEcdsa` payload for all
5
+ * requested integer feed ids from the Lazer HTTP API (Bearer-authenticated
6
+ * via the `pythApiKey` create option), verifies it ONCE on-chain via
7
+ * `pyth_lazer::parse_and_verify_le_ecdsa_update`, and hands the resulting
8
+ * `Update` PTB value back through a `RuleUpdateHandle` for the feed calls.
9
+ */
10
+ import type { Transaction, TransactionArgument } from "@mysten/sui/transactions";
11
+ import type { OracleHost } from "../host.ts";
12
+ import { type PriceUpdateRule } from "../price-update-rule.ts";
13
+ /** `pyth_lazer_rule`'s narrowed `RuleUpdateData.payload` shape. */
14
+ export interface PythLazerUpdatePayload {
15
+ /** One signed `leEcdsa` message carrying every requested feed. */
16
+ readonly update: Uint8Array;
17
+ /** Integer Lazer feed ids the update was requested for (debug/audit trail). */
18
+ readonly feedIds: number[];
19
+ }
20
+ /**
21
+ * Thrown by {@link PythLazerRule.fetchUpdateData} when `pyth_lazer_rule` is
22
+ * deployed in config but no `pythApiKey` was supplied at client init — the
23
+ * Lazer HTTP API requires a Bearer token and the SDK never reads
24
+ * `process.env` to find one. `instanceof`-able (mirrors
25
+ * `OracleFeeSourceUnavailableError` in `pyth.ts`) so a consumer can branch on
26
+ * the failure type directly instead of string-matching `error.message`.
27
+ */
28
+ export declare class LazerApiKeyMissingError extends Error {
29
+ constructor();
30
+ }
31
+ /**
32
+ * `pyth_lazer_rule::feed(collector, config, clock, &update)` — contribute the
33
+ * verified Lazer price for `collector.symbol()` to the collector. `update` is
34
+ * the `RuleUpdateHandle` value from {@link PythLazerRule.buildUpdateCalls} in
35
+ * the SAME PTB; one verified update serves every ticker's feed call. On-chain
36
+ * the rule abstains (records `none`) instead of aborting when the symbol is
37
+ * unconfigured, the feed is absent from the update, the value is degenerate,
38
+ * or the Lazer timestamp is stale.
39
+ */
40
+ export declare function feedLazerRule(tx: Transaction, host: OracleHost, collector: TransactionArgument, update: TransactionArgument): void;
41
+ export declare const PythLazerRule: PriceUpdateRule;
@@ -0,0 +1,197 @@
1
+ "use strict";
2
+ /**
3
+ * `PythLazerRule` — `PriceUpdateRule` for Pyth Lazer (Pyth Pro) signed
4
+ * updates, plus `feedLazerRule`, the collector-feed leg `aggregateTicker`
5
+ * appends per lazer-routed ticker. Fetches one `leEcdsa` payload for all
6
+ * requested integer feed ids from the Lazer HTTP API (Bearer-authenticated
7
+ * via the `pythApiKey` create option), verifies it ONCE on-chain via
8
+ * `pyth_lazer::parse_and_verify_le_ecdsa_update`, and hands the resulting
9
+ * `Update` PTB value back through a `RuleUpdateHandle` for the feed calls.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.PythLazerRule = exports.LazerApiKeyMissingError = void 0;
13
+ exports.feedLazerRule = feedLazerRule;
14
+ const bcs_1 = require("@mysten/bcs");
15
+ const config_ts_1 = require("../config.js");
16
+ const price_update_rule_ts_1 = require("../price-update-rule.js");
17
+ const update_fetch_ts_1 = require("../update-fetch.js");
18
+ /**
19
+ * Signed-update request pins, mirroring what the on-chain rule consumes:
20
+ * - `properties` — `price` + `exponent` are REQUIRED by
21
+ * `pyth_lazer_rule::price_or_abstain` (a missing exponent abstains);
22
+ * `confidence` is optional on-chain but requested so the rule's
23
+ * fail-closed confidence gate actually engages (a payload without
24
+ * confidence passes the gate unchecked).
25
+ * - `channel` — `fixed_rate@200ms`, NOT `real_time`: Lazer rejects a request
26
+ * whose channel is faster than ANY requested feed's `min_channel`, and it
27
+ * rejects the WHOLE batch (`400 Feeds do not support channel …`). Only the
28
+ * majors (BTC/ETH/SOL/USDC/DOGE/XRP/BNB/HYPE + EUR/JPY FX) publish
29
+ * `real_time`; the other 19 of the 29 configured feeds — including SUIUSD
30
+ * and every xStock — are `min_channel: fixed_rate@200ms` (Lazer symbol
31
+ * registry, verified 2026-07-22: the same 29-feed batch 400s at
32
+ * `real_time`/`50ms` and serves 200 with the leEcdsa blob at `200ms`).
33
+ * 200ms is the fastest channel every configured feed supports, and the
34
+ * deployed rule accepts it: the v1 on-chain `channel::from_u8` aborts only
35
+ * on the 1000ms fixed-rate channel (real_time / 50ms / 200ms are safe).
36
+ * - `formats: leEcdsa` + `jsonBinaryEncoding: hex` — the Sui verifier takes
37
+ * the `leEcdsa` framing; hex matches `fromHex` below.
38
+ */
39
+ const LAZER_LATEST_PRICE_REQUEST = {
40
+ properties: ["price", "exponent", "confidence"],
41
+ formats: ["leEcdsa"],
42
+ jsonBinaryEncoding: "hex",
43
+ channel: "fixed_rate@200ms",
44
+ };
45
+ /**
46
+ * Shape check ONLY — the `kind` discriminant is checked separately by the
47
+ * caller before this runs (mirrors `PythCoreRule`'s guard split), so a
48
+ * same-shaped payload from a different rule can never silently pass.
49
+ */
50
+ function isPythLazerUpdatePayloadShape(payload) {
51
+ return (typeof payload === "object" &&
52
+ payload !== null &&
53
+ payload.update instanceof Uint8Array &&
54
+ Array.isArray(payload.feedIds));
55
+ }
56
+ /**
57
+ * Thrown by {@link PythLazerRule.fetchUpdateData} when `pyth_lazer_rule` is
58
+ * deployed in config but no `pythApiKey` was supplied at client init — the
59
+ * Lazer HTTP API requires a Bearer token and the SDK never reads
60
+ * `process.env` to find one. `instanceof`-able (mirrors
61
+ * `OracleFeeSourceUnavailableError` in `pyth.ts`) so a consumer can branch on
62
+ * the failure type directly instead of string-matching `error.message`.
63
+ */
64
+ class LazerApiKeyMissingError extends Error {
65
+ constructor() {
66
+ super("LazerApiKeyMissing: pyth_lazer_rule requires a Pyth Lazer access token — " +
67
+ "pass `pythApiKey` when creating the client (the SDK never reads process.env)");
68
+ this.name = "LazerApiKeyMissingError";
69
+ }
70
+ }
71
+ exports.LazerApiKeyMissingError = LazerApiKeyMissingError;
72
+ /** The `pyth_lazer_rule` deployment entry; throws when the config carries none. */
73
+ function requireLazerPackage(host) {
74
+ const entry = host.config.packages.pyth_lazer_rule;
75
+ if (!entry) {
76
+ throw new Error("pyth_lazer_rule package is not deployed in this config");
77
+ }
78
+ return entry;
79
+ }
80
+ /**
81
+ * Fetch one signed `leEcdsa` update for `feedIds` from the Lazer HTTP API.
82
+ * Goes through the shared `fetchWithPolicy` (`../update-fetch.ts`) — same
83
+ * retry/timeout/Bearer policy as `fetchPriceFeedsUpdateData`, unified so
84
+ * both oracle sources fail the same way under upstream degradation.
85
+ */
86
+ async function fetchLazerSignedUpdate(endpoint, apiKey, feedIds, fetchOpts) {
87
+ // joinEndpointPath preserves any base path on the endpoint — the same
88
+ // leading-slash `new URL` footgun that 404'd every feed on the Pyth Pro
89
+ // Hermes endpoint (see update-fetch.ts). Defensive here: the default
90
+ // Lazer endpoint has no base path, but a config override may.
91
+ const url = (0, update_fetch_ts_1.joinEndpointPath)(endpoint, "v1/latest_price");
92
+ let res;
93
+ try {
94
+ res = await (0, update_fetch_ts_1.fetchWithPolicy)(url.toString(), {
95
+ method: "POST",
96
+ headers: { "Content-Type": "application/json" },
97
+ body: JSON.stringify({ priceFeedIds: feedIds, ...LAZER_LATEST_PRICE_REQUEST }),
98
+ }, { apiKey, ...fetchOpts });
99
+ }
100
+ catch (err) {
101
+ (0, update_fetch_ts_1.rethrowExhaustedFetch)(err, (e) => `Lazer price fetch failed: ${e.status}${e.bodySnippet ? ` ${e.bodySnippet}` : ""}`);
102
+ }
103
+ if (!res.ok)
104
+ throw new Error(`Lazer price fetch failed: ${res.status} ${await res.text()}`);
105
+ const json = (await res.json());
106
+ const hex = json.leEcdsa?.data;
107
+ if (typeof hex !== "string" || hex.length === 0) {
108
+ throw new Error("Lazer returned no leEcdsa update data");
109
+ }
110
+ return (0, bcs_1.fromHex)(hex);
111
+ }
112
+ /**
113
+ * `pyth_lazer_rule::feed(collector, config, clock, &update)` — contribute the
114
+ * verified Lazer price for `collector.symbol()` to the collector. `update` is
115
+ * the `RuleUpdateHandle` value from {@link PythLazerRule.buildUpdateCalls} in
116
+ * the SAME PTB; one verified update serves every ticker's feed call. On-chain
117
+ * the rule abstains (records `none`) instead of aborting when the symbol is
118
+ * unconfigured, the feed is absent from the update, the value is degenerate,
119
+ * or the Lazer timestamp is stale.
120
+ */
121
+ function feedLazerRule(tx, host, collector, update) {
122
+ const lazer = requireLazerPackage(host);
123
+ tx.moveCall({
124
+ target: `${lazer.published_at}::pyth_lazer_rule::feed`,
125
+ arguments: [collector, tx.object(lazer.config), tx.object.clock(), update],
126
+ });
127
+ }
128
+ exports.PythLazerRule = {
129
+ kind: "pyth_lazer_rule",
130
+ // Verification is a flat signature check with no Coin argument — no
131
+ // update fee — see `PriceUpdateRule.requiresFeeSource`.
132
+ requiresFeeSource: false,
133
+ /** Tickers with a `pyth_lazer_rule.feeds` entry (integer Lazer feed ids). */
134
+ supportedTickers(host) {
135
+ return Object.keys(host.config.packages.pyth_lazer_rule?.feeds ?? {});
136
+ },
137
+ /** Resolves integer feed ids for `tickers`, then fetches one signed `leEcdsa` update. */
138
+ async fetchUpdateData(host, tickers) {
139
+ if (tickers.length === 0)
140
+ return null;
141
+ // Package-level check first: a config without the deployment must say so,
142
+ // not fail per ticker as if only that feed were missing.
143
+ const { feeds } = requireLazerPackage(host);
144
+ const feedIds = tickers.map((ticker) => {
145
+ const feedId = feeds[ticker];
146
+ if (feedId === undefined) {
147
+ throw new Error(`No pyth_lazer_rule feed listed for ticker: ${ticker}`);
148
+ }
149
+ return feedId;
150
+ });
151
+ const apiKey = host.pyth.api_key;
152
+ if (!apiKey) {
153
+ throw new LazerApiKeyMissingError();
154
+ }
155
+ const update = await fetchLazerSignedUpdate(config_ts_1.LAZER_DEFAULTS[host.network].endpoint, apiKey, feedIds, host.pyth.fetch);
156
+ return { kind: "pyth_lazer_rule", payload: { update, feedIds } };
157
+ },
158
+ /**
159
+ * A Lazer payload is ONE signed `leEcdsa` message covering every feed it was
160
+ * fetched for — verification is a single flat signature check over the whole
161
+ * message (`parse_and_verify_le_ecdsa_update`, no per-feed cost), so the
162
+ * payload is indivisible: it can only be served whole. Returns the whole
163
+ * payload iff every requested ticker's integer feed id is packed in THIS
164
+ * payload's `feedIds`; any coverage gap (unlisted ticker, or a feed this
165
+ * payload does not carry) → `null` (miss), never a silent partial.
166
+ */
167
+ narrowUpdateData(host, data, tickers) {
168
+ const payload = (0, price_update_rule_ts_1.assertRuleUpdateData)(data, "pyth_lazer_rule", isPythLazerUpdatePayloadShape, "{ update: Uint8Array; feedIds: number[] }");
169
+ if (!payload || tickers.length === 0)
170
+ return null;
171
+ const packedFeedIds = new Set(payload.feedIds);
172
+ for (const ticker of tickers) {
173
+ const feedId = host.config.packages.pyth_lazer_rule?.feeds?.[ticker];
174
+ if (feedId === undefined || !packedFeedIds.has(feedId))
175
+ return null;
176
+ }
177
+ return { kind: "pyth_lazer_rule", payload };
178
+ },
179
+ /**
180
+ * Appends the single `parse_and_verify_le_ecdsa_update(state, clock, bytes)`
181
+ * call — one secp256k1 signature check covering every feed in the payload —
182
+ * and returns its `Update` result as the handle the per-ticker feed leg
183
+ * consumes. `opts.cache` / `opts.feeSource` are Pyth-Core-specific and
184
+ * ignored (Lazer verification charges no update fee).
185
+ */
186
+ buildUpdateCalls(tx, host, data, _opts) {
187
+ const payload = (0, price_update_rule_ts_1.assertRuleUpdateData)(data, "pyth_lazer_rule", isPythLazerUpdatePayloadShape, "{ update: Uint8Array; feedIds: number[] }");
188
+ if (!payload)
189
+ return undefined;
190
+ const lazer = requireLazerPackage(host);
191
+ const [update] = tx.moveCall({
192
+ target: `${config_ts_1.LAZER_DEFAULTS[host.network].verifier_package}::pyth_lazer::parse_and_verify_le_ecdsa_update`,
193
+ arguments: [tx.object(lazer.state), tx.object.clock(), tx.pure.vector("u8", payload.update)],
194
+ });
195
+ return { kind: "pyth_lazer_rule", update };
196
+ },
197
+ };
@@ -16,6 +16,11 @@ function feedPythRule(tx, host, collector, priceInfoObjectId) {
16
16
  arguments: {
17
17
  collector,
18
18
  config: tx.object(host.config.packages.pyth_rule.config),
19
+ // The deployed pyth_rule package is compiled against the Core pyth
20
+ // dependency, so its `&PythState` parameter is the Core-package-qualified
21
+ // type and `host.pyth` (the fixed per-network Core infra) is always the
22
+ // right state to pass. The config's price_info_object entries are Core
23
+ // objects to match.
19
24
  pythState: tx.object(host.pyth.state_id),
20
25
  pythPriceInfo: tx.object(priceInfoObjectId),
21
26
  },
@@ -3,17 +3,21 @@
3
3
  * attach the `PythSponsorRule` witness to a `TradingRequest`. Required when the
4
4
  * market's `request_checklist` contains `PythSponsorRule`.
5
5
  *
6
- * Flow: {@link openPythSponsorFund} opens a `Fund` hot potato; pass the returned
7
- * `{ fund, packageId }` to the Pyth update path as its `sponsorFund` (it draws
8
- * per-feed fees via `pyth_sponsor_rule::split`); then {@link reimbursePythSponsor}
9
- * consumes the `Fund`, returns leftover SUI, and attaches the witness.
6
+ * Flow: {@link openPythSponsorFund} opens a `Fund` hot potato; the caller wraps
7
+ * the returned `{ fund, packageId }` into an `OracleFeeSource` (`{ kind:
8
+ * 'sponsor', fund, packageId }`) and passes that to the Pyth update path (it
9
+ * draws per-feed fees via `pyth_sponsor_rule::split`); then
10
+ * {@link reimbursePythSponsor} consumes the `Fund`, returns leftover SUI, and
11
+ * attaches the witness.
10
12
  */
11
13
  import type { Transaction, TransactionArgument } from "@mysten/sui/transactions";
12
14
  import type { OracleHost } from "../host.ts";
13
15
  /**
14
- * Opens a `Fund` hot potato from the shared PythSponsor pool. Pass the returned
15
- * `{ fund, packageId }` straight to `refreshOraclePrices` as `sponsorFund`, then
16
- * {@link reimbursePythSponsor} once the TradingRequest is built.
16
+ * Opens a `Fund` hot potato from the shared PythSponsor pool. Wrap the
17
+ * returned `{ fund, packageId }` into an `OracleFeeSource`
18
+ * (`{ kind: 'sponsor', fund, packageId }`) and pass that to
19
+ * `refreshOraclePrices` as `feeSource`, then {@link reimbursePythSponsor} once
20
+ * the TradingRequest is built.
17
21
  */
18
22
  export declare function openPythSponsorFund(tx: Transaction, host: OracleHost): {
19
23
  fund: TransactionArgument;
@@ -4,19 +4,23 @@
4
4
  * attach the `PythSponsorRule` witness to a `TradingRequest`. Required when the
5
5
  * market's `request_checklist` contains `PythSponsorRule`.
6
6
  *
7
- * Flow: {@link openPythSponsorFund} opens a `Fund` hot potato; pass the returned
8
- * `{ fund, packageId }` to the Pyth update path as its `sponsorFund` (it draws
9
- * per-feed fees via `pyth_sponsor_rule::split`); then {@link reimbursePythSponsor}
10
- * consumes the `Fund`, returns leftover SUI, and attaches the witness.
7
+ * Flow: {@link openPythSponsorFund} opens a `Fund` hot potato; the caller wraps
8
+ * the returned `{ fund, packageId }` into an `OracleFeeSource` (`{ kind:
9
+ * 'sponsor', fund, packageId }`) and passes that to the Pyth update path (it
10
+ * draws per-feed fees via `pyth_sponsor_rule::split`); then
11
+ * {@link reimbursePythSponsor} consumes the `Fund`, returns leftover SUI, and
12
+ * attaches the witness.
11
13
  */
12
14
  Object.defineProperty(exports, "__esModule", { value: true });
13
15
  exports.openPythSponsorFund = openPythSponsorFund;
14
16
  exports.reimbursePythSponsor = reimbursePythSponsor;
15
17
  const pyth_sponsor_rule_ts_1 = require("../../generated/pyth_sponsor_rule/pyth_sponsor_rule.js");
16
18
  /**
17
- * Opens a `Fund` hot potato from the shared PythSponsor pool. Pass the returned
18
- * `{ fund, packageId }` straight to `refreshOraclePrices` as `sponsorFund`, then
19
- * {@link reimbursePythSponsor} once the TradingRequest is built.
19
+ * Opens a `Fund` hot potato from the shared PythSponsor pool. Wrap the
20
+ * returned `{ fund, packageId }` into an `OracleFeeSource`
21
+ * (`{ kind: 'sponsor', fund, packageId }`) and pass that to
22
+ * `refreshOraclePrices` as `feeSource`, then {@link reimbursePythSponsor} once
23
+ * the TradingRequest is built.
20
24
  */
21
25
  function openPythSponsorFund(tx, host) {
22
26
  const entry = host.config.packages.pyth_sponsor_rule;
@@ -0,0 +1,115 @@
1
+ /**
2
+ * `fetchWithPolicy` — resilience wrapper around `fetch` for the oracle money
3
+ * path's off-chain update-data fetches. Every order/position/collateral
4
+ * tx-build depends on one of these REST calls landing (Hermes VAA for
5
+ * `pyth_rule`, Lazer signed updates for `pyth_lazer_rule`); a bare `fetch`
6
+ * with a single attempt and no retry means one Hermes 429/5xx or timeout
7
+ * fails every trade. `fetchPriceFeedsUpdateData` (`./pyth.ts`),
8
+ * `PythLazerRule.fetchUpdateData`'s Lazer POST (`./rules/pyth-lazer-rule.ts`),
9
+ * and `loadConfig` (`../perp/config.ts`) all delegate here instead of calling
10
+ * `fetch` directly — this is the ONE place a retry/timeout/auth policy is
11
+ * implemented for these fetches.
12
+ *
13
+ * Policy semantics:
14
+ * - Bearer auth is attached iff `policy.apiKey` is a non-empty string —
15
+ * absent/empty is byte-identical to today's keyless request (no
16
+ * `Authorization` header at all). This is the Phase-0 invariant of the
17
+ * Pyth Pro migration: existing keyless deployments see no behavior change.
18
+ * - Retries on network errors, HTTP 429, and HTTP 5xx, with exponential
19
+ * backoff (`retryDelayMs * 2^attempt`, capped at `MAX_BACKOFF_MS`). A 429
20
+ * carrying a numeric `Retry-After` header uses the SERVER'S delay instead,
21
+ * when it fits under the same cap — a longer ask degrades to normal
22
+ * backoff rather than stalling a money-path build for tens of seconds.
23
+ * Other
24
+ * 4xx statuses (401/400/403/404/…) are NOT retried — auth/bad-request
25
+ * failures are deterministic, so that `Response` (`ok: false`) is handed
26
+ * back on the first attempt for the caller to format its own
27
+ * domain-specific error, exactly as it did before this wrapper existed.
28
+ * - `init.body`, if set, MUST be replayable across attempts — a retry
29
+ * re-sends the SAME `init` object to `fetch` on every attempt. A string /
30
+ * `URLSearchParams` / BCS-serialized `Uint8Array` body (every caller today)
31
+ * is fine; a one-shot `ReadableStream` body would not survive a second
32
+ * attempt and must not be passed through this function.
33
+ * - Each attempt gets its own `AbortSignal.timeout(policy.timeoutMs)`
34
+ * combined with whichever of `init.signal` / the `externalSignal` param are
35
+ * set — ALL of them can end the whole policy (not just the in-flight
36
+ * attempt), including a queued backoff sleep, via `AbortSignal.any`
37
+ * (runtime floor: Node ≥20.3 / any modern browser — matches this repo's
38
+ * `target: ES2023` + `lib: ["dom", "esnext"]`).
39
+ * - Exhausting retries with no successful/non-retryable response (i.e. every
40
+ * attempt was a network error, or the final attempt was still a retryable
41
+ * HTTP failure) throws a {@link FetchPolicyError} naming the target's
42
+ * `host + pathname` (never the query string — feed ids are off-chain
43
+ * noise, not diagnostic value), the attempt count, and whichever of
44
+ * `status` (a retryable HTTP failure — plus a truncated response-body
45
+ * snippet, when the final attempt's response carried one) or `cause` (a
46
+ * network error) the final attempt produced. An INTERMEDIATE (non-final)
47
+ * retryable response's body is discarded via `response.body?.cancel()`
48
+ * instead of read, so a doomed-to-retry response doesn't pin its
49
+ * connection's socket open for no reason.
50
+ * - Retry worst case: with the defaults (15s timeout × 3 attempts + ~0.75s of
51
+ * backoff between them) a FULL outage takes up to ~46s to surface as a
52
+ * `FetchPolicyError`, vs ~15s pre-3.2.0's single bare-`fetch` attempt.
53
+ * Tunable per client via the `pythFetch` create option (`{timeoutMs,retries}`).
54
+ */
55
+ export interface FetchPolicy {
56
+ /** Per-attempt timeout (ms). Default 15_000. */
57
+ readonly timeoutMs?: number;
58
+ /** Retry attempts AFTER the first try — `retries: 2` ⇒ 3 attempts total. Default 2. */
59
+ readonly retries?: number;
60
+ /** Base backoff (ms); doubled per attempt, capped at 2_000ms. Default 250. */
61
+ readonly retryDelayMs?: number;
62
+ /** Bearer token. Attached iff non-empty; empty/missing ⇒ no `Authorization` header. */
63
+ readonly apiKey?: string;
64
+ /** Override the fetch implementation (tests / non-global-`fetch` environments). Default: global `fetch`. */
65
+ readonly fetchImpl?: typeof fetch;
66
+ }
67
+ /** Thrown by {@link fetchWithPolicy} when every attempt failed. */
68
+ export declare class FetchPolicyError extends Error {
69
+ /** HTTP status of the final attempt, when it got a (retryable-but-failing) response. */
70
+ readonly status?: number;
71
+ /** Truncated (~200 char) body of the final attempt's response, when one was readable. */
72
+ readonly bodySnippet?: string;
73
+ /** Total attempts made (first try + retries actually used). */
74
+ readonly attempts: number;
75
+ constructor(message: string, opts: {
76
+ status?: number;
77
+ bodySnippet?: string;
78
+ cause?: unknown;
79
+ attempts: number;
80
+ });
81
+ }
82
+ /**
83
+ * Rethrow a `catch`-ed {@link fetchWithPolicy} failure. When it is a
84
+ * status-carrying `FetchPolicyError` — a retryable status (429/5xx) that never
85
+ * recovered — throw a new Error `${describe(err)} (retries exhausted after N
86
+ * attempts)` with the original as `cause`; otherwise (a network-level
87
+ * exhaustion with no status, or any non-`FetchPolicyError`) rethrow it verbatim,
88
+ * since there is no domain reframing to add. `describe` builds the
89
+ * status-bearing prefix so each caller keeps its own message shape (the e2e
90
+ * transient detector keys off those prefixes) while the guard, the `retries
91
+ * exhausted` suffix, and the `cause` wrapping live in one place. `: never` so a
92
+ * caller's `catch` block is understood not to fall through.
93
+ */
94
+ export declare function rethrowExhaustedFetch(err: unknown, describe: (err: FetchPolicyError) => string): never;
95
+ /**
96
+ * Join an API `path` onto an `endpoint` PRESERVING the endpoint's own base
97
+ * path. `new URL(path, endpoint)` is the footgun this replaces: a
98
+ * leading-slash path is *absolute* and silently discards the endpoint's path
99
+ * — harmless for a bare-origin endpoint (`https://hermes.pyth.network`) but
100
+ * it dropped the `/hermes` prefix of the Pyth Pro compat endpoint and 404'd
101
+ * every feed (see `fetchPriceFeedsUpdateData`). Every oracle fetch that
102
+ * targets `<endpoint><fixed path>` must build its URL here.
103
+ */
104
+ /** One canonical trailing-slash trim — `joinEndpointPath` (URL building) and
105
+ * `pyth.ts`'s `memoKey` (endpoint identity) must never drift apart on it. */
106
+ export declare function trimTrailingSlashes(endpoint: string): string;
107
+ export declare function joinEndpointPath(endpoint: string, path: string): URL;
108
+ /**
109
+ * `fetch` with per-attempt timeout, bounded retry + backoff, and optional
110
+ * Bearer auth. See the module header for the full policy. Both `init.signal`
111
+ * (if the caller set one) AND the separate `externalSignal` param cancel the
112
+ * WHOLE policy — in-flight attempts AND queued backoff sleeps — not just a
113
+ * single attempt.
114
+ */
115
+ export declare function fetchWithPolicy(url: string, init?: RequestInit, policy?: FetchPolicy, externalSignal?: AbortSignal): Promise<Response>;