@waterx/sdk 4.3.3 → 5.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.
- package/.claude/skills/waterx-sdk-integration/SKILL.md +41 -35
- package/README.md +81 -77
- package/dist/cjs/src/account/config.d.ts +3 -5
- package/dist/cjs/src/account/funding/wormhole.d.ts +1 -2
- package/dist/cjs/src/account/funding/wormhole.js +1 -2
- package/dist/cjs/src/oracle/aggregate.d.ts +45 -61
- package/dist/cjs/src/oracle/aggregate.js +138 -138
- package/dist/cjs/src/oracle/config.d.ts +35 -49
- package/dist/cjs/src/oracle/config.js +1 -1
- package/dist/cjs/src/oracle/host.d.ts +14 -19
- package/dist/cjs/src/oracle/host.js +3 -3
- package/dist/cjs/src/oracle/index.d.ts +25 -16
- package/dist/cjs/src/oracle/index.js +79 -66
- package/dist/cjs/src/oracle/price-update-rule.d.ts +104 -66
- package/dist/cjs/src/oracle/price-update-rule.js +24 -16
- package/dist/cjs/src/oracle/pyth-pro-history.d.ts +36 -0
- package/dist/cjs/src/oracle/pyth-pro-history.js +41 -0
- package/dist/cjs/src/oracle/read-plane.d.ts +35 -53
- package/dist/cjs/src/oracle/read-plane.js +26 -48
- package/dist/cjs/src/oracle/read-prices.d.ts +105 -0
- package/dist/cjs/src/oracle/read-prices.js +162 -0
- package/dist/cjs/src/oracle/rule-registry.d.ts +12 -12
- package/dist/cjs/src/oracle/rule-registry.js +12 -14
- package/dist/cjs/src/oracle/rules/pyth-lazer-rule.d.ts +24 -3
- package/dist/cjs/src/oracle/rules/pyth-lazer-rule.js +40 -20
- package/dist/cjs/src/oracle/rules/waterx-rule.d.ts +137 -12
- package/dist/cjs/src/oracle/rules/waterx-rule.js +318 -55
- package/dist/cjs/src/oracle/schedule.d.ts +109 -0
- package/dist/cjs/src/oracle/schedule.js +991 -0
- package/dist/cjs/src/oracle/source-list.d.ts +31 -27
- package/dist/cjs/src/oracle/source-list.js +43 -45
- package/dist/cjs/src/oracle/symbol-catalog.d.ts +54 -0
- package/dist/cjs/src/oracle/symbol-catalog.js +69 -0
- package/dist/cjs/src/oracle/update-fetch.d.ts +11 -15
- package/dist/cjs/src/oracle/update-fetch.js +12 -19
- package/dist/cjs/src/oracle/validate.d.ts +105 -0
- package/dist/cjs/src/oracle/validate.js +185 -0
- package/dist/cjs/src/oracle/weight-coverage.d.ts +79 -0
- package/dist/cjs/src/oracle/weight-coverage.js +173 -0
- package/dist/cjs/src/perp/client.d.ts +35 -52
- package/dist/cjs/src/perp/client.js +44 -17
- package/dist/cjs/src/perp/config-view.d.ts +5 -14
- package/dist/cjs/src/perp/config-view.js +5 -16
- package/dist/cjs/src/perp/config.d.ts +3 -3
- package/dist/cjs/src/perp/config.js +11 -13
- package/dist/cjs/src/perp/index.d.ts +4 -6
- package/dist/cjs/src/perp/index.js +35 -18
- package/dist/cjs/src/perp/tx-builders/common.d.ts +86 -48
- package/dist/cjs/src/perp/tx-builders/common.js +115 -47
- package/dist/cjs/src/perp/tx-builders/wlp.d.ts +3 -14
- package/dist/cjs/src/perp/tx-builders/wlp.js +6 -35
- package/dist/cjs/src/perp/tx-builders.d.ts +6 -5
- package/dist/cjs/src/perp/tx-builders.js +16 -10
- package/dist/cjs/src/unified-client.d.ts +8 -34
- package/dist/cjs/src/unified-client.js +3 -4
- package/dist/cjs/src/utils/config.d.ts +0 -8
- package/dist/cjs/src/utils/config.js +5 -13
- package/dist/src/account/config.d.ts +3 -5
- package/dist/src/account/funding/wormhole.d.ts +1 -2
- package/dist/src/account/funding/wormhole.js +1 -2
- package/dist/src/oracle/aggregate.d.ts +45 -61
- package/dist/src/oracle/aggregate.js +138 -137
- package/dist/src/oracle/config.d.ts +35 -49
- package/dist/src/oracle/config.js +1 -1
- package/dist/src/oracle/host.d.ts +14 -19
- package/dist/src/oracle/host.js +3 -3
- package/dist/src/oracle/index.d.ts +25 -16
- package/dist/src/oracle/index.js +55 -50
- package/dist/src/oracle/price-update-rule.d.ts +104 -66
- package/dist/src/oracle/price-update-rule.js +23 -16
- package/dist/src/oracle/pyth-pro-history.d.ts +36 -0
- package/dist/src/oracle/pyth-pro-history.js +38 -0
- package/dist/src/oracle/read-plane.d.ts +35 -53
- package/dist/src/oracle/read-plane.js +25 -47
- package/dist/src/oracle/read-prices.d.ts +105 -0
- package/dist/src/oracle/read-prices.js +156 -0
- package/dist/src/oracle/rule-registry.d.ts +12 -12
- package/dist/src/oracle/rule-registry.js +12 -14
- package/dist/src/oracle/rules/pyth-lazer-rule.d.ts +24 -3
- package/dist/src/oracle/rules/pyth-lazer-rule.js +39 -20
- package/dist/src/oracle/rules/waterx-rule.d.ts +137 -12
- package/dist/src/oracle/rules/waterx-rule.js +314 -56
- package/dist/src/oracle/schedule.d.ts +109 -0
- package/dist/src/oracle/schedule.js +985 -0
- package/dist/src/oracle/source-list.d.ts +31 -27
- package/dist/src/oracle/source-list.js +42 -43
- package/dist/src/oracle/symbol-catalog.d.ts +54 -0
- package/dist/src/oracle/symbol-catalog.js +65 -0
- package/dist/src/oracle/update-fetch.d.ts +11 -15
- package/dist/src/oracle/update-fetch.js +12 -18
- package/dist/src/oracle/validate.d.ts +105 -0
- package/dist/src/oracle/validate.js +177 -0
- package/dist/src/oracle/weight-coverage.d.ts +79 -0
- package/dist/src/oracle/weight-coverage.js +166 -0
- package/dist/src/perp/client.d.ts +35 -52
- package/dist/src/perp/client.js +45 -18
- package/dist/src/perp/config-view.d.ts +5 -14
- package/dist/src/perp/config-view.js +5 -16
- package/dist/src/perp/config.d.ts +3 -3
- package/dist/src/perp/config.js +11 -13
- package/dist/src/perp/index.d.ts +4 -6
- package/dist/src/perp/index.js +11 -5
- package/dist/src/perp/tx-builders/common.d.ts +86 -48
- package/dist/src/perp/tx-builders/common.js +114 -48
- package/dist/src/perp/tx-builders/wlp.d.ts +3 -14
- package/dist/src/perp/tx-builders/wlp.js +6 -35
- package/dist/src/perp/tx-builders.d.ts +6 -5
- package/dist/src/perp/tx-builders.js +15 -6
- package/dist/src/unified-client.d.ts +8 -34
- package/dist/src/unified-client.js +3 -4
- package/dist/src/utils/config.d.ts +0 -8
- package/dist/src/utils/config.js +5 -12
- package/package.json +1 -1
- package/dist/cjs/src/generated/pyth_sponsor_rule/deps/sui/balance.d.ts +0 -12
- package/dist/cjs/src/generated/pyth_sponsor_rule/deps/sui/balance.js +0 -17
- package/dist/cjs/src/generated/pyth_sponsor_rule/pyth_sponsor_rule.d.ts +0 -68
- package/dist/cjs/src/generated/pyth_sponsor_rule/pyth_sponsor_rule.js +0 -113
- package/dist/cjs/src/generated/waterx_pyth_rule/deps/sui/vec_map.d.ts +0 -36
- package/dist/cjs/src/generated/waterx_pyth_rule/deps/sui/vec_map.js +0 -31
- package/dist/cjs/src/generated/waterx_pyth_rule/pyth_rule.d.ts +0 -192
- package/dist/cjs/src/generated/waterx_pyth_rule/pyth_rule.js +0 -214
- package/dist/cjs/src/oracle/pyth.d.ts +0 -227
- package/dist/cjs/src/oracle/pyth.js +0 -652
- package/dist/cjs/src/oracle/rules/pyth-core-rule.d.ts +0 -15
- package/dist/cjs/src/oracle/rules/pyth-core-rule.js +0 -100
- package/dist/cjs/src/oracle/rules/pyth-rule.d.ts +0 -11
- package/dist/cjs/src/oracle/rules/pyth-rule.js +0 -29
- package/dist/cjs/src/oracle/rules/sponsor.d.ts +0 -32
- package/dist/cjs/src/oracle/rules/sponsor.js +0 -56
- package/dist/src/generated/pyth_sponsor_rule/deps/sui/balance.d.ts +0 -12
- package/dist/src/generated/pyth_sponsor_rule/deps/sui/balance.js +0 -14
- package/dist/src/generated/pyth_sponsor_rule/pyth_sponsor_rule.d.ts +0 -68
- package/dist/src/generated/pyth_sponsor_rule/pyth_sponsor_rule.js +0 -73
- package/dist/src/generated/waterx_pyth_rule/deps/sui/vec_map.d.ts +0 -36
- package/dist/src/generated/waterx_pyth_rule/deps/sui/vec_map.js +0 -27
- package/dist/src/generated/waterx_pyth_rule/pyth_rule.d.ts +0 -192
- package/dist/src/generated/waterx_pyth_rule/pyth_rule.js +0 -170
- package/dist/src/oracle/pyth.d.ts +0 -227
- package/dist/src/oracle/pyth.js +0 -638
- package/dist/src/oracle/rules/pyth-core-rule.d.ts +0 -15
- package/dist/src/oracle/rules/pyth-core-rule.js +0 -97
- package/dist/src/oracle/rules/pyth-rule.d.ts +0 -11
- package/dist/src/oracle/rules/pyth-rule.js +0 -26
- package/dist/src/oracle/rules/sponsor.d.ts +0 -32
- package/dist/src/oracle/rules/sponsor.js +0 -52
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `read-prices.ts` — the READ-plane executors for the two live sources: parsed
|
|
3
|
+
* (display-grade) prices from the Lazer HTTP API and the WaterX quote-center.
|
|
4
|
+
* The sibling `read-plane.ts` resolves WHICH tickers a source can price and
|
|
5
|
+
* with which ids; these functions execute that plan. Consumers (FE/BE price
|
|
6
|
+
* facades) fold onto these instead of hand-rolling per-source fetch + decode —
|
|
7
|
+
* the price DECODING here is the one place each source's wire scaling is
|
|
8
|
+
* interpreted (the retired hermes decode was deleted, not re-homed).
|
|
9
|
+
*
|
|
10
|
+
* Both executors return a Map keyed the way the plan is keyed (integer Lazer
|
|
11
|
+
* feed id / ticker) of {@link OraclePriceEntry} — plain numbers, for display
|
|
12
|
+
* and freshness policy, NEVER for rebuilding signed payloads (tx-builds carry
|
|
13
|
+
* the exact signed bytes through `refreshOraclePrices`).
|
|
14
|
+
*/
|
|
15
|
+
import type { Network } from "../constants.ts";
|
|
16
|
+
import type { PythFetchPolicy } from "./config.ts";
|
|
17
|
+
import type { FetchPolicy } from "./update-fetch.ts";
|
|
18
|
+
/**
|
|
19
|
+
* One decoded read-plane price. `price` / `conf` are display-grade floats in
|
|
20
|
+
* quote units (USD); `publishTimeMs` is the feed's own publish timestamp in
|
|
21
|
+
* ms — the input to freshness policy (`isFreshWaterxEntry`, consumers'
|
|
22
|
+
* max-age gates).
|
|
23
|
+
*/
|
|
24
|
+
export type OraclePriceEntry = {
|
|
25
|
+
price: number;
|
|
26
|
+
publishTimeMs: number;
|
|
27
|
+
conf: number;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Thrown by {@link readLazerPrices} on a Lazer `403` — the key's grant does
|
|
31
|
+
* not cover one of the requested feeds (Lazer rejects the WHOLE batch). The
|
|
32
|
+
* message carries the endpoint's own PLAIN-TEXT body verbatim (e.g.
|
|
33
|
+
* `Not entitled: feed 327 (no grant accepts this feed (asset type 'fx',
|
|
34
|
+
* instrument type 'spot'))`) — it names the offending feed and grant reason
|
|
35
|
+
* better than anything the SDK could synthesize. `instanceof`-able so a
|
|
36
|
+
* consumer can drop unentitled feeds and retry rather than string-matching.
|
|
37
|
+
*/
|
|
38
|
+
export declare class LazerNotEntitledError extends Error {
|
|
39
|
+
/** The integer Lazer feed ids the rejected request asked for. */
|
|
40
|
+
readonly feedIds: number[];
|
|
41
|
+
constructor(body: string, feedIds: number[]);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Read parsed prices for `feedIds` (integer Lazer ids — the `"lazer"` arm of
|
|
45
|
+
* `resolveOracleReadPlan`) via `POST /v1/latest_price`.
|
|
46
|
+
*
|
|
47
|
+
* - `network` is REQUIRED: it selects the Lazer endpoint AND the channel, and
|
|
48
|
+
* the integer feed ids in a plan are network-specific, so defaulting it
|
|
49
|
+
* would silently read mainnet infra with testnet ids. Every caller already
|
|
50
|
+
* holds one (`resolveOracleReadPlan` takes the same host).
|
|
51
|
+
* - `channel` defaults to `LAZER_INFRA[network].channel` (the same channel
|
|
52
|
+
* the write leg uses — bounded by the feeds' `min_channel` AND the key's
|
|
53
|
+
* grant). A `400` naming an unsupported/rate-limited channel propagates as
|
|
54
|
+
* a plain error with the body attached — that is an operator
|
|
55
|
+
* misconfiguration, not a retry case.
|
|
56
|
+
* - Decoding: `price = Number(price) * 10 ** exponent` (the wire `price` is a
|
|
57
|
+
* decimal STRING), `conf = Number(confidence) * 10 ** exponent`,
|
|
58
|
+
* `publishTimeMs = feedUpdateTimestamp / 1000` (the wire value is µs). An
|
|
59
|
+
* entry with NO `feedUpdateTimestamp` is SKIPPED — never timestamped off
|
|
60
|
+
* the response's batch `timestampUs`, which says when Lazer answered, not
|
|
61
|
+
* when that feed last printed (a closed-market feed would masquerade as
|
|
62
|
+
* fresh).
|
|
63
|
+
* - `403` → {@link LazerNotEntitledError} (whole batch rejected; body text
|
|
64
|
+
* verbatim).
|
|
65
|
+
*/
|
|
66
|
+
export declare function readLazerPrices(opts: {
|
|
67
|
+
apiKey: string;
|
|
68
|
+
feedIds: number[];
|
|
69
|
+
network: Network;
|
|
70
|
+
channel?: string;
|
|
71
|
+
fetch?: PythFetchPolicy;
|
|
72
|
+
}): Promise<Map<number, OraclePriceEntry>>;
|
|
73
|
+
/**
|
|
74
|
+
* Read prices for `tickers` (the `"quote_center"` arm of
|
|
75
|
+
* `resolveOracleReadPlan`) through {@link pullWaterxQuotes}, the rule-owned
|
|
76
|
+
* route ladder the write path uses: per-symbol Merkle leaves by default, the
|
|
77
|
+
* batch envelope only against a quote-center with no leaf route. Public read,
|
|
78
|
+
* no auth, same retry/timeout policy as every oracle fetch.
|
|
79
|
+
*
|
|
80
|
+
* Going through the shared ladder is what keeps the read plane from drifting
|
|
81
|
+
* off the write plane, and it is why reads do not pull a whole-registry
|
|
82
|
+
* envelope: that is one signature over every symbol, so a plane polling a
|
|
83
|
+
* handful of them would transfer and bigint-revive the lot on every tick.
|
|
84
|
+
*
|
|
85
|
+
* Decoding per item: `price = Number(price_n) / Number(price_scale)` and
|
|
86
|
+
* `conf = Number(confidence_n) / Number(confidence_scale)`, each `0` when its
|
|
87
|
+
* scale is `0` (a zero divisor is "no value", not `Infinity`);
|
|
88
|
+
* `publishTimeMs = Number(price_timestamp_ms)`. Reads share the write path's
|
|
89
|
+
* fetch — a mispointed endpoint fails the same intent/shape checks tx-builds
|
|
90
|
+
* fail — but this NEVER hands its signed data to a tx-build: the on-chain
|
|
91
|
+
* per-symbol replay guard (F-014) burns one submission per signed timestamp,
|
|
92
|
+
* and reads must not race trades for it.
|
|
93
|
+
*/
|
|
94
|
+
export declare function readQuoteCenterPrices(opts: {
|
|
95
|
+
endpoint: string;
|
|
96
|
+
/**
|
|
97
|
+
* Oracle TICKERS, per the repo convention — this is the consumer boundary,
|
|
98
|
+
* fed directly by `readPlanTickers(plan)`, and naming the same values
|
|
99
|
+
* `symbols` here made every call site cross a rename. Below this point the
|
|
100
|
+
* wire helpers keep `symbols`: that mirrors the literal `?symbols=` query
|
|
101
|
+
* parameter and the `symbol` field the quote-center returns.
|
|
102
|
+
*/
|
|
103
|
+
tickers: string[];
|
|
104
|
+
fetch?: FetchPolicy;
|
|
105
|
+
}): Promise<Map<string, OraclePriceEntry>>;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `read-prices.ts` — the READ-plane executors for the two live sources: parsed
|
|
3
|
+
* (display-grade) prices from the Lazer HTTP API and the WaterX quote-center.
|
|
4
|
+
* The sibling `read-plane.ts` resolves WHICH tickers a source can price and
|
|
5
|
+
* with which ids; these functions execute that plan. Consumers (FE/BE price
|
|
6
|
+
* facades) fold onto these instead of hand-rolling per-source fetch + decode —
|
|
7
|
+
* the price DECODING here is the one place each source's wire scaling is
|
|
8
|
+
* interpreted (the retired hermes decode was deleted, not re-homed).
|
|
9
|
+
*
|
|
10
|
+
* Both executors return a Map keyed the way the plan is keyed (integer Lazer
|
|
11
|
+
* feed id / ticker) of {@link OraclePriceEntry} — plain numbers, for display
|
|
12
|
+
* and freshness policy, NEVER for rebuilding signed payloads (tx-builds carry
|
|
13
|
+
* the exact signed bytes through `refreshOraclePrices`).
|
|
14
|
+
*/
|
|
15
|
+
import { LAZER_INFRA, postLazerLatestPrice } from "./rules/pyth-lazer-rule.js";
|
|
16
|
+
import { pullWaterxQuotes } from "./rules/waterx-rule.js";
|
|
17
|
+
/**
|
|
18
|
+
* Thrown by {@link readLazerPrices} on a Lazer `403` — the key's grant does
|
|
19
|
+
* not cover one of the requested feeds (Lazer rejects the WHOLE batch). The
|
|
20
|
+
* message carries the endpoint's own PLAIN-TEXT body verbatim (e.g.
|
|
21
|
+
* `Not entitled: feed 327 (no grant accepts this feed (asset type 'fx',
|
|
22
|
+
* instrument type 'spot'))`) — it names the offending feed and grant reason
|
|
23
|
+
* better than anything the SDK could synthesize. `instanceof`-able so a
|
|
24
|
+
* consumer can drop unentitled feeds and retry rather than string-matching.
|
|
25
|
+
*/
|
|
26
|
+
export class LazerNotEntitledError extends Error {
|
|
27
|
+
/** The integer Lazer feed ids the rejected request asked for. */
|
|
28
|
+
feedIds;
|
|
29
|
+
constructor(body, feedIds) {
|
|
30
|
+
super(`Lazer price read not entitled: ${body}`);
|
|
31
|
+
this.name = "LazerNotEntitledError";
|
|
32
|
+
this.feedIds = [...feedIds];
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The parsed-read request pins, mirroring the live-probed Lazer contract:
|
|
37
|
+
* `formats: []` is REQUIRED — an ABSENT `formats` is rejected while an empty
|
|
38
|
+
* array means "parsed only, no signed blob", which is exactly what a read
|
|
39
|
+
* plane wants (no signature bytes to pay bandwidth for). `properties` pins
|
|
40
|
+
* the fields the decoder below consumes; `marketSession` rides along for
|
|
41
|
+
* consumers that surface venue state.
|
|
42
|
+
*/
|
|
43
|
+
const LAZER_PARSED_READ_REQUEST = {
|
|
44
|
+
properties: ["price", "exponent", "confidence", "feedUpdateTimestamp", "marketSession"],
|
|
45
|
+
formats: [],
|
|
46
|
+
parsed: true,
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Read parsed prices for `feedIds` (integer Lazer ids — the `"lazer"` arm of
|
|
50
|
+
* `resolveOracleReadPlan`) via `POST /v1/latest_price`.
|
|
51
|
+
*
|
|
52
|
+
* - `network` is REQUIRED: it selects the Lazer endpoint AND the channel, and
|
|
53
|
+
* the integer feed ids in a plan are network-specific, so defaulting it
|
|
54
|
+
* would silently read mainnet infra with testnet ids. Every caller already
|
|
55
|
+
* holds one (`resolveOracleReadPlan` takes the same host).
|
|
56
|
+
* - `channel` defaults to `LAZER_INFRA[network].channel` (the same channel
|
|
57
|
+
* the write leg uses — bounded by the feeds' `min_channel` AND the key's
|
|
58
|
+
* grant). A `400` naming an unsupported/rate-limited channel propagates as
|
|
59
|
+
* a plain error with the body attached — that is an operator
|
|
60
|
+
* misconfiguration, not a retry case.
|
|
61
|
+
* - Decoding: `price = Number(price) * 10 ** exponent` (the wire `price` is a
|
|
62
|
+
* decimal STRING), `conf = Number(confidence) * 10 ** exponent`,
|
|
63
|
+
* `publishTimeMs = feedUpdateTimestamp / 1000` (the wire value is µs). An
|
|
64
|
+
* entry with NO `feedUpdateTimestamp` is SKIPPED — never timestamped off
|
|
65
|
+
* the response's batch `timestampUs`, which says when Lazer answered, not
|
|
66
|
+
* when that feed last printed (a closed-market feed would masquerade as
|
|
67
|
+
* fresh).
|
|
68
|
+
* - `403` → {@link LazerNotEntitledError} (whole batch rejected; body text
|
|
69
|
+
* verbatim).
|
|
70
|
+
*/
|
|
71
|
+
export async function readLazerPrices(opts) {
|
|
72
|
+
const out = new Map();
|
|
73
|
+
if (opts.feedIds.length === 0)
|
|
74
|
+
return out;
|
|
75
|
+
const infra = LAZER_INFRA[opts.network];
|
|
76
|
+
// Same POST transport the rule's write leg uses (URL join, method, headers,
|
|
77
|
+
// Bearer + retry policy) — only the request pins and the decoding below are
|
|
78
|
+
// read-specific.
|
|
79
|
+
const res = await postLazerLatestPrice(infra.endpoint, opts.channel ?? infra.channel, opts.apiKey, opts.feedIds, LAZER_PARSED_READ_REQUEST, opts.fetch);
|
|
80
|
+
if (res.status === 403) {
|
|
81
|
+
// Plain-text body naming the unentitled feed + grant reason — verbatim.
|
|
82
|
+
throw new LazerNotEntitledError((await res.text()).trim(), opts.feedIds);
|
|
83
|
+
}
|
|
84
|
+
if (!res.ok) {
|
|
85
|
+
// Includes the 400 channel rejections ("Feeds do not support channel …" /
|
|
86
|
+
// rate-limit minimum-channel) — deterministic misconfigurations that must
|
|
87
|
+
// reach the operator with the endpoint's own wording.
|
|
88
|
+
throw new Error(`Lazer price read failed: ${res.status} ${await res.text()}`);
|
|
89
|
+
}
|
|
90
|
+
const json = (await res.json());
|
|
91
|
+
for (const feed of json.parsed?.priceFeeds ?? []) {
|
|
92
|
+
if (typeof feed.priceFeedId !== "number" ||
|
|
93
|
+
feed.price === undefined ||
|
|
94
|
+
typeof feed.exponent !== "number" ||
|
|
95
|
+
// No per-feed timestamp ⇒ no honest freshness claim ⇒ skip (see doc).
|
|
96
|
+
typeof feed.feedUpdateTimestamp !== "number") {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const scale = 10 ** feed.exponent;
|
|
100
|
+
const price = Number(feed.price) * scale;
|
|
101
|
+
const conf = typeof feed.confidence === "number" ? feed.confidence * scale : 0;
|
|
102
|
+
// A non-finite decode is a MISSING price, not a price. `Number("abc")` is
|
|
103
|
+
// NaN and an absurd exponent overflows to Infinity; either would flow
|
|
104
|
+
// straight into a consumer's staleness/deviation maths, where NaN silently
|
|
105
|
+
// fails every comparison it appears in. Skipping matches how a feed with no
|
|
106
|
+
// timestamp is handled above — omitted, so the caller sees it as unserved.
|
|
107
|
+
if (!Number.isFinite(price) || !Number.isFinite(conf))
|
|
108
|
+
continue;
|
|
109
|
+
out.set(feed.priceFeedId, {
|
|
110
|
+
price,
|
|
111
|
+
conf,
|
|
112
|
+
publishTimeMs: feed.feedUpdateTimestamp / 1000,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Read prices for `tickers` (the `"quote_center"` arm of
|
|
119
|
+
* `resolveOracleReadPlan`) through {@link pullWaterxQuotes}, the rule-owned
|
|
120
|
+
* route ladder the write path uses: per-symbol Merkle leaves by default, the
|
|
121
|
+
* batch envelope only against a quote-center with no leaf route. Public read,
|
|
122
|
+
* no auth, same retry/timeout policy as every oracle fetch.
|
|
123
|
+
*
|
|
124
|
+
* Going through the shared ladder is what keeps the read plane from drifting
|
|
125
|
+
* off the write plane, and it is why reads do not pull a whole-registry
|
|
126
|
+
* envelope: that is one signature over every symbol, so a plane polling a
|
|
127
|
+
* handful of them would transfer and bigint-revive the lot on every tick.
|
|
128
|
+
*
|
|
129
|
+
* Decoding per item: `price = Number(price_n) / Number(price_scale)` and
|
|
130
|
+
* `conf = Number(confidence_n) / Number(confidence_scale)`, each `0` when its
|
|
131
|
+
* scale is `0` (a zero divisor is "no value", not `Infinity`);
|
|
132
|
+
* `publishTimeMs = Number(price_timestamp_ms)`. Reads share the write path's
|
|
133
|
+
* fetch — a mispointed endpoint fails the same intent/shape checks tx-builds
|
|
134
|
+
* fail — but this NEVER hands its signed data to a tx-build: the on-chain
|
|
135
|
+
* per-symbol replay guard (F-014) burns one submission per signed timestamp,
|
|
136
|
+
* and reads must not race trades for it.
|
|
137
|
+
*/
|
|
138
|
+
export async function readQuoteCenterPrices(opts) {
|
|
139
|
+
const out = new Map();
|
|
140
|
+
if (opts.tickers.length === 0)
|
|
141
|
+
return out;
|
|
142
|
+
const { items } = await pullWaterxQuotes(opts.endpoint, opts.tickers, opts.fetch);
|
|
143
|
+
const requested = new Set(opts.tickers);
|
|
144
|
+
for (const item of items) {
|
|
145
|
+
if (!requested.has(item.symbol))
|
|
146
|
+
continue;
|
|
147
|
+
out.set(item.symbol, {
|
|
148
|
+
price: item.price_scale === 0n ? 0 : Number(item.price_n) / Number(item.price_scale),
|
|
149
|
+
conf: item.confidence_scale === 0n
|
|
150
|
+
? 0
|
|
151
|
+
: Number(item.confidence_n) / Number(item.confidence_scale),
|
|
152
|
+
publishTimeMs: Number(item.price_timestamp_ms),
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
@@ -5,26 +5,26 @@
|
|
|
5
5
|
* every `host.oracleSources` entry through it, and consumers (e.g. a BE
|
|
6
6
|
* prefetch cache keying per source) import `resolveOracleRule` rather than
|
|
7
7
|
* hand-mirroring this map. Selection is driven purely by the value passed in
|
|
8
|
-
* (ultimately `OracleHost.oracleSources`, the
|
|
8
|
+
* (ultimately `OracleHost.oracleSources`, derived from the deployment config
|
|
9
9
|
* option) — never by a config JSON `enabled` flag and never by `process.env`.
|
|
10
10
|
*
|
|
11
11
|
* Each source is self-contained: it owns its own infra + config and does NOT
|
|
12
|
-
* back-stop any other source.
|
|
13
|
-
* (`
|
|
14
|
-
* signed
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
12
|
+
* back-stop any other source. Two are registered: `pyth_lazer_rule`
|
|
13
|
+
* (`PythLazerRule`, Lazer signed updates) and `waterx_rule` (`WaterxRule`,
|
|
14
|
+
* first-party quote-center ed25519 signed batches). Resolving a source with
|
|
15
|
+
* no registered rule throws a clear `OracleSourceNotImplemented` error. There
|
|
16
|
+
* is deliberately no cross-source fallback and no client-creation config
|
|
17
|
+
* guard: selecting a source whose feeds are absent is not an error at init —
|
|
18
|
+
* it surfaces at tx-build time for the specific tickers that source can't
|
|
19
|
+
* serve (see `refreshOraclePrices`).
|
|
20
20
|
*/
|
|
21
21
|
import type { OracleSource, PriceUpdateRule } from "./price-update-rule.ts";
|
|
22
22
|
/**
|
|
23
23
|
* Thrown by {@link resolveOracleRule} when `source` has no `PriceUpdateRule`
|
|
24
24
|
* registered in either the production registry or a test's `overrides` map.
|
|
25
|
-
* `instanceof`-able (mirrors `
|
|
26
|
-
* so a consumer can branch on the failure type
|
|
27
|
-
* string-matching `error.message`.
|
|
25
|
+
* `instanceof`-able (mirrors `LazerApiKeyMissingError` in
|
|
26
|
+
* `rules/pyth-lazer-rule.ts`) so a consumer can branch on the failure type
|
|
27
|
+
* directly instead of string-matching `error.message`.
|
|
28
28
|
*/
|
|
29
29
|
export declare class OracleSourceNotImplementedError extends Error {
|
|
30
30
|
/** The unregistered `OracleSource` that was requested. */
|
|
@@ -5,20 +5,19 @@
|
|
|
5
5
|
* every `host.oracleSources` entry through it, and consumers (e.g. a BE
|
|
6
6
|
* prefetch cache keying per source) import `resolveOracleRule` rather than
|
|
7
7
|
* hand-mirroring this map. Selection is driven purely by the value passed in
|
|
8
|
-
* (ultimately `OracleHost.oracleSources`, the
|
|
8
|
+
* (ultimately `OracleHost.oracleSources`, derived from the deployment config
|
|
9
9
|
* option) — never by a config JSON `enabled` flag and never by `process.env`.
|
|
10
10
|
*
|
|
11
11
|
* Each source is self-contained: it owns its own infra + config and does NOT
|
|
12
|
-
* back-stop any other source.
|
|
13
|
-
* (`
|
|
14
|
-
* signed
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
12
|
+
* back-stop any other source. Two are registered: `pyth_lazer_rule`
|
|
13
|
+
* (`PythLazerRule`, Lazer signed updates) and `waterx_rule` (`WaterxRule`,
|
|
14
|
+
* first-party quote-center ed25519 signed batches). Resolving a source with
|
|
15
|
+
* no registered rule throws a clear `OracleSourceNotImplemented` error. There
|
|
16
|
+
* is deliberately no cross-source fallback and no client-creation config
|
|
17
|
+
* guard: selecting a source whose feeds are absent is not an error at init —
|
|
18
|
+
* it surfaces at tx-build time for the specific tickers that source can't
|
|
19
|
+
* serve (see `refreshOraclePrices`).
|
|
20
20
|
*/
|
|
21
|
-
import { PythCoreRule } from "./rules/pyth-core-rule.js";
|
|
22
21
|
import { PythLazerRule } from "./rules/pyth-lazer-rule.js";
|
|
23
22
|
import { WaterxRule } from "./rules/waterx-rule.js";
|
|
24
23
|
/**
|
|
@@ -26,16 +25,15 @@ import { WaterxRule } from "./rules/waterx-rule.js";
|
|
|
26
25
|
* `resolveOracleRule`'s `overrides` param instead of mutating this.
|
|
27
26
|
*/
|
|
28
27
|
const DEFAULT_RULES = Object.freeze({
|
|
29
|
-
pyth_rule: PythCoreRule,
|
|
30
28
|
pyth_lazer_rule: PythLazerRule,
|
|
31
29
|
waterx_rule: WaterxRule,
|
|
32
30
|
});
|
|
33
31
|
/**
|
|
34
32
|
* Thrown by {@link resolveOracleRule} when `source` has no `PriceUpdateRule`
|
|
35
33
|
* registered in either the production registry or a test's `overrides` map.
|
|
36
|
-
* `instanceof`-able (mirrors `
|
|
37
|
-
* so a consumer can branch on the failure type
|
|
38
|
-
* string-matching `error.message`.
|
|
34
|
+
* `instanceof`-able (mirrors `LazerApiKeyMissingError` in
|
|
35
|
+
* `rules/pyth-lazer-rule.ts`) so a consumer can branch on the failure type
|
|
36
|
+
* directly instead of string-matching `error.message`.
|
|
39
37
|
*/
|
|
40
38
|
export class OracleSourceNotImplementedError extends Error {
|
|
41
39
|
/** The unregistered `OracleSource` that was requested. */
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import type { Transaction, TransactionArgument } from "@mysten/sui/transactions";
|
|
12
12
|
import type { Network } from "../../constants.ts";
|
|
13
|
+
import type { PythFetchPolicy } from "../config.ts";
|
|
13
14
|
import type { OracleHost } from "../host.ts";
|
|
14
15
|
import { type PriceUpdateRule } from "../price-update-rule.ts";
|
|
15
16
|
/**
|
|
@@ -60,13 +61,33 @@ export interface PythLazerUpdatePayload {
|
|
|
60
61
|
* Thrown by {@link PythLazerRule.fetchUpdateData} when `pyth_lazer_rule` is
|
|
61
62
|
* deployed in config but no `pythApiKey` was supplied at client init — the
|
|
62
63
|
* Lazer HTTP API requires a Bearer token and the SDK never reads
|
|
63
|
-
* `process.env` to find one. `
|
|
64
|
-
*
|
|
65
|
-
*
|
|
64
|
+
* `process.env` to find one. Also thrown by `refreshOraclePrices`'s hoisted
|
|
65
|
+
* credential pre-check (`aggregate.ts`) BEFORE any fetch, keyed off this
|
|
66
|
+
* rule's `credential` declaration. `instanceof`-able (mirrors `FetchPolicyError`
|
|
67
|
+
* in `../update-fetch.ts`) so a consumer can branch on the failure type
|
|
68
|
+
* directly instead of string-matching `error.message`.
|
|
66
69
|
*/
|
|
67
70
|
export declare class LazerApiKeyMissingError extends Error {
|
|
68
71
|
constructor();
|
|
69
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* THE `POST {endpoint}/v1/latest_price` transport — shared by BOTH legs of
|
|
75
|
+
* this endpoint: the signed-update WRITE fetch below and the parsed-price READ
|
|
76
|
+
* executor (`readLazerPrices` in `../read-prices.ts`). They differ only in the
|
|
77
|
+
* request pins they send and how they decode/blame the response, so keeping
|
|
78
|
+
* one transport stops the URL, method, headers, and auth/retry policy drifting
|
|
79
|
+
* between them.
|
|
80
|
+
*
|
|
81
|
+
* `requestPins` is spread into the body alongside `priceFeedIds` + `channel`;
|
|
82
|
+
* the raw `Response` comes back undecoded so each caller owns its own error
|
|
83
|
+
* framing (the write leg reframes an exhausted retry, the read leg maps 403 to
|
|
84
|
+
* an entitlement error).
|
|
85
|
+
*
|
|
86
|
+
* Goes through the shared `fetchWithPolicy` (`../update-fetch.ts`) — the one
|
|
87
|
+
* retry/timeout/Bearer policy every oracle fetch shares, so all sources fail
|
|
88
|
+
* the same way under upstream degradation.
|
|
89
|
+
*/
|
|
90
|
+
export declare function postLazerLatestPrice(endpoint: string, channel: string, apiKey: string, feedIds: number[], requestPins: Record<string, unknown>, fetchOpts?: PythFetchPolicy): Promise<Response>;
|
|
70
91
|
/**
|
|
71
92
|
* `pyth_lazer_rule::feed(collector, config, clock, &update)` — contribute the
|
|
72
93
|
* verified Lazer price for `collector.symbol()` to the collector. `update` is
|
|
@@ -85,7 +85,7 @@ const LAZER_LATEST_PRICE_REQUEST = {
|
|
|
85
85
|
};
|
|
86
86
|
/**
|
|
87
87
|
* Shape check ONLY — the `kind` discriminant is checked separately by the
|
|
88
|
-
* caller before this runs (mirrors `
|
|
88
|
+
* caller before this runs (mirrors `WaterxRule`'s guard split), so a
|
|
89
89
|
* same-shaped payload from a different rule can never silently pass.
|
|
90
90
|
*/
|
|
91
91
|
function isPythLazerUpdatePayloadShape(payload) {
|
|
@@ -98,9 +98,11 @@ function isPythLazerUpdatePayloadShape(payload) {
|
|
|
98
98
|
* Thrown by {@link PythLazerRule.fetchUpdateData} when `pyth_lazer_rule` is
|
|
99
99
|
* deployed in config but no `pythApiKey` was supplied at client init — the
|
|
100
100
|
* Lazer HTTP API requires a Bearer token and the SDK never reads
|
|
101
|
-
* `process.env` to find one. `
|
|
102
|
-
*
|
|
103
|
-
*
|
|
101
|
+
* `process.env` to find one. Also thrown by `refreshOraclePrices`'s hoisted
|
|
102
|
+
* credential pre-check (`aggregate.ts`) BEFORE any fetch, keyed off this
|
|
103
|
+
* rule's `credential` declaration. `instanceof`-able (mirrors `FetchPolicyError`
|
|
104
|
+
* in `../update-fetch.ts`) so a consumer can branch on the failure type
|
|
105
|
+
* directly instead of string-matching `error.message`.
|
|
104
106
|
*/
|
|
105
107
|
export class LazerApiKeyMissingError extends Error {
|
|
106
108
|
constructor() {
|
|
@@ -118,24 +120,39 @@ function requireLazerPackage(host) {
|
|
|
118
120
|
return entry;
|
|
119
121
|
}
|
|
120
122
|
/**
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
123
|
+
* THE `POST {endpoint}/v1/latest_price` transport — shared by BOTH legs of
|
|
124
|
+
* this endpoint: the signed-update WRITE fetch below and the parsed-price READ
|
|
125
|
+
* executor (`readLazerPrices` in `../read-prices.ts`). They differ only in the
|
|
126
|
+
* request pins they send and how they decode/blame the response, so keeping
|
|
127
|
+
* one transport stops the URL, method, headers, and auth/retry policy drifting
|
|
128
|
+
* between them.
|
|
129
|
+
*
|
|
130
|
+
* `requestPins` is spread into the body alongside `priceFeedIds` + `channel`;
|
|
131
|
+
* the raw `Response` comes back undecoded so each caller owns its own error
|
|
132
|
+
* framing (the write leg reframes an exhausted retry, the read leg maps 403 to
|
|
133
|
+
* an entitlement error).
|
|
134
|
+
*
|
|
135
|
+
* Goes through the shared `fetchWithPolicy` (`../update-fetch.ts`) — the one
|
|
136
|
+
* retry/timeout/Bearer policy every oracle fetch shares, so all sources fail
|
|
137
|
+
* the same way under upstream degradation.
|
|
125
138
|
*/
|
|
126
|
-
|
|
139
|
+
export function postLazerLatestPrice(endpoint, channel, apiKey, feedIds, requestPins, fetchOpts) {
|
|
127
140
|
// joinEndpointPath preserves any base path on the endpoint — the same
|
|
128
141
|
// leading-slash `new URL` footgun that 404'd every feed on the Pyth Pro
|
|
129
142
|
// Hermes endpoint (see update-fetch.ts). Defensive here: the default
|
|
130
143
|
// Lazer endpoint has no base path, but a config override may.
|
|
131
144
|
const url = joinEndpointPath(endpoint, "v1/latest_price");
|
|
145
|
+
return fetchWithPolicy(url.toString(), {
|
|
146
|
+
method: "POST",
|
|
147
|
+
headers: { "Content-Type": "application/json" },
|
|
148
|
+
body: JSON.stringify({ priceFeedIds: feedIds, ...requestPins, channel }),
|
|
149
|
+
}, { apiKey, ...fetchOpts });
|
|
150
|
+
}
|
|
151
|
+
/** Fetch one signed `leEcdsa` update for `feedIds` from the Lazer HTTP API. */
|
|
152
|
+
async function fetchLazerSignedUpdate(endpoint, channel, apiKey, feedIds, fetchOpts) {
|
|
132
153
|
let res;
|
|
133
154
|
try {
|
|
134
|
-
res = await
|
|
135
|
-
method: "POST",
|
|
136
|
-
headers: { "Content-Type": "application/json" },
|
|
137
|
-
body: JSON.stringify({ priceFeedIds: feedIds, ...LAZER_LATEST_PRICE_REQUEST, channel }),
|
|
138
|
-
}, { apiKey, ...fetchOpts });
|
|
155
|
+
res = await postLazerLatestPrice(endpoint, channel, apiKey, feedIds, LAZER_LATEST_PRICE_REQUEST, fetchOpts);
|
|
139
156
|
}
|
|
140
157
|
catch (err) {
|
|
141
158
|
rethrowExhaustedFetch(err, (e) => `Lazer price fetch failed: ${e.status}${e.bodySnippet ? ` ${e.bodySnippet}` : ""}`);
|
|
@@ -167,9 +184,12 @@ export function feedLazerRule(tx, host, collector, update) {
|
|
|
167
184
|
}
|
|
168
185
|
export const PythLazerRule = {
|
|
169
186
|
kind: "pyth_lazer_rule",
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
|
|
187
|
+
// Lazer is auth-first: the signed-update fetch cannot run without the
|
|
188
|
+
// caller's `pythApiKey` Bearer. Declared on the port — kind AND error
|
|
189
|
+
// together — so `refreshOraclePrices`'s hoisted pre-check and consumers'
|
|
190
|
+
// `missingOracleCredentials` boot asserts both key off the rule itself, and
|
|
191
|
+
// the orchestrator never constructs this error on the rule's behalf.
|
|
192
|
+
credential: { kind: "pyth_api_key", missing: () => new LazerApiKeyMissingError() },
|
|
173
193
|
/** Tickers with a `pyth_lazer_rule.feeds` entry (integer Lazer feed ids). */
|
|
174
194
|
supportedTickers(host) {
|
|
175
195
|
return Object.keys(host.config.packages.pyth_lazer_rule?.feeds ?? {});
|
|
@@ -222,14 +242,13 @@ export const PythLazerRule = {
|
|
|
222
242
|
* Appends the single `parse_and_verify_le_ecdsa_update*(state, clock, bytes)`
|
|
223
243
|
* call — one secp256k1 signature check covering every feed in the payload —
|
|
224
244
|
* and returns its `Update` result as the handle the per-ticker feed leg
|
|
225
|
-
* consumes.
|
|
226
|
-
* ignored (Lazer verification charges no update fee).
|
|
245
|
+
* consumes.
|
|
227
246
|
*
|
|
228
247
|
* The entry name comes from `LAZER_INFRA[network].verify_entry`: mainnet's
|
|
229
248
|
* rule binds `update_v2`, testnet's is still the v1 publish. Both take
|
|
230
249
|
* `(state, clock, bytes)` and accept the same `leEcdsa` payload.
|
|
231
250
|
*/
|
|
232
|
-
buildUpdateCalls(tx, host, data
|
|
251
|
+
buildUpdateCalls(tx, host, data) {
|
|
233
252
|
const payload = assertRuleUpdateData(data, "pyth_lazer_rule", isPythLazerUpdatePayloadShape, "{ update: Uint8Array; feedIds: number[] }");
|
|
234
253
|
if (!payload)
|
|
235
254
|
return undefined;
|