@waterx/sdk 3.1.1 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +36 -0
  2. package/dist/cjs/src/oracle/aggregate.d.ts +98 -17
  3. package/dist/cjs/src/oracle/aggregate.js +191 -21
  4. package/dist/cjs/src/oracle/config.d.ts +103 -0
  5. package/dist/cjs/src/oracle/config.js +64 -1
  6. package/dist/cjs/src/oracle/host.d.ts +13 -0
  7. package/dist/cjs/src/oracle/index.d.ts +19 -5
  8. package/dist/cjs/src/oracle/index.js +40 -6
  9. package/dist/cjs/src/oracle/price-update-rule.d.ts +180 -0
  10. package/dist/cjs/src/oracle/price-update-rule.js +56 -0
  11. package/dist/cjs/src/oracle/pyth.d.ts +80 -11
  12. package/dist/cjs/src/oracle/pyth.js +84 -17
  13. package/dist/cjs/src/oracle/rule-registry.d.ts +37 -0
  14. package/dist/cjs/src/oracle/rule-registry.js +61 -0
  15. package/dist/cjs/src/oracle/rules/pyth-core-rule.d.ts +15 -0
  16. package/dist/cjs/src/oracle/rules/pyth-core-rule.js +84 -0
  17. package/dist/cjs/src/oracle/rules/pyth-lazer-rule.d.ts +41 -0
  18. package/dist/cjs/src/oracle/rules/pyth-lazer-rule.js +194 -0
  19. package/dist/cjs/src/oracle/rules/sponsor.d.ts +11 -7
  20. package/dist/cjs/src/oracle/rules/sponsor.js +11 -7
  21. package/dist/cjs/src/oracle/update-fetch.d.ts +85 -0
  22. package/dist/cjs/src/oracle/update-fetch.js +228 -0
  23. package/dist/cjs/src/perp/client.d.ts +22 -1
  24. package/dist/cjs/src/perp/client.js +11 -2
  25. package/dist/cjs/src/perp/config.d.ts +9 -4
  26. package/dist/cjs/src/perp/config.js +82 -18
  27. package/dist/cjs/src/perp/index.d.ts +4 -3
  28. package/dist/cjs/src/perp/index.js +8 -3
  29. package/dist/cjs/src/perp/tx-builders/common.d.ts +52 -15
  30. package/dist/cjs/src/perp/tx-builders/common.js +39 -6
  31. package/dist/cjs/src/perp/tx-builders/wlp.d.ts +11 -3
  32. package/dist/cjs/src/perp/tx-builders/wlp.js +29 -3
  33. package/dist/cjs/src/perp/tx-builders.d.ts +3 -3
  34. package/dist/cjs/src/perp/tx-builders.js +3 -3
  35. package/dist/cjs/src/unified-client.d.ts +17 -0
  36. package/dist/cjs/src/unified-client.js +2 -0
  37. package/dist/src/oracle/aggregate.d.ts +98 -17
  38. package/dist/src/oracle/aggregate.js +192 -22
  39. package/dist/src/oracle/config.d.ts +103 -0
  40. package/dist/src/oracle/config.js +63 -0
  41. package/dist/src/oracle/host.d.ts +13 -0
  42. package/dist/src/oracle/index.d.ts +19 -5
  43. package/dist/src/oracle/index.js +34 -6
  44. package/dist/src/oracle/price-update-rule.d.ts +180 -0
  45. package/dist/src/oracle/price-update-rule.js +53 -0
  46. package/dist/src/oracle/pyth.d.ts +80 -11
  47. package/dist/src/oracle/pyth.js +82 -16
  48. package/dist/src/oracle/rule-registry.d.ts +37 -0
  49. package/dist/src/oracle/rule-registry.js +56 -0
  50. package/dist/src/oracle/rules/pyth-core-rule.d.ts +15 -0
  51. package/dist/src/oracle/rules/pyth-core-rule.js +81 -0
  52. package/dist/src/oracle/rules/pyth-lazer-rule.d.ts +41 -0
  53. package/dist/src/oracle/rules/pyth-lazer-rule.js +189 -0
  54. package/dist/src/oracle/rules/sponsor.d.ts +11 -7
  55. package/dist/src/oracle/rules/sponsor.js +11 -7
  56. package/dist/src/oracle/update-fetch.d.ts +85 -0
  57. package/dist/src/oracle/update-fetch.js +223 -0
  58. package/dist/src/perp/client.d.ts +22 -1
  59. package/dist/src/perp/client.js +12 -3
  60. package/dist/src/perp/config.d.ts +9 -4
  61. package/dist/src/perp/config.js +81 -18
  62. package/dist/src/perp/index.d.ts +4 -3
  63. package/dist/src/perp/index.js +2 -2
  64. package/dist/src/perp/tx-builders/common.d.ts +52 -15
  65. package/dist/src/perp/tx-builders/common.js +39 -6
  66. package/dist/src/perp/tx-builders/wlp.d.ts +11 -3
  67. package/dist/src/perp/tx-builders/wlp.js +29 -3
  68. package/dist/src/perp/tx-builders.d.ts +3 -3
  69. package/dist/src/perp/tx-builders.js +3 -3
  70. package/dist/src/unified-client.d.ts +17 -0
  71. package/dist/src/unified-client.js +2 -0
  72. package/package.json +1 -1
@@ -15,12 +15,13 @@
15
15
  * 4. hot_potato_vector::destroy
16
16
  */
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
- exports.PythCache = void 0;
18
+ exports.OracleFeeSourceUnavailableError = exports.PythCache = void 0;
19
19
  exports.fetchPriceFeedsUpdateData = fetchPriceFeedsUpdateData;
20
20
  exports.buildPythPriceUpdateCalls = buildPythPriceUpdateCalls;
21
21
  exports.updatePythPrices = updatePythPrices;
22
22
  const bcs_1 = require("@mysten/bcs");
23
23
  const bcs_2 = require("@mysten/sui/bcs");
24
+ const update_fetch_ts_1 = require("./update-fetch.js");
24
25
  class PythCache {
25
26
  pythStateInfo;
26
27
  wormholePackageId;
@@ -31,12 +32,28 @@ exports.PythCache = PythCache;
31
32
  // ============================================================================
32
33
  // Hermes REST
33
34
  // ============================================================================
34
- async function fetchPriceFeedsUpdateData(endpoint, priceIds) {
35
+ async function fetchPriceFeedsUpdateData(endpoint, priceIds, opts) {
35
36
  if (priceIds.length === 0)
36
37
  return [];
37
38
  const url = new URL("/v2/updates/price/latest", endpoint);
38
39
  priceIds.forEach((id) => url.searchParams.append("ids[]", id));
39
- const res = await fetch(url.toString(), { signal: AbortSignal.timeout(15_000) });
40
+ let res;
41
+ try {
42
+ res = await (0, update_fetch_ts_1.fetchWithPolicy)(url.toString(), {}, { apiKey: opts?.apiKey, ...opts?.fetch });
43
+ }
44
+ catch (err) {
45
+ // A retryable status (429/5xx) that never recovered surfaces as a
46
+ // FetchPolicyError with `status` set — reformat it into this function's
47
+ // own message shape so callers (and the e2e transient-failure detector,
48
+ // which keys off "Hermes price fetch failed") see the same text whether
49
+ // the failure was retried or not. A network-level exhaustion (no status)
50
+ // has no domain-specific reframing to add — propagate it as-is.
51
+ if (err instanceof update_fetch_ts_1.FetchPolicyError && err.status !== undefined) {
52
+ const body = err.bodySnippet ? ` ${err.bodySnippet}` : "";
53
+ throw new Error(`Hermes price fetch failed: ${err.status}${body} (retries exhausted after ${err.attempts} attempts)`, { cause: err });
54
+ }
55
+ throw err;
56
+ }
40
57
  if (!res.ok)
41
58
  throw new Error(`Hermes price fetch failed: ${res.status} ${await res.text()}`);
42
59
  const json = (await res.json());
@@ -144,26 +161,73 @@ function extractVaaBytes(accumulatorMessage) {
144
161
  const vaaOffset = vaaSizeOffset + 2;
145
162
  return accumulatorMessage.subarray(vaaOffset, vaaOffset + vaaSize);
146
163
  }
147
- // ============================================================================
148
- // Pyth update calls
149
- // ============================================================================
164
+ /**
165
+ * Thrown when no {@link OracleFeeSource} is available for the Pyth update fee
166
+ * — from `buildPythPriceUpdateCalls`'s own per-call guard, or `aggregate.ts`'s
167
+ * hoisted `refreshOraclePrices` pre-check (see its docblock). `instanceof`-able
168
+ * (mirrors `FetchPolicyError` in `update-fetch.ts`) so a consumer — e.g. a BE
169
+ * integration wiring its own `allowGasFee` decision — can branch on the error
170
+ * type directly instead of string-matching `error.message`.
171
+ */
172
+ class OracleFeeSourceUnavailableError extends Error {
173
+ constructor() {
174
+ super("OracleFeeSourceUnavailable: no fee source available for the Pyth update fee — " +
175
+ "deploy pyth_sponsor_rule to config so a sponsor fund can be opened (see " +
176
+ "openPythSponsorFund / wrapRequestAndExecute), or pass allowGasFee: true to draw " +
177
+ "the fee from tx.gas in a non-sponsored context");
178
+ this.name = "OracleFeeSourceUnavailableError";
179
+ }
180
+ }
181
+ exports.OracleFeeSourceUnavailableError = OracleFeeSourceUnavailableError;
150
182
  /**
151
183
  * Append the on-chain Pyth update PTB block. Returns `PriceInfoObject` IDs
152
184
  * (one per `feedIds`, same order). After this you can feed `pyth_rule` per
153
185
  * ticker against the matching `PriceInfoObject` (see `rules/pyth-rule.ts`).
154
186
  *
155
- * If `sponsorFund` is provided, the per-feed update fee comes from the
156
- * sponsor pool (`pyth_sponsor_rule::split`) instead of `tx.gas`. Opening and
157
- * reimbursing that fund is the sponsor rule's job (`rules/sponsor.ts`); here we
158
- * only draw a fee coin from the already-open `fund` hot potato.
187
+ * `opts.feeSource` is resolved BEFORE any PTB mutation and is never silently
188
+ * defaulted this function trusts whatever single {@link OracleFeeSource}
189
+ * it's handed, it does not choose between competing candidates:
190
+ * - `{ kind: 'sponsor' }` the per-feed update fee is drawn from the
191
+ * sponsor pool (`pyth_sponsor_rule::split`) instead of `tx.gas`. Opening
192
+ * and reimbursing that fund is the caller's job (`rules/sponsor.ts` /
193
+ * `wrapRequestAndExecute`, which opens it whenever the client's config
194
+ * has `pyth_sponsor_rule` deployed) — this function only draws a fee
195
+ * coin from the already-open `fund` hot potato.
196
+ * - `{ kind: 'gas' }` → the fee is drawn from `tx.gas` via `tx.splitCoins`.
197
+ * Only safe in a non-sponsored context — Enoki-sponsored transactions
198
+ * reject any `tx.gas` draw.
199
+ * - `undefined` → throws `OracleFeeSourceUnavailable` instead of silently
200
+ * drawing from `tx.gas` (the old default), which broke under Enoki and,
201
+ * worse, could fail ON-CHAIN when the market's `request_checklist`
202
+ * requires the `PythSponsorRule` witness that only a real sponsor fund
203
+ * attaches.
204
+ *
205
+ * This function's own check runs AFTER `updates`/`feedIds` are already in
206
+ * hand, so for `updatePythPrices` (which fetches from Hermes, then calls
207
+ * straight into this function) the off-chain fetch has already completed by
208
+ * the time this throws — a wasted network call, never a stray PTB command.
209
+ * `refreshOraclePrices` avoids that waste entirely: it hoists an EQUIVALENT
210
+ * check ABOVE its off-chain fetch AND its per-group build loop (see its
211
+ * docblock in `aggregate.ts`), keyed on `PriceUpdateRule.requiresFeeSource`
212
+ * rather than waiting for a specific rule's fetch to complete — so for that
213
+ * route neither the network call NOR any PTB command happens before the
214
+ * throw. This function's own (later, per-call) guard alone could not
215
+ * provide that "before any group builds" guarantee in a mixed shape (e.g. a
216
+ * fee-free Lazer group ordered ahead of a Pyth Core fallback group in the
217
+ * same PTB) — `refreshOraclePrices`'s pre-check is what closes it.
159
218
  */
160
- async function buildPythPriceUpdateCalls(tx, host, updates, feedIds, cache, sponsorFund) {
219
+ async function buildPythPriceUpdateCalls(tx, host, updates, feedIds, opts) {
161
220
  if (updates.length === 0) {
162
221
  throw new Error("No price update data provided; Hermes returned empty results");
163
222
  }
164
223
  if (updates.length > 1) {
165
224
  throw new Error("Only a single accumulator message is supported per transaction");
166
225
  }
226
+ const feeSource = opts?.feeSource;
227
+ if (!feeSource) {
228
+ throw new OracleFeeSourceUnavailableError();
229
+ }
230
+ const cache = opts?.cache;
167
231
  const pyth = host.pyth;
168
232
  const [stateInfo, wormholePackageId, table] = await Promise.all([
169
233
  getPythStateInfo(host.grpcClient, pyth.state_id, cache),
@@ -195,10 +259,10 @@ async function buildPythPriceUpdateCalls(tx, host, updates, feedIds, cache, spon
195
259
  if (!priceInfoObjectId) {
196
260
  throw new Error(`Pyth feed ${feedIds[i]} not registered on-chain in Pyth state`);
197
261
  }
198
- const feeCoin = sponsorFund
262
+ const feeCoin = feeSource.kind === "sponsor"
199
263
  ? tx.moveCall({
200
- target: `${sponsorFund.packageId}::pyth_sponsor_rule::split`,
201
- arguments: [sponsorFund.fund],
264
+ target: `${feeSource.packageId}::pyth_sponsor_rule::split`,
265
+ arguments: [feeSource.fund],
202
266
  })[0]
203
267
  : tx.splitCoins(tx.gas, [tx.pure.u64(baseUpdateFee)])[0];
204
268
  [hotPotato] = tx.moveCall({
@@ -221,7 +285,10 @@ async function buildPythPriceUpdateCalls(tx, host, updates, feedIds, cache, spon
221
285
  return priceInfoObjectIds;
222
286
  }
223
287
  /** All-in-one: fetch from Hermes, append update calls. Returns PriceInfoObject IDs. */
224
- async function updatePythPrices(tx, host, feedIds, cache, sponsorFund) {
225
- const updates = await fetchPriceFeedsUpdateData(host.pyth.hermes_endpoint, feedIds);
226
- return buildPythPriceUpdateCalls(tx, host, updates, feedIds, cache, sponsorFund);
288
+ async function updatePythPrices(tx, host, feedIds, opts) {
289
+ const updates = await fetchPriceFeedsUpdateData(host.pyth.hermes_endpoint, feedIds, {
290
+ apiKey: host.pyth.api_key,
291
+ fetch: host.pyth.fetch,
292
+ });
293
+ return buildPythPriceUpdateCalls(tx, host, updates, feedIds, opts);
227
294
  }
@@ -0,0 +1,37 @@
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`, a client create option) — never by a
7
+ * config JSON `enabled` flag and never by `process.env`.
8
+ *
9
+ * Both sources are registered: `pyth_rule` (`PythCoreRule`, Hermes VAA) and
10
+ * `pyth_lazer_rule` (`PythLazerRule`, Lazer signed updates). Resolving a
11
+ * source with no registered rule throws a clear `OracleSourceNotImplemented`
12
+ * error instead of silently falling back to Pyth Core.
13
+ */
14
+ import type { OracleSource, PriceUpdateRule } from "./price-update-rule.ts";
15
+ /**
16
+ * Thrown by {@link resolveOracleRule} when `source` has no `PriceUpdateRule`
17
+ * registered in either the production registry or a test's `overrides` map.
18
+ * `instanceof`-able (mirrors `OracleFeeSourceUnavailableError` in `pyth.ts`)
19
+ * so a consumer can branch on the failure type directly instead of
20
+ * string-matching `error.message`.
21
+ */
22
+ export declare class OracleSourceNotImplementedError extends Error {
23
+ /** The unregistered `OracleSource` that was requested. */
24
+ readonly source: OracleSource;
25
+ constructor(source: OracleSource);
26
+ }
27
+ /**
28
+ * Resolve the `PriceUpdateRule` registered for `source`.
29
+ *
30
+ * `overrides` — test-only — layers on top of the production registry so a
31
+ * spec can inject a fake rule (e.g. a stub `pyth_lazer_rule`) without
32
+ * touching {@link DEFAULT_RULES}; production callers never pass it.
33
+ *
34
+ * Throws {@link OracleSourceNotImplementedError} (`OracleSourceNotImplemented:
35
+ * <source>`) when nothing is registered for `source` in either map.
36
+ */
37
+ export declare function resolveOracleRule(source: OracleSource, overrides?: Partial<Record<OracleSource, PriceUpdateRule>>): PriceUpdateRule;
@@ -0,0 +1,61 @@
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`, a client create option) — never by a
8
+ * config JSON `enabled` flag and never by `process.env`.
9
+ *
10
+ * Both sources are registered: `pyth_rule` (`PythCoreRule`, Hermes VAA) and
11
+ * `pyth_lazer_rule` (`PythLazerRule`, Lazer signed updates). Resolving a
12
+ * source with no registered rule throws a clear `OracleSourceNotImplemented`
13
+ * error instead of silently falling back to Pyth Core.
14
+ */
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.OracleSourceNotImplementedError = void 0;
17
+ exports.resolveOracleRule = resolveOracleRule;
18
+ const pyth_core_rule_ts_1 = require("./rules/pyth-core-rule.js");
19
+ const pyth_lazer_rule_ts_1 = require("./rules/pyth-lazer-rule.js");
20
+ /**
21
+ * Production registry. Frozen — tests inject a fake rule via
22
+ * `resolveOracleRule`'s `overrides` param instead of mutating this.
23
+ */
24
+ const DEFAULT_RULES = Object.freeze({
25
+ pyth_rule: pyth_core_rule_ts_1.PythCoreRule,
26
+ pyth_lazer_rule: pyth_lazer_rule_ts_1.PythLazerRule,
27
+ });
28
+ /**
29
+ * Thrown by {@link resolveOracleRule} when `source` has no `PriceUpdateRule`
30
+ * registered in either the production registry or a test's `overrides` map.
31
+ * `instanceof`-able (mirrors `OracleFeeSourceUnavailableError` in `pyth.ts`)
32
+ * so a consumer can branch on the failure type directly instead of
33
+ * string-matching `error.message`.
34
+ */
35
+ class OracleSourceNotImplementedError extends Error {
36
+ /** The unregistered `OracleSource` that was requested. */
37
+ source;
38
+ constructor(source) {
39
+ super(`OracleSourceNotImplemented: ${source}`);
40
+ this.name = "OracleSourceNotImplementedError";
41
+ this.source = source;
42
+ }
43
+ }
44
+ exports.OracleSourceNotImplementedError = OracleSourceNotImplementedError;
45
+ /**
46
+ * Resolve the `PriceUpdateRule` registered for `source`.
47
+ *
48
+ * `overrides` — test-only — layers on top of the production registry so a
49
+ * spec can inject a fake rule (e.g. a stub `pyth_lazer_rule`) without
50
+ * touching {@link DEFAULT_RULES}; production callers never pass it.
51
+ *
52
+ * Throws {@link OracleSourceNotImplementedError} (`OracleSourceNotImplemented:
53
+ * <source>`) when nothing is registered for `source` in either map.
54
+ */
55
+ function resolveOracleRule(source, overrides) {
56
+ const rule = overrides?.[source] ?? DEFAULT_RULES[source];
57
+ if (!rule) {
58
+ throw new OracleSourceNotImplementedError(source);
59
+ }
60
+ return rule;
61
+ }
@@ -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,84 @@
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
+ const feedIds = tickers.map((ticker) => host.getPythFeed(ticker).feed_id);
40
+ const updates = await (0, pyth_ts_1.fetchPriceFeedsUpdateData)(host.pyth.hermes_endpoint, feedIds, {
41
+ apiKey: host.pyth.api_key,
42
+ fetch: host.pyth.fetch,
43
+ });
44
+ return { kind: "pyth_rule", payload: { updates, feedIds } };
45
+ },
46
+ /**
47
+ * Subsets a (typically whole-universe) payload from {@link fetchUpdateData}
48
+ * down to exactly `tickers`. Pyth Core charges a per-feed update fee (one
49
+ * `update_single_price_feed` moveCall per `feedIds` entry — see
50
+ * `buildPythPriceUpdateCalls`), so serving a full all-registry payload for a
51
+ * 2-ticker build would multiply both the fee and the PTB size ~N× — a
52
+ * per-feed subset is valid input by construction. Narrows `feedIds` only:
53
+ * the single combined Hermes accumulator blob in `updates` already covers
54
+ * every packed feed and needs no re-slicing. A ticker with no
55
+ * `pyth_rule.feeds` entry, or whose feed id is not packed in THIS payload's
56
+ * `feedIds`, is a coverage gap → `null` (miss), never a silent partial.
57
+ */
58
+ narrowUpdateData(host, data, tickers) {
59
+ const payload = (0, price_update_rule_ts_1.assertRuleUpdateData)(data, "pyth_rule", isPythCoreUpdatePayloadShape, "{ updates: Uint8Array[]; feedIds: string[] }");
60
+ if (!payload || tickers.length === 0)
61
+ return null;
62
+ const packedFeedIds = new Set(payload.feedIds);
63
+ const feedIds = [];
64
+ for (const ticker of tickers) {
65
+ // Same lookup as `host.getPythFeed(ticker)` minus its throw — an
66
+ // unlisted ticker is a miss here, not an error.
67
+ const feedId = host.config.packages.pyth_rule?.feeds?.[ticker]?.feed_id;
68
+ if (feedId === undefined || !packedFeedIds.has(feedId))
69
+ return null;
70
+ feedIds.push(feedId);
71
+ }
72
+ return { kind: "pyth_rule", payload: { updates: payload.updates, feedIds } };
73
+ },
74
+ /** Appends the wormhole/pyth update PTB block for the payload from {@link fetchUpdateData}. */
75
+ async buildUpdateCalls(tx, host, data, opts) {
76
+ const payload = (0, price_update_rule_ts_1.assertRuleUpdateData)(data, "pyth_rule", isPythCoreUpdatePayloadShape, "{ updates: Uint8Array[]; feedIds: string[] }");
77
+ if (!payload)
78
+ return;
79
+ await (0, pyth_ts_1.buildPythPriceUpdateCalls)(tx, host, payload.updates, payload.feedIds, {
80
+ cache: opts?.cache,
81
+ feeSource: opts?.feeSource,
82
+ });
83
+ },
84
+ };
@@ -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 `config.pyth.api_key`), 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 `pyth.api_key` is set — the Lazer HTTP API
23
+ * requires a Bearer token and the SDK never reads `process.env` to find one.
24
+ * `instanceof`-able (mirrors `OracleFeeSourceUnavailableError` in `pyth.ts`)
25
+ * so a consumer can branch on the failure type directly instead of
26
+ * 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,194 @@
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 `config.pyth.api_key`), 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` — `real_time`: the deployed rule binds the v1 Lazer API, whose
26
+ * `channel::from_u8` aborts on the 1000ms fixed-rate channel; real_time /
27
+ * 50ms / 200ms are the safe subscriptions, and for an on-demand pull
28
+ * real_time is the freshest.
29
+ * - `formats: leEcdsa` + `jsonBinaryEncoding: hex` — the Sui verifier takes
30
+ * the `leEcdsa` framing; hex matches `fromHex` below.
31
+ */
32
+ const LAZER_LATEST_PRICE_REQUEST = {
33
+ properties: ["price", "exponent", "confidence"],
34
+ formats: ["leEcdsa"],
35
+ jsonBinaryEncoding: "hex",
36
+ channel: "real_time",
37
+ };
38
+ /**
39
+ * Shape check ONLY — the `kind` discriminant is checked separately by the
40
+ * caller before this runs (mirrors `PythCoreRule`'s guard split), so a
41
+ * same-shaped payload from a different rule can never silently pass.
42
+ */
43
+ function isPythLazerUpdatePayloadShape(payload) {
44
+ return (typeof payload === "object" &&
45
+ payload !== null &&
46
+ payload.update instanceof Uint8Array &&
47
+ Array.isArray(payload.feedIds));
48
+ }
49
+ /**
50
+ * Thrown by {@link PythLazerRule.fetchUpdateData} when `pyth_lazer_rule` is
51
+ * deployed in config but no `pyth.api_key` is set — the Lazer HTTP API
52
+ * requires a Bearer token and the SDK never reads `process.env` to find one.
53
+ * `instanceof`-able (mirrors `OracleFeeSourceUnavailableError` in `pyth.ts`)
54
+ * so a consumer can branch on the failure type directly instead of
55
+ * string-matching `error.message`.
56
+ */
57
+ class LazerApiKeyMissingError extends Error {
58
+ constructor() {
59
+ super("LazerApiKeyMissing: pyth_lazer_rule requires a Pyth Lazer access token — " +
60
+ "set `pyth.api_key` in the client config (the SDK never reads process.env)");
61
+ this.name = "LazerApiKeyMissingError";
62
+ }
63
+ }
64
+ exports.LazerApiKeyMissingError = LazerApiKeyMissingError;
65
+ /** The `pyth_lazer_rule` deployment entry; throws when the config carries none. */
66
+ function requireLazerPackage(host) {
67
+ const entry = host.config.packages.pyth_lazer_rule;
68
+ if (!entry) {
69
+ throw new Error("pyth_lazer_rule package is not deployed in this config");
70
+ }
71
+ return entry;
72
+ }
73
+ /**
74
+ * Fetch one signed `leEcdsa` update for `feedIds` from the Lazer HTTP API.
75
+ * Goes through the shared `fetchWithPolicy` (`../update-fetch.ts`) — same
76
+ * retry/timeout/Bearer policy as `fetchPriceFeedsUpdateData`, unified so
77
+ * both oracle sources fail the same way under upstream degradation.
78
+ */
79
+ async function fetchLazerSignedUpdate(endpoint, apiKey, feedIds, fetchOpts) {
80
+ const url = new URL("/v1/latest_price", endpoint);
81
+ let res;
82
+ try {
83
+ res = await (0, update_fetch_ts_1.fetchWithPolicy)(url.toString(), {
84
+ method: "POST",
85
+ headers: { "Content-Type": "application/json" },
86
+ body: JSON.stringify({ priceFeedIds: feedIds, ...LAZER_LATEST_PRICE_REQUEST }),
87
+ }, { apiKey, ...fetchOpts });
88
+ }
89
+ catch (err) {
90
+ // Mirrors fetchPriceFeedsUpdateData's reframing: a retryable status that
91
+ // never recovered carries `status` on the FetchPolicyError — reformat
92
+ // into this function's own message shape; a network-level exhaustion
93
+ // (no status) propagates as-is.
94
+ if (err instanceof update_fetch_ts_1.FetchPolicyError && err.status !== undefined) {
95
+ const body = err.bodySnippet ? ` ${err.bodySnippet}` : "";
96
+ throw new Error(`Lazer price fetch failed: ${err.status}${body} (retries exhausted after ${err.attempts} attempts)`, { cause: err });
97
+ }
98
+ throw err;
99
+ }
100
+ if (!res.ok)
101
+ throw new Error(`Lazer price fetch failed: ${res.status} ${await res.text()}`);
102
+ const json = (await res.json());
103
+ const hex = json.leEcdsa?.data;
104
+ if (typeof hex !== "string" || hex.length === 0) {
105
+ throw new Error("Lazer returned no leEcdsa update data");
106
+ }
107
+ return (0, bcs_1.fromHex)(hex);
108
+ }
109
+ /**
110
+ * `pyth_lazer_rule::feed(collector, config, clock, &update)` — contribute the
111
+ * verified Lazer price for `collector.symbol()` to the collector. `update` is
112
+ * the `RuleUpdateHandle` value from {@link PythLazerRule.buildUpdateCalls} in
113
+ * the SAME PTB; one verified update serves every ticker's feed call. On-chain
114
+ * the rule abstains (records `none`) instead of aborting when the symbol is
115
+ * unconfigured, the feed is absent from the update, the value is degenerate,
116
+ * or the Lazer timestamp is stale.
117
+ */
118
+ function feedLazerRule(tx, host, collector, update) {
119
+ const lazer = requireLazerPackage(host);
120
+ tx.moveCall({
121
+ target: `${lazer.published_at}::pyth_lazer_rule::feed`,
122
+ arguments: [collector, tx.object(lazer.config), tx.object.clock(), update],
123
+ });
124
+ }
125
+ exports.PythLazerRule = {
126
+ kind: "pyth_lazer_rule",
127
+ // Verification is a flat signature check with no Coin argument — no
128
+ // update fee — see `PriceUpdateRule.requiresFeeSource`.
129
+ requiresFeeSource: false,
130
+ /** Tickers with a `pyth_lazer_rule.feeds` entry (integer Lazer feed ids). */
131
+ supportedTickers(host) {
132
+ return Object.keys(host.config.packages.pyth_lazer_rule?.feeds ?? {});
133
+ },
134
+ /** Resolves integer feed ids for `tickers`, then fetches one signed `leEcdsa` update. */
135
+ async fetchUpdateData(host, tickers) {
136
+ if (tickers.length === 0)
137
+ return null;
138
+ // Package-level check first: a config without the deployment must say so,
139
+ // not fail per ticker as if only that feed were missing.
140
+ const { feeds } = requireLazerPackage(host);
141
+ const feedIds = tickers.map((ticker) => {
142
+ const feedId = feeds[ticker];
143
+ if (feedId === undefined) {
144
+ throw new Error(`No pyth_lazer_rule feed listed for ticker: ${ticker}`);
145
+ }
146
+ return feedId;
147
+ });
148
+ const apiKey = host.pyth.api_key;
149
+ if (!apiKey) {
150
+ throw new LazerApiKeyMissingError();
151
+ }
152
+ const update = await fetchLazerSignedUpdate(config_ts_1.LAZER_DEFAULTS[host.network].endpoint, apiKey, feedIds, host.pyth.fetch);
153
+ return { kind: "pyth_lazer_rule", payload: { update, feedIds } };
154
+ },
155
+ /**
156
+ * A Lazer payload is ONE signed `leEcdsa` message covering every feed it was
157
+ * fetched for — verification is a single flat signature check over the whole
158
+ * message (`parse_and_verify_le_ecdsa_update`, no per-feed cost), so the
159
+ * payload is indivisible: it can only be served whole. Returns the whole
160
+ * payload iff every requested ticker's integer feed id is packed in THIS
161
+ * payload's `feedIds`; any coverage gap (unlisted ticker, or a feed this
162
+ * payload does not carry) → `null` (miss), never a silent partial.
163
+ */
164
+ narrowUpdateData(host, data, tickers) {
165
+ const payload = (0, price_update_rule_ts_1.assertRuleUpdateData)(data, "pyth_lazer_rule", isPythLazerUpdatePayloadShape, "{ update: Uint8Array; feedIds: number[] }");
166
+ if (!payload || tickers.length === 0)
167
+ return null;
168
+ const packedFeedIds = new Set(payload.feedIds);
169
+ for (const ticker of tickers) {
170
+ const feedId = host.config.packages.pyth_lazer_rule?.feeds?.[ticker];
171
+ if (feedId === undefined || !packedFeedIds.has(feedId))
172
+ return null;
173
+ }
174
+ return { kind: "pyth_lazer_rule", payload };
175
+ },
176
+ /**
177
+ * Appends the single `parse_and_verify_le_ecdsa_update(state, clock, bytes)`
178
+ * call — one secp256k1 signature check covering every feed in the payload —
179
+ * and returns its `Update` result as the handle the per-ticker feed leg
180
+ * consumes. `opts.cache` / `opts.feeSource` are Pyth-Core-specific and
181
+ * ignored (Lazer verification charges no update fee).
182
+ */
183
+ buildUpdateCalls(tx, host, data, _opts) {
184
+ const payload = (0, price_update_rule_ts_1.assertRuleUpdateData)(data, "pyth_lazer_rule", isPythLazerUpdatePayloadShape, "{ update: Uint8Array; feedIds: number[] }");
185
+ if (!payload)
186
+ return undefined;
187
+ const lazer = requireLazerPackage(host);
188
+ const [update] = tx.moveCall({
189
+ target: `${config_ts_1.LAZER_DEFAULTS[host.network].verifier_package}::pyth_lazer::parse_and_verify_le_ecdsa_update`,
190
+ arguments: [tx.object(lazer.state), tx.object.clock(), tx.pure.vector("u8", payload.update)],
191
+ });
192
+ return { kind: "pyth_lazer_rule", update };
193
+ },
194
+ };
@@ -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;