@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.
- package/README.md +54 -18
- package/dist/cjs/src/account/config.d.ts +0 -16
- package/dist/cjs/src/oracle/aggregate.d.ts +96 -17
- package/dist/cjs/src/oracle/aggregate.js +179 -21
- package/dist/cjs/src/oracle/config.d.ts +85 -2
- package/dist/cjs/src/oracle/config.js +30 -1
- package/dist/cjs/src/oracle/host.d.ts +14 -1
- package/dist/cjs/src/oracle/index.d.ts +19 -5
- package/dist/cjs/src/oracle/index.js +54 -6
- package/dist/cjs/src/oracle/price-update-rule.d.ts +180 -0
- package/dist/cjs/src/oracle/price-update-rule.js +56 -0
- package/dist/cjs/src/oracle/pyth.d.ts +142 -11
- package/dist/cjs/src/oracle/pyth.js +408 -25
- package/dist/cjs/src/oracle/rule-registry.d.ts +41 -0
- package/dist/cjs/src/oracle/rule-registry.js +65 -0
- package/dist/cjs/src/oracle/rules/pyth-core-rule.d.ts +15 -0
- package/dist/cjs/src/oracle/rules/pyth-core-rule.js +99 -0
- package/dist/cjs/src/oracle/rules/pyth-lazer-rule.d.ts +41 -0
- package/dist/cjs/src/oracle/rules/pyth-lazer-rule.js +197 -0
- package/dist/cjs/src/oracle/rules/pyth-rule.js +5 -0
- package/dist/cjs/src/oracle/rules/sponsor.d.ts +11 -7
- package/dist/cjs/src/oracle/rules/sponsor.js +11 -7
- package/dist/cjs/src/oracle/update-fetch.d.ts +115 -0
- package/dist/cjs/src/oracle/update-fetch.js +285 -0
- package/dist/cjs/src/perp/client.d.ts +40 -5
- package/dist/cjs/src/perp/client.js +18 -3
- package/dist/cjs/src/perp/config.d.ts +10 -7
- package/dist/cjs/src/perp/config.js +77 -17
- package/dist/cjs/src/perp/index.d.ts +3 -2
- package/dist/cjs/src/perp/index.js +6 -2
- package/dist/cjs/src/perp/tx-builders/common.d.ts +52 -15
- package/dist/cjs/src/perp/tx-builders/common.js +39 -6
- package/dist/cjs/src/perp/tx-builders/wlp.d.ts +11 -3
- package/dist/cjs/src/perp/tx-builders/wlp.js +29 -3
- package/dist/cjs/src/perp/tx-builders.d.ts +3 -3
- package/dist/cjs/src/perp/tx-builders.js +3 -3
- package/dist/cjs/src/unified-client.d.ts +25 -0
- package/dist/cjs/src/unified-client.js +3 -0
- package/dist/src/account/config.d.ts +0 -16
- package/dist/src/oracle/aggregate.d.ts +96 -17
- package/dist/src/oracle/aggregate.js +180 -22
- package/dist/src/oracle/config.d.ts +85 -2
- package/dist/src/oracle/config.js +29 -0
- package/dist/src/oracle/host.d.ts +14 -1
- package/dist/src/oracle/index.d.ts +19 -5
- package/dist/src/oracle/index.js +42 -6
- package/dist/src/oracle/price-update-rule.d.ts +180 -0
- package/dist/src/oracle/price-update-rule.js +53 -0
- package/dist/src/oracle/pyth.d.ts +142 -11
- package/dist/src/oracle/pyth.js +402 -24
- package/dist/src/oracle/rule-registry.d.ts +41 -0
- package/dist/src/oracle/rule-registry.js +60 -0
- package/dist/src/oracle/rules/pyth-core-rule.d.ts +15 -0
- package/dist/src/oracle/rules/pyth-core-rule.js +96 -0
- package/dist/src/oracle/rules/pyth-lazer-rule.d.ts +41 -0
- package/dist/src/oracle/rules/pyth-lazer-rule.js +192 -0
- package/dist/src/oracle/rules/pyth-rule.js +5 -0
- package/dist/src/oracle/rules/sponsor.d.ts +11 -7
- package/dist/src/oracle/rules/sponsor.js +11 -7
- package/dist/src/oracle/update-fetch.d.ts +115 -0
- package/dist/src/oracle/update-fetch.js +277 -0
- package/dist/src/perp/client.d.ts +40 -5
- package/dist/src/perp/client.js +18 -3
- package/dist/src/perp/config.d.ts +10 -7
- package/dist/src/perp/config.js +77 -17
- package/dist/src/perp/index.d.ts +3 -2
- package/dist/src/perp/index.js +1 -1
- package/dist/src/perp/tx-builders/common.d.ts +52 -15
- package/dist/src/perp/tx-builders/common.js +39 -6
- package/dist/src/perp/tx-builders/wlp.d.ts +11 -3
- package/dist/src/perp/tx-builders/wlp.js +29 -3
- package/dist/src/perp/tx-builders.d.ts +3 -3
- package/dist/src/perp/tx-builders.js +3 -3
- package/dist/src/unified-client.d.ts +25 -0
- package/dist/src/unified-client.js +3 -0
- 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;
|
|
7
|
-
* `{ fund, packageId }`
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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.
|
|
15
|
-
* `{ fund, packageId }`
|
|
16
|
-
* {
|
|
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;
|
|
8
|
-
* `{ fund, packageId }`
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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.
|
|
18
|
-
* `{ fund, packageId }`
|
|
19
|
-
* {
|
|
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>;
|