@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
@@ -14,6 +14,7 @@
14
14
  * 4. hot_potato_vector::destroy
15
15
  */
16
16
  import type { Transaction, TransactionArgument } from "@mysten/sui/transactions";
17
+ import type { PythFetchPolicy } from "./config.ts";
17
18
  import type { OracleHost } from "./host.ts";
18
19
  type PriceTableInfo = {
19
20
  id: string;
@@ -29,24 +30,154 @@ export declare class PythCache {
29
30
  priceTableInfo?: PriceTableInfo;
30
31
  priceFeedObjectIdCache: Map<string, string | undefined>;
31
32
  }
32
- export declare function fetchPriceFeedsUpdateData(endpoint: string, priceIds: string[]): Promise<Uint8Array[]>;
33
+ type FetchOpts = {
34
+ apiKey?: string;
35
+ fetch?: PythFetchPolicy;
36
+ };
37
+ /**
38
+ * How long a "this endpoint lacks feed X" verdict stays memoized. The verdict
39
+ * is a claim about *someone else's* deployment — a feed can be added to the
40
+ * catalog, an entitlement can be granted, a Pro plan can be upgraded — so it
41
+ * must expire rather than bind the whole process lifetime. Long enough that a
42
+ * genuinely-absent feed costs one discovery per window instead of one per
43
+ * build; short enough that a recovered endpoint self-heals without a restart.
44
+ */
45
+ export declare const MISSING_FEED_MEMO_TTL_MS: number;
46
+ /**
47
+ * Thrown when discovery concludes that an endpoint rejects EVERY requested
48
+ * feed id without a catalog vouching for that verdict. `instanceof`-able
49
+ * (mirrors `FetchPolicyError` / {@link OracleFeeSourceUnavailableError}).
50
+ *
51
+ * "All of them are missing" is the signature of an endpoint/credential fault —
52
+ * a wrong base path (the Pyth Pro `/hermes` prefix dropped), a changed route,
53
+ * a revoked or downgraded entitlement — not of N individually-absent feeds.
54
+ * Memoizing it would convert a loud, fixable misconfiguration into a silent
55
+ * permanent one: every id marked missing ⇒ `fetchPriceFeedsUpdateData` returns
56
+ * `[]` ⇒ `buildPythPriceUpdateCalls` throws "Hermes returned empty results",
57
+ * blaming Hermes for having no data, for the rest of the process's life. So
58
+ * this case writes NOTHING to the memo and throws instead; the next call
59
+ * re-probes and recovers on its own once the endpoint does.
60
+ *
61
+ * The message keeps the `Hermes price fetch failed: <status>` prefix on its
62
+ * first line — the documented contract downstream consumers string-match (see
63
+ * {@link fetchPriceFeedsUpdateData} and the e2e transient detector).
64
+ */
65
+ export declare class HermesEndpointRejectedAllFeedsError extends Error {
66
+ readonly endpoint: string;
67
+ readonly requestedCount: number;
68
+ constructor(endpoint: string, requestedCount: number, catalogState: "unreadable" | "empty");
69
+ }
70
+ /**
71
+ * The subset of `feedIds` this `endpoint` is known to serve — i.e. minus any
72
+ * discovered to be absent within the last {@link MISSING_FEED_MEMO_TTL_MS}
73
+ * (see {@link fetchPriceFeedsUpdateData}). Callers building a
74
+ * `{ updates, feedIds }` payload use this to keep `feedIds` aligned with the
75
+ * feeds the fetch actually returned data for, so `buildPythPriceUpdateCalls`
76
+ * (one moveCall per feed id) never references a feed the accumulator blob
77
+ * doesn't cover.
78
+ *
79
+ * Expired entries are pruned here rather than on a timer: the memo is only
80
+ * ever consulted through this function, so a lazy sweep is both sufficient and
81
+ * free of a dangling interval in a library.
82
+ */
83
+ export declare function endpointSupportedFeedIds(endpoint: string, feedIds: string[], apiKey?: string): string[];
84
+ /** Test-only: forget everything learned about which feeds an endpoint lacks. */
85
+ export declare function __resetMissingFeedCacheForTest(): void;
86
+ /**
87
+ * Discovery-only entry for consumers that fetch Hermes THEMSELVES (e.g. a
88
+ * parsed latest-price reader) and just observed a whole-batch 404: resolves
89
+ * which ids the endpoint lacks, memoizes them (see {@link
90
+ * endpointSupportedFeedIds}), fetches NO survivor data. Without this, such a
91
+ * consumer's only way to populate the memo was calling {@link
92
+ * fetchPriceFeedsUpdateData} and discarding its accumulator blob — two full
93
+ * redundant transfers per cold discovery.
94
+ *
95
+ * @throws HermesEndpointRejectedAllFeedsError when the rejection looks
96
+ * endpoint-wide rather than per-feed — the caller's own 404 is then a
97
+ * misconfiguration to surface, not a set of feeds to quietly drop.
98
+ */
99
+ export declare function probeMissingFeeds(endpoint: string, ids: string[], opts?: FetchOpts): Promise<void>;
100
+ export declare function fetchPriceFeedsUpdateData(endpoint: string, priceIds: string[], opts?: FetchOpts): Promise<Uint8Array[]>;
101
+ /**
102
+ * Resolved source for the Pyth Core on-chain update fee. Deliberately a
103
+ * closed two-variant union, not a `{ sponsorFund?, allowGasFee? }` pair — a
104
+ * caller can no longer construct the "both supplied" or "neither supplied
105
+ * but some other truthy flag" shapes that used to require a priority rule to
106
+ * disambiguate.
107
+ *
108
+ * Resolved exactly ONCE, at the edges (`wrapRequestAndExecute` and the WLP
109
+ * builders' equivalent in `perp/tx-builders/`) from config presence
110
+ * (`pyth_sponsor_rule` deployed → open a fund → `'sponsor'`) and the
111
+ * caller's ergonomic `allowGasFee` opt-in (→ `'gas'`), then threaded
112
+ * verbatim through `refreshOraclePrices` → `BuildUpdateOpts` →
113
+ * `PythCoreRule` → {@link buildPythPriceUpdateCalls}. The sponsor-beats-gas
114
+ * priority from the old two-flag design is now structural — whichever edge
115
+ * resolves this value decides once; no downstream layer re-derives or
116
+ * re-documents a priority because none of them ever see more than one
117
+ * candidate source.
118
+ */
119
+ export type OracleFeeSource = {
120
+ readonly kind: "sponsor";
121
+ readonly fund: TransactionArgument;
122
+ readonly packageId: string;
123
+ } | {
124
+ readonly kind: "gas";
125
+ };
126
+ /**
127
+ * Thrown when no {@link OracleFeeSource} is available for the Pyth update fee
128
+ * — from `buildPythPriceUpdateCalls`'s own per-call guard, or `aggregate.ts`'s
129
+ * hoisted `refreshOraclePrices` pre-check (see its docblock). `instanceof`-able
130
+ * (mirrors `FetchPolicyError` in `update-fetch.ts`) so a consumer — e.g. a BE
131
+ * integration wiring its own `allowGasFee` decision — can branch on the error
132
+ * type directly instead of string-matching `error.message`.
133
+ */
134
+ export declare class OracleFeeSourceUnavailableError extends Error {
135
+ constructor();
136
+ }
33
137
  /**
34
138
  * Append the on-chain Pyth update PTB block. Returns `PriceInfoObject` IDs
35
139
  * (one per `feedIds`, same order). After this you can feed `pyth_rule` per
36
140
  * ticker against the matching `PriceInfoObject` (see `rules/pyth-rule.ts`).
37
141
  *
38
- * If `sponsorFund` is provided, the per-feed update fee comes from the
39
- * sponsor pool (`pyth_sponsor_rule::split`) instead of `tx.gas`. Opening and
40
- * reimbursing that fund is the sponsor rule's job (`rules/sponsor.ts`); here we
41
- * only draw a fee coin from the already-open `fund` hot potato.
142
+ * `opts.feeSource` is resolved BEFORE any PTB mutation and is never silently
143
+ * defaulted this function trusts whatever single {@link OracleFeeSource}
144
+ * it's handed, it does not choose between competing candidates:
145
+ * - `{ kind: 'sponsor' }` the per-feed update fee is drawn from the
146
+ * sponsor pool (`pyth_sponsor_rule::split`) instead of `tx.gas`. Opening
147
+ * and reimbursing that fund is the caller's job (`rules/sponsor.ts` /
148
+ * `wrapRequestAndExecute`, which opens it whenever the client's config
149
+ * has `pyth_sponsor_rule` deployed) — this function only draws a fee
150
+ * coin from the already-open `fund` hot potato.
151
+ * - `{ kind: 'gas' }` → the fee is drawn from `tx.gas` via `tx.splitCoins`.
152
+ * Only safe in a non-sponsored context — Enoki-sponsored transactions
153
+ * reject any `tx.gas` draw.
154
+ * - `undefined` → throws `OracleFeeSourceUnavailable` instead of silently
155
+ * drawing from `tx.gas` (the old default), which broke under Enoki and,
156
+ * worse, could fail ON-CHAIN when the market's `request_checklist`
157
+ * requires the `PythSponsorRule` witness that only a real sponsor fund
158
+ * attaches.
159
+ *
160
+ * This function's own check runs AFTER `updates`/`feedIds` are already in
161
+ * hand, so for `updatePythPrices` (which fetches from Hermes, then calls
162
+ * straight into this function) the off-chain fetch has already completed by
163
+ * the time this throws — a wasted network call, never a stray PTB command.
164
+ * `refreshOraclePrices` avoids that waste entirely: it hoists an EQUIVALENT
165
+ * check ABOVE its off-chain fetch AND its per-group build loop (see its
166
+ * docblock in `aggregate.ts`), keyed on `PriceUpdateRule.requiresFeeSource`
167
+ * rather than waiting for a specific rule's fetch to complete — so for that
168
+ * route neither the network call NOR any PTB command happens before the
169
+ * throw. This function's own (later, per-call) guard alone could not
170
+ * provide that "before any group builds" guarantee in a mixed shape (e.g. a
171
+ * fee-free Lazer group ordered ahead of a Pyth Core fallback group in the
172
+ * same PTB) — `refreshOraclePrices`'s pre-check is what closes it.
42
173
  */
43
- export declare function buildPythPriceUpdateCalls(tx: Transaction, host: OracleHost, updates: Uint8Array[], feedIds: string[], cache?: PythCache, sponsorFund?: {
44
- fund: TransactionArgument;
45
- packageId: string;
174
+ export declare function buildPythPriceUpdateCalls(tx: Transaction, host: OracleHost, updates: Uint8Array[], feedIds: string[], opts?: {
175
+ cache?: PythCache;
176
+ feeSource?: OracleFeeSource;
46
177
  }): Promise<string[]>;
47
178
  /** All-in-one: fetch from Hermes, append update calls. Returns PriceInfoObject IDs. */
48
- export declare function updatePythPrices(tx: Transaction, host: OracleHost, feedIds: string[], cache?: PythCache, sponsorFund?: {
49
- fund: TransactionArgument;
50
- packageId: string;
179
+ export declare function updatePythPrices(tx: Transaction, host: OracleHost, feedIds: string[], opts?: {
180
+ cache?: PythCache;
181
+ feeSource?: OracleFeeSource;
51
182
  }): Promise<string[]>;
52
183
  export {};
@@ -15,12 +15,16 @@
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.HermesEndpointRejectedAllFeedsError = exports.MISSING_FEED_MEMO_TTL_MS = exports.PythCache = void 0;
19
+ exports.endpointSupportedFeedIds = endpointSupportedFeedIds;
20
+ exports.__resetMissingFeedCacheForTest = __resetMissingFeedCacheForTest;
21
+ exports.probeMissingFeeds = probeMissingFeeds;
19
22
  exports.fetchPriceFeedsUpdateData = fetchPriceFeedsUpdateData;
20
23
  exports.buildPythPriceUpdateCalls = buildPythPriceUpdateCalls;
21
24
  exports.updatePythPrices = updatePythPrices;
22
25
  const bcs_1 = require("@mysten/bcs");
23
26
  const bcs_2 = require("@mysten/sui/bcs");
27
+ const update_fetch_ts_1 = require("./update-fetch.js");
24
28
  class PythCache {
25
29
  pythStateInfo;
26
30
  wormholePackageId;
@@ -28,17 +32,337 @@ class PythCache {
28
32
  priceFeedObjectIdCache = new Map();
29
33
  }
30
34
  exports.PythCache = PythCache;
31
- // ============================================================================
32
- // Hermes REST
33
- // ============================================================================
34
- async function fetchPriceFeedsUpdateData(endpoint, priceIds) {
35
- if (priceIds.length === 0)
35
+ /**
36
+ * How long a "this endpoint lacks feed X" verdict stays memoized. The verdict
37
+ * is a claim about *someone else's* deployment — a feed can be added to the
38
+ * catalog, an entitlement can be granted, a Pro plan can be upgraded — so it
39
+ * must expire rather than bind the whole process lifetime. Long enough that a
40
+ * genuinely-absent feed costs one discovery per window instead of one per
41
+ * build; short enough that a recovered endpoint self-heals without a restart.
42
+ */
43
+ exports.MISSING_FEED_MEMO_TTL_MS = 15 * 60_000;
44
+ /**
45
+ * Per-endpoint memo of feed ids this endpoint has rejected as unknown (a 404
46
+ * on `/v2/updates/price/latest`). The Pyth Pro compat endpoint carries a
47
+ * SUBSET of Core's feeds — mainnet `WTIUSD`/`BRENTUSD` (Commodities
48
+ * USOILSPOT/UKOILSPOT) are absent, for example. Pyth 404s the WHOLE batch if
49
+ * ANY id is unknown, and the body naming the bad ids is NOT reliably delivered
50
+ * to `fetch` (Cloudflare returns it to curl but `content-length: 0` to node's
51
+ * undici), so the ids are isolated by catalog read (or bisection) and
52
+ * remembered here — later batches skip them instead of 404ing every time.
53
+ *
54
+ * Entries carry an expiry ({@link MISSING_FEED_MEMO_TTL_MS}); an
55
+ * endpoint-level fault never lands here at all (see {@link
56
+ * HermesEndpointRejectedAllFeedsError}).
57
+ */
58
+ const missingFeedIdsByEndpoint = new Map();
59
+ /**
60
+ * Thrown when discovery concludes that an endpoint rejects EVERY requested
61
+ * feed id without a catalog vouching for that verdict. `instanceof`-able
62
+ * (mirrors `FetchPolicyError` / {@link OracleFeeSourceUnavailableError}).
63
+ *
64
+ * "All of them are missing" is the signature of an endpoint/credential fault —
65
+ * a wrong base path (the Pyth Pro `/hermes` prefix dropped), a changed route,
66
+ * a revoked or downgraded entitlement — not of N individually-absent feeds.
67
+ * Memoizing it would convert a loud, fixable misconfiguration into a silent
68
+ * permanent one: every id marked missing ⇒ `fetchPriceFeedsUpdateData` returns
69
+ * `[]` ⇒ `buildPythPriceUpdateCalls` throws "Hermes returned empty results",
70
+ * blaming Hermes for having no data, for the rest of the process's life. So
71
+ * this case writes NOTHING to the memo and throws instead; the next call
72
+ * re-probes and recovers on its own once the endpoint does.
73
+ *
74
+ * The message keeps the `Hermes price fetch failed: <status>` prefix on its
75
+ * first line — the documented contract downstream consumers string-match (see
76
+ * {@link fetchPriceFeedsUpdateData} and the e2e transient detector).
77
+ */
78
+ class HermesEndpointRejectedAllFeedsError extends Error {
79
+ endpoint;
80
+ requestedCount;
81
+ constructor(endpoint, requestedCount, catalogState) {
82
+ super(`Hermes price fetch failed: 404 — endpoint rejected ALL ${requestedCount} requested feed id(s) ` +
83
+ `and its feed catalog was ${catalogState}, so nothing vouches for those feeds being ` +
84
+ `individually absent. Treating this as an endpoint/credential fault (wrong base path — ` +
85
+ `e.g. a dropped Pyth Pro '/hermes' prefix — changed route, or revoked entitlement) rather ` +
86
+ `than caching every feed as missing. Endpoint: ${endpoint}`);
87
+ this.endpoint = endpoint;
88
+ this.requestedCount = requestedCount;
89
+ this.name = "HermesEndpointRejectedAllFeedsError";
90
+ }
91
+ }
92
+ exports.HermesEndpointRejectedAllFeedsError = HermesEndpointRejectedAllFeedsError;
93
+ /**
94
+ * Memo key for `(endpoint, credential)` — the same trailing-slash trim
95
+ * {@link joinEndpointPath} applies to the request URL (shared helper, so the
96
+ * two can't drift), plus the apiKey: "feed X is missing" is a property of the
97
+ * endpoint AND the credential (Pyth Pro entitlements are per-key), so two
98
+ * clients in one process with different keys must not cross-poison each
99
+ * other's memo. Without the trim, two consumers spelling the same endpoint
100
+ * differently (`…/hermes` vs `…/hermes/`) would fragment the memo and re-run
101
+ * the whole 404 discovery despite one of them having already paid for it.
102
+ */
103
+ function memoKey(endpoint, apiKey) {
104
+ return `${(0, update_fetch_ts_1.trimTrailingSlashes)(endpoint)}\u0000${apiKey ?? ""}`;
105
+ }
106
+ function recordMissingFeeds(endpoint, feedIds, apiKey) {
107
+ const key = memoKey(endpoint, apiKey);
108
+ let entries = missingFeedIdsByEndpoint.get(key);
109
+ if (!entries) {
110
+ entries = new Map();
111
+ missingFeedIdsByEndpoint.set(key, entries);
112
+ }
113
+ const expiresAt = Date.now() + exports.MISSING_FEED_MEMO_TTL_MS;
114
+ // Key on the bare (0x-stripped, lowercased) form — the SAME normalization the
115
+ // catalog comparison and the in-flight latch use — so a feed can't fragment
116
+ // the memo across `0xAB`/`ab` spellings and slip back through as unfiltered.
117
+ for (const feedId of feedIds)
118
+ entries.set(bareFeedId(feedId), expiresAt);
119
+ }
120
+ /**
121
+ * The subset of `feedIds` this `endpoint` is known to serve — i.e. minus any
122
+ * discovered to be absent within the last {@link MISSING_FEED_MEMO_TTL_MS}
123
+ * (see {@link fetchPriceFeedsUpdateData}). Callers building a
124
+ * `{ updates, feedIds }` payload use this to keep `feedIds` aligned with the
125
+ * feeds the fetch actually returned data for, so `buildPythPriceUpdateCalls`
126
+ * (one moveCall per feed id) never references a feed the accumulator blob
127
+ * doesn't cover.
128
+ *
129
+ * Expired entries are pruned here rather than on a timer: the memo is only
130
+ * ever consulted through this function, so a lazy sweep is both sufficient and
131
+ * free of a dangling interval in a library.
132
+ */
133
+ function endpointSupportedFeedIds(endpoint, feedIds, apiKey) {
134
+ const key = memoKey(endpoint, apiKey);
135
+ const entries = missingFeedIdsByEndpoint.get(key);
136
+ if (!entries)
137
+ return feedIds;
138
+ const now = Date.now();
139
+ for (const [id, expiresAt] of entries) {
140
+ if (expiresAt <= now)
141
+ entries.delete(id);
142
+ }
143
+ if (entries.size === 0) {
144
+ missingFeedIdsByEndpoint.delete(key);
145
+ return feedIds;
146
+ }
147
+ return feedIds.filter((id) => !entries.has(bareFeedId(id)));
148
+ }
149
+ /**
150
+ * Discovery runs currently in flight, keyed by `(endpoint, credential,
151
+ * requested id set)`. A cold memo plus two concurrent tx-builds asking the
152
+ * same question ran two full independent discoveries — duplicate catalog reads
153
+ * (or duplicate bisection probe trees) on the money path, for one answer. The
154
+ * second caller now joins the first run's promise: each still gets its own
155
+ * survivor data, but they share the one discovery behind it.
156
+ */
157
+ const inFlightDiscoveries = new Map();
158
+ /** Test-only: forget everything learned about which feeds an endpoint lacks. */
159
+ function __resetMissingFeedCacheForTest() {
160
+ missingFeedIdsByEndpoint.clear();
161
+ inFlightDiscoveries.clear();
162
+ }
163
+ /**
164
+ * One `GET /v2/price_feeds` — the set of feed ids this endpoint serves for
165
+ * THIS credential (verified against the Pro compat endpoint: WTI absent from
166
+ * the catalog AND 404 on latest-price; BTC present AND 200 —
167
+ * entitlement-filtered per key), normalized to bare lowercase hex.
168
+ *
169
+ * Returns `null` when the catalog is unreadable (non-2xx, unparseable, network
170
+ * error) — the catalog is an optimization, {@link bisectMissingFeeds} remains
171
+ * the ground truth derived from the money-path fetch itself. Note that "read
172
+ * fine, served nothing" (`size === 0`) is NOT the same as unreadable: an empty
173
+ * catalog is an entitlement/route fault in its own right, and the caller
174
+ * treats it as one.
175
+ */
176
+ async function readEndpointCatalog(endpoint, opts) {
177
+ try {
178
+ const res = await (0, update_fetch_ts_1.fetchWithPolicy)((0, update_fetch_ts_1.joinEndpointPath)(endpoint, "v2/price_feeds").toString(), {}, { apiKey: opts?.apiKey, ...opts?.fetch });
179
+ if (!res.ok) {
180
+ void res.body?.cancel().catch(() => { });
181
+ return null;
182
+ }
183
+ const catalog = (await res.json());
184
+ if (!Array.isArray(catalog))
185
+ return null;
186
+ // Catalog ids come WITHOUT the 0x prefix; callers pass either form.
187
+ return new Set(catalog.map((f) => bareFeedId(f.id)));
188
+ }
189
+ catch {
190
+ return null;
191
+ }
192
+ }
193
+ /** Feed ids are compared prefix- and case-insensitively (`0xAB` ≡ `ab`). */
194
+ function bareFeedId(feedId) {
195
+ return feedId.toLowerCase().replace(/^0x/, "");
196
+ }
197
+ async function rawFetch(endpoint, ids, opts) {
198
+ // joinEndpointPath preserves the endpoint's own base path — `new URL`
199
+ // with a leading-slash path would discard it (the Pyth Pro `/hermes`
200
+ // prefix → 404 on EVERY feed); see its doc in update-fetch.ts.
201
+ const url = (0, update_fetch_ts_1.joinEndpointPath)(endpoint, "v2/updates/price/latest");
202
+ ids.forEach((id) => url.searchParams.append("ids[]", id));
203
+ return (0, update_fetch_ts_1.fetchWithPolicy)(url.toString(), {}, { apiKey: opts?.apiKey, ...opts?.fetch });
204
+ }
205
+ /**
206
+ * Bisect `ids` down to the individual ones this endpoint 404s on — the
207
+ * response body isn't readable, so a single id that still 404s IS the unknown
208
+ * one. Data is discarded; this only *reports* (the caller decides whether the
209
+ * verdict is trustworthy enough to memoize). A non-404 (the subset is fine) or
210
+ * a network error stops that branch and contributes nothing.
211
+ *
212
+ * `known404: true` skips the root probe — the caller has already watched this
213
+ * exact batch 404, so re-fetching it would only re-learn a fact in hand (a
214
+ * wasted round trip on the money path during cold discovery). Recursive
215
+ * half-calls always probe: their status is genuinely unknown.
216
+ */
217
+ async function bisectMissingFeeds(endpoint, ids, opts, known404 = false) {
218
+ if (ids.length === 0)
219
+ return new Set();
220
+ if (!known404) {
221
+ let res;
222
+ try {
223
+ res = await rawFetch(endpoint, ids, opts);
224
+ }
225
+ catch {
226
+ return new Set(); // transient/network failure — can't classify
227
+ }
228
+ void res.body?.cancel().catch(() => { });
229
+ if (res.status !== 404)
230
+ return new Set(); // this subset is serveable
231
+ }
232
+ if (ids.length === 1)
233
+ return new Set([ids[0]]);
234
+ const mid = Math.floor(ids.length / 2);
235
+ const [lo, hi] = await Promise.all([
236
+ bisectMissingFeeds(endpoint, ids.slice(0, mid), opts),
237
+ bisectMissingFeeds(endpoint, ids.slice(mid), opts),
238
+ ]);
239
+ return new Set([...lo, ...hi]);
240
+ }
241
+ /**
242
+ * Work out which of `ids` this endpoint lacks and memoize exactly those.
243
+ *
244
+ * Catalog first: one `GET /v2/price_feeds` answers for the whole batch, so a
245
+ * confirmed 404 costs one extra request instead of O(log n) bisection probes.
246
+ * The catalog is also the only *authoritative* source here — it says what the
247
+ * endpoint DOES serve, which is what separates "these two feeds are absent"
248
+ * from "this endpoint is serving nothing to me". Bisection can only observe
249
+ * 404s, and a wrong base path 404s identically to an unknown feed.
250
+ *
251
+ * Hence the guard: a verdict of "every requested id is missing" is only
252
+ * committed when a non-empty catalog vouches for it. Otherwise nothing is
253
+ * written and {@link HermesEndpointRejectedAllFeedsError} is thrown — see its
254
+ * docblock for why silently memoizing that case is worse than failing.
255
+ *
256
+ * Concurrent callers asking the identical question share ONE run (see {@link
257
+ * inFlightDiscoveries}); the latch is released on failure too, so a blip never
258
+ * pins later callers to a stale outcome.
259
+ *
260
+ * @throws HermesEndpointRejectedAllFeedsError on an endpoint-level rejection.
261
+ */
262
+ function discoverMissingFeeds(endpoint, ids, opts, known404 = false) {
263
+ // Same endpoint + credential + requested set ⇒ same answer; anything else
264
+ // is a different question and runs on its own.
265
+ const key = `${memoKey(endpoint, opts?.apiKey)}${ids.map(bareFeedId).sort().join(",")}`;
266
+ const inFlight = inFlightDiscoveries.get(key);
267
+ if (inFlight)
268
+ return inFlight;
269
+ const run = runDiscovery(endpoint, ids, opts, known404).finally(() => {
270
+ inFlightDiscoveries.delete(key);
271
+ });
272
+ inFlightDiscoveries.set(key, run);
273
+ return run;
274
+ }
275
+ async function runDiscovery(endpoint, ids, opts, known404 = false) {
276
+ if (ids.length === 0)
277
+ return;
278
+ if (!known404) {
279
+ let res;
280
+ try {
281
+ res = await rawFetch(endpoint, ids, opts);
282
+ }
283
+ catch {
284
+ return; // transient/network failure — can't classify; leave the memo untouched
285
+ }
286
+ void res.body?.cancel().catch(() => { });
287
+ if (res.status !== 404)
288
+ return; // this batch is serveable — nothing to record
289
+ }
290
+ const catalog = await readEndpointCatalog(endpoint, opts);
291
+ if (catalog !== null && catalog.size === 0) {
292
+ // Read fine, serves nothing: an entitlement/route fault, not N absent feeds.
293
+ throw new HermesEndpointRejectedAllFeedsError(endpoint, ids.length, "empty");
294
+ }
295
+ const missing = catalog !== null
296
+ ? new Set(ids.filter((id) => !catalog.has(bareFeedId(id))))
297
+ : await bisectMissingFeeds(endpoint, ids, opts, true);
298
+ if (missing.size === 0)
299
+ return;
300
+ if (catalog === null && missing.size === ids.length) {
301
+ throw new HermesEndpointRejectedAllFeedsError(endpoint, ids.length, "unreadable");
302
+ }
303
+ recordMissingFeeds(endpoint, missing, opts?.apiKey);
304
+ }
305
+ /**
306
+ * Discovery-only entry for consumers that fetch Hermes THEMSELVES (e.g. a
307
+ * parsed latest-price reader) and just observed a whole-batch 404: resolves
308
+ * which ids the endpoint lacks, memoizes them (see {@link
309
+ * endpointSupportedFeedIds}), fetches NO survivor data. Without this, such a
310
+ * consumer's only way to populate the memo was calling {@link
311
+ * fetchPriceFeedsUpdateData} and discarding its accumulator blob — two full
312
+ * redundant transfers per cold discovery.
313
+ *
314
+ * @throws HermesEndpointRejectedAllFeedsError when the rejection looks
315
+ * endpoint-wide rather than per-feed — the caller's own 404 is then a
316
+ * misconfiguration to surface, not a set of feeds to quietly drop.
317
+ */
318
+ function probeMissingFeeds(endpoint, ids, opts) {
319
+ return discoverMissingFeeds(endpoint, ids, opts, true);
320
+ }
321
+ async function fetchPriceFeedsUpdateData(endpoint, priceIds, opts) {
322
+ // Skip feeds this endpoint has already told us it doesn't have.
323
+ const ids = endpointSupportedFeedIds(endpoint, priceIds, opts?.apiKey);
324
+ if (ids.length === 0)
36
325
  return [];
37
- const url = new URL("/v2/updates/price/latest", endpoint);
38
- priceIds.forEach((id) => url.searchParams.append("ids[]", id));
39
- const res = await fetch(url.toString(), { signal: AbortSignal.timeout(15_000) });
40
- if (!res.ok)
41
- throw new Error(`Hermes price fetch failed: ${res.status} ${await res.text()}`);
326
+ let res;
327
+ try {
328
+ res = await rawFetch(endpoint, ids, opts);
329
+ }
330
+ catch (err) {
331
+ (0, update_fetch_ts_1.rethrowExhaustedFetch)(err, (e) => `Hermes price fetch failed: ${e.status}${e.bodySnippet ? ` ${e.bodySnippet}` : ""}`);
332
+ }
333
+ if (!res.ok) {
334
+ // Drain the body ONCE, here. A `Response` body can only be read once, and
335
+ // the 404 branch below finishes with it before the throw is reached — so
336
+ // reading it inside the throw surfaced `TypeError: Body is unusable`
337
+ // instead of this function's documented message whenever a 404 fell
338
+ // through. Reading up front also releases the connection on every path.
339
+ const body = await res.text().catch(() => "");
340
+ // Pyth 404s the ENTIRE batch if ANY id is unknown to the endpoint (a Core
341
+ // feed absent from the Pyth Pro compat endpoint). This is on the money
342
+ // path of every order/position/WLP tx-build, so instead of failing the
343
+ // whole refresh: discover the unknown ids (catalog, else bisection — the
344
+ // body isn't reliably delivered), memoize them, and re-fetch the survivors
345
+ // as ONE clean batch (a single combined accumulator blob, which
346
+ // buildPythPriceUpdateCalls requires). A genuinely-absent ticker just
347
+ // isn't in the payload — its on-chain aggregate abstains/aborts, which is
348
+ // correct. Steady state: once discovered, survivors are filtered up front
349
+ // and this never runs. A rejection that looks endpoint-wide instead of
350
+ // per-feed throws out of here (HermesEndpointRejectedAllFeedsError).
351
+ if (res.status === 404) {
352
+ // known404: this exact batch just 404'd above — skip the root re-probe.
353
+ await discoverMissingFeeds(endpoint, ids, opts, true);
354
+ const survivors = endpointSupportedFeedIds(endpoint, ids, opts?.apiKey);
355
+ if (survivors.length === 0)
356
+ return [];
357
+ // survivors < ids ⇒ we removed the offender(s); re-fetch cleanly. Equal
358
+ // ⇒ nothing was recorded (a 404 that wasn't a missing-feed rejection) —
359
+ // surface it rather than loop.
360
+ if (survivors.length < ids.length) {
361
+ return fetchPriceFeedsUpdateData(endpoint, survivors, opts);
362
+ }
363
+ }
364
+ throw new Error(`Hermes price fetch failed: ${res.status}${body ? ` ${body}` : ""}`);
365
+ }
42
366
  const json = (await res.json());
43
367
  const data = json.binary?.data;
44
368
  if (!Array.isArray(data) || data.length === 0) {
@@ -144,26 +468,73 @@ function extractVaaBytes(accumulatorMessage) {
144
468
  const vaaOffset = vaaSizeOffset + 2;
145
469
  return accumulatorMessage.subarray(vaaOffset, vaaOffset + vaaSize);
146
470
  }
147
- // ============================================================================
148
- // Pyth update calls
149
- // ============================================================================
471
+ /**
472
+ * Thrown when no {@link OracleFeeSource} is available for the Pyth update fee
473
+ * — from `buildPythPriceUpdateCalls`'s own per-call guard, or `aggregate.ts`'s
474
+ * hoisted `refreshOraclePrices` pre-check (see its docblock). `instanceof`-able
475
+ * (mirrors `FetchPolicyError` in `update-fetch.ts`) so a consumer — e.g. a BE
476
+ * integration wiring its own `allowGasFee` decision — can branch on the error
477
+ * type directly instead of string-matching `error.message`.
478
+ */
479
+ class OracleFeeSourceUnavailableError extends Error {
480
+ constructor() {
481
+ super("OracleFeeSourceUnavailable: no fee source available for the Pyth update fee — " +
482
+ "deploy pyth_sponsor_rule to config so a sponsor fund can be opened (see " +
483
+ "openPythSponsorFund / wrapRequestAndExecute), or pass allowGasFee: true to draw " +
484
+ "the fee from tx.gas in a non-sponsored context");
485
+ this.name = "OracleFeeSourceUnavailableError";
486
+ }
487
+ }
488
+ exports.OracleFeeSourceUnavailableError = OracleFeeSourceUnavailableError;
150
489
  /**
151
490
  * Append the on-chain Pyth update PTB block. Returns `PriceInfoObject` IDs
152
491
  * (one per `feedIds`, same order). After this you can feed `pyth_rule` per
153
492
  * ticker against the matching `PriceInfoObject` (see `rules/pyth-rule.ts`).
154
493
  *
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.
494
+ * `opts.feeSource` is resolved BEFORE any PTB mutation and is never silently
495
+ * defaulted this function trusts whatever single {@link OracleFeeSource}
496
+ * it's handed, it does not choose between competing candidates:
497
+ * - `{ kind: 'sponsor' }` the per-feed update fee is drawn from the
498
+ * sponsor pool (`pyth_sponsor_rule::split`) instead of `tx.gas`. Opening
499
+ * and reimbursing that fund is the caller's job (`rules/sponsor.ts` /
500
+ * `wrapRequestAndExecute`, which opens it whenever the client's config
501
+ * has `pyth_sponsor_rule` deployed) — this function only draws a fee
502
+ * coin from the already-open `fund` hot potato.
503
+ * - `{ kind: 'gas' }` → the fee is drawn from `tx.gas` via `tx.splitCoins`.
504
+ * Only safe in a non-sponsored context — Enoki-sponsored transactions
505
+ * reject any `tx.gas` draw.
506
+ * - `undefined` → throws `OracleFeeSourceUnavailable` instead of silently
507
+ * drawing from `tx.gas` (the old default), which broke under Enoki and,
508
+ * worse, could fail ON-CHAIN when the market's `request_checklist`
509
+ * requires the `PythSponsorRule` witness that only a real sponsor fund
510
+ * attaches.
511
+ *
512
+ * This function's own check runs AFTER `updates`/`feedIds` are already in
513
+ * hand, so for `updatePythPrices` (which fetches from Hermes, then calls
514
+ * straight into this function) the off-chain fetch has already completed by
515
+ * the time this throws — a wasted network call, never a stray PTB command.
516
+ * `refreshOraclePrices` avoids that waste entirely: it hoists an EQUIVALENT
517
+ * check ABOVE its off-chain fetch AND its per-group build loop (see its
518
+ * docblock in `aggregate.ts`), keyed on `PriceUpdateRule.requiresFeeSource`
519
+ * rather than waiting for a specific rule's fetch to complete — so for that
520
+ * route neither the network call NOR any PTB command happens before the
521
+ * throw. This function's own (later, per-call) guard alone could not
522
+ * provide that "before any group builds" guarantee in a mixed shape (e.g. a
523
+ * fee-free Lazer group ordered ahead of a Pyth Core fallback group in the
524
+ * same PTB) — `refreshOraclePrices`'s pre-check is what closes it.
159
525
  */
160
- async function buildPythPriceUpdateCalls(tx, host, updates, feedIds, cache, sponsorFund) {
526
+ async function buildPythPriceUpdateCalls(tx, host, updates, feedIds, opts) {
161
527
  if (updates.length === 0) {
162
528
  throw new Error("No price update data provided; Hermes returned empty results");
163
529
  }
164
530
  if (updates.length > 1) {
165
531
  throw new Error("Only a single accumulator message is supported per transaction");
166
532
  }
533
+ const feeSource = opts?.feeSource;
534
+ if (!feeSource) {
535
+ throw new OracleFeeSourceUnavailableError();
536
+ }
537
+ const cache = opts?.cache;
167
538
  const pyth = host.pyth;
168
539
  const [stateInfo, wormholePackageId, table] = await Promise.all([
169
540
  getPythStateInfo(host.grpcClient, pyth.state_id, cache),
@@ -195,10 +566,10 @@ async function buildPythPriceUpdateCalls(tx, host, updates, feedIds, cache, spon
195
566
  if (!priceInfoObjectId) {
196
567
  throw new Error(`Pyth feed ${feedIds[i]} not registered on-chain in Pyth state`);
197
568
  }
198
- const feeCoin = sponsorFund
569
+ const feeCoin = feeSource.kind === "sponsor"
199
570
  ? tx.moveCall({
200
- target: `${sponsorFund.packageId}::pyth_sponsor_rule::split`,
201
- arguments: [sponsorFund.fund],
571
+ target: `${feeSource.packageId}::pyth_sponsor_rule::split`,
572
+ arguments: [feeSource.fund],
202
573
  })[0]
203
574
  : tx.splitCoins(tx.gas, [tx.pure.u64(baseUpdateFee)])[0];
204
575
  [hotPotato] = tx.moveCall({
@@ -221,7 +592,19 @@ async function buildPythPriceUpdateCalls(tx, host, updates, feedIds, cache, spon
221
592
  return priceInfoObjectIds;
222
593
  }
223
594
  /** 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);
595
+ async function updatePythPrices(tx, host, feedIds, opts) {
596
+ // `host.pyth` is the Pyth Core infra (fixed per network) plus the caller's
597
+ // api_key/fetch endpoint, credential and policy all come from it.
598
+ const endpoint = host.pyth.hermes_endpoint;
599
+ const updates = await fetchPriceFeedsUpdateData(endpoint, feedIds, {
600
+ apiKey: host.pyth.api_key,
601
+ fetch: host.pyth.fetch,
602
+ });
603
+ // Align feedIds with the feeds the endpoint actually served — the fetch drops
604
+ // (and memoizes) any it lacks, and `buildPythPriceUpdateCalls` emits one
605
+ // update call per feedId, so a dropped feed would reference a PriceInfoObject
606
+ // the accumulator blob doesn't cover (invalid PTB / on-chain abort). Mirrors
607
+ // `PythCoreRule.fetchUpdateData`.
608
+ const servedFeedIds = endpointSupportedFeedIds(endpoint, feedIds, host.pyth.api_key);
609
+ return buildPythPriceUpdateCalls(tx, host, updates, servedFeedIds, opts);
227
610
  }