@waterx/sdk 4.3.1 → 4.3.3
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 +219 -0
- package/README.md +177 -10
- package/SKILLS.md +34 -0
- package/dist/cjs/src/generated/waterx_rule/waterx_rule.d.ts +140 -7
- package/dist/cjs/src/generated/waterx_rule/waterx_rule.js +163 -8
- package/dist/cjs/src/oracle/aggregate.d.ts +8 -4
- package/dist/cjs/src/oracle/aggregate.js +74 -29
- package/dist/cjs/src/oracle/config.d.ts +3 -2
- package/dist/cjs/src/oracle/index.d.ts +2 -2
- package/dist/cjs/src/oracle/index.js +10 -5
- package/dist/cjs/src/oracle/price-update-rule.d.ts +10 -2
- package/dist/cjs/src/oracle/rules/pyth-lazer-rule.d.ts +25 -7
- package/dist/cjs/src/oracle/rules/pyth-lazer-rule.js +41 -17
- package/dist/cjs/src/oracle/rules/waterx-rule.d.ts +136 -47
- package/dist/cjs/src/oracle/rules/waterx-rule.js +450 -114
- package/dist/cjs/src/perp/client.d.ts +10 -8
- package/dist/cjs/src/perp/index.d.ts +2 -2
- package/dist/cjs/src/perp/index.js +10 -2
- package/dist/cjs/src/unified-client.d.ts +1 -1
- package/dist/src/generated/waterx_rule/waterx_rule.d.ts +140 -7
- package/dist/src/generated/waterx_rule/waterx_rule.js +151 -7
- package/dist/src/oracle/aggregate.d.ts +8 -4
- package/dist/src/oracle/aggregate.js +75 -30
- package/dist/src/oracle/config.d.ts +3 -2
- package/dist/src/oracle/index.d.ts +2 -2
- package/dist/src/oracle/index.js +8 -6
- package/dist/src/oracle/price-update-rule.d.ts +10 -2
- package/dist/src/oracle/rules/pyth-lazer-rule.d.ts +25 -7
- package/dist/src/oracle/rules/pyth-lazer-rule.js +41 -17
- package/dist/src/oracle/rules/waterx-rule.d.ts +136 -47
- package/dist/src/oracle/rules/waterx-rule.js +447 -114
- package/dist/src/perp/client.d.ts +10 -8
- package/dist/src/perp/index.d.ts +2 -2
- package/dist/src/perp/index.js +7 -1
- package/dist/src/unified-client.d.ts +1 -1
- package/package.json +8 -2
|
@@ -30,7 +30,7 @@ import { feedConstantRule } from "./rules/constant-rule.js";
|
|
|
30
30
|
import { feedLazerRule } from "./rules/pyth-lazer-rule.js";
|
|
31
31
|
import { feedPythRule } from "./rules/pyth-rule.js";
|
|
32
32
|
import { maybeFeedSupra } from "./rules/supra-rule.js";
|
|
33
|
-
import { feedWaterxRule, waterxEnvelopeOf, } from "./rules/waterx-rule.js";
|
|
33
|
+
import { feedWaterxRule, feedWaterxRuleWithProof, waterxEnvelopeOf, waterxLeavesOf, } from "./rules/waterx-rule.js";
|
|
34
34
|
/**
|
|
35
35
|
* Resolve one group's off-chain update payload for {@link refreshOraclePrices}:
|
|
36
36
|
* try `provider.get(source, tickers)` first (when a provider is configured),
|
|
@@ -84,9 +84,10 @@ async function resolveGroupUpdateData(host, group, provider) {
|
|
|
84
84
|
* if it is stale — it never aborts — so the call stays mandatory while
|
|
85
85
|
* `pyth_rule` remains in the ticker's on-chain weighted set
|
|
86
86
|
* (`EMissingPriceSource` requires every weighted rule to appear).
|
|
87
|
-
* - **Lazer** — fed when `lazerUpdate` is supplied: the verified
|
|
88
|
-
*
|
|
89
|
-
* (
|
|
87
|
+
* - **Lazer** — fed when `lazerUpdate` is supplied: the verified update this
|
|
88
|
+
* PTB's lazer update leg produced with the network's verify entry
|
|
89
|
+
* (`update_v2::Update` on mainnet, `update::Update` on testnet — see
|
|
90
|
+
* `PythLazerRule.buildUpdateCalls`). If the ticker's aggregator does
|
|
90
91
|
* not (yet) weight `PythLazerRule`, the contribution is silently dropped
|
|
91
92
|
* on-chain — feeding ahead of the weight migration is harmless.
|
|
92
93
|
* - **Supra** — fed alongside Pyth/Lazer when supra is enabled + wired
|
|
@@ -113,17 +114,25 @@ export function aggregateTicker(tx, host, args) {
|
|
|
113
114
|
feedLazerRule(tx, host, collector, args.lazerUpdate);
|
|
114
115
|
fed = true;
|
|
115
116
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
// header
|
|
117
|
+
// One waterx leg at most, and the leaf shape wins: both entries record the
|
|
118
|
+
// same per-symbol signed-timestamp high-water mark and feed the same rule
|
|
119
|
+
// witness into this collector, so emitting both would make the second one
|
|
120
|
+
// abstain on its own predecessor's mark for no gain. `refreshOraclePrices`
|
|
121
|
+
// only ever supplies one; a caller that passes both gets the cheaper leg.
|
|
122
|
+
if (args.waterxLeaf !== undefined) {
|
|
123
|
+
// waterx_rule::collect_single_with_proof re-derives the snapshot root from
|
|
124
|
+
// this leaf + its proof, verifies the enclave signature over that root, and
|
|
125
|
+
// feeds the price. If the ticker's aggregator does not (yet) weight
|
|
126
|
+
// `WaterxRule`, the contribution is silently dropped on-chain — feeding
|
|
127
|
+
// ahead of the weight migration is safe. See WaterxRule's module header for
|
|
128
|
+
// the abort-vs-abstain split.
|
|
129
|
+
feedWaterxRuleWithProof(tx, host, collector, args.waterxLeaf);
|
|
130
|
+
fed = true;
|
|
131
|
+
}
|
|
132
|
+
else if (args.waterxEnvelope !== undefined) {
|
|
133
|
+
// Fallback shape: collect_batch_latest re-verifies the WHOLE batch
|
|
134
|
+
// signature (every item rebuilt in-PTB) and feeds this collector's symbol
|
|
135
|
+
// out of it. Only reached against a quote-center with no leaf route.
|
|
127
136
|
feedWaterxRule(tx, host, collector, args.waterxEnvelope);
|
|
128
137
|
fed = true;
|
|
129
138
|
}
|
|
@@ -216,11 +225,11 @@ export async function refreshOraclePrices(tx, host, tickers, opts = {}) {
|
|
|
216
225
|
if (tickers.length === 0)
|
|
217
226
|
return;
|
|
218
227
|
// Dedupe the caller's list (order-preserving): a repeated ticker would
|
|
219
|
-
// otherwise aggregate TWICE in this one PTB — wasted gas for every rule,
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
228
|
+
// otherwise aggregate TWICE in this one PTB — wasted gas for every rule, and
|
|
229
|
+
// under waterx the second collect would be dead weight on top of that: the
|
|
230
|
+
// on-chain per-symbol replay guard (F-014) sees its own predecessor's
|
|
231
|
+
// high-water mark from earlier in this same transaction and abstains, so the
|
|
232
|
+
// repeat pays full verification cost to contribute nothing.
|
|
224
233
|
tickers = [...new Set(tickers)];
|
|
225
234
|
// price_info_object lookup for every ticker with a pyth_rule.feeds entry —
|
|
226
235
|
// needed by aggregateTicker's (unchanged) Pyth feed step below regardless of
|
|
@@ -291,10 +300,12 @@ export async function refreshOraclePrices(tx, host, tickers, opts = {}) {
|
|
|
291
300
|
// leg needs per-ticker data from its update leg must decide its carry here
|
|
292
301
|
// — falling through silently would starve its weighted tickers on-chain.
|
|
293
302
|
const lazerUpdateByTicker = new Map();
|
|
294
|
-
// Signed
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
//
|
|
303
|
+
// Signed waterx data per served ticker — a per-symbol Merkle leaf normally, a
|
|
304
|
+
// shared batch envelope on the fallback shape. Unlike Lazer's shared PTB
|
|
305
|
+
// handle, waterx's verify+feed is bundled into the per-ticker collect call, so
|
|
306
|
+
// its `buildUpdateCalls` emits nothing and the signed data is carried straight
|
|
307
|
+
// from the group's fetched data.
|
|
308
|
+
const waterxLeafByTicker = new Map();
|
|
298
309
|
const waterxEnvelopeByTicker = new Map();
|
|
299
310
|
for (const [i, group] of groups.entries()) {
|
|
300
311
|
const data = dataByGroup[i] ?? null;
|
|
@@ -318,12 +329,45 @@ export async function refreshOraclePrices(tx, host, tickers, opts = {}) {
|
|
|
318
329
|
break;
|
|
319
330
|
case "waterx_rule": {
|
|
320
331
|
// waterx_rule emits no shared handle (verify+feed is bundled into the
|
|
321
|
-
// per-ticker
|
|
322
|
-
//
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
332
|
+
// per-ticker collect call), so the signed data is carried straight from
|
|
333
|
+
// this group's fetched data to the feed leg below. Leaves are per-symbol
|
|
334
|
+
// and indexed BY symbol — never fanned out across the group like the
|
|
335
|
+
// envelope, since each leaf only verifies for its own symbol
|
|
336
|
+
// (`ECollectorSymbolMismatch`). A leaf for a symbol outside this group is
|
|
337
|
+
// dropped rather than carried: the feed leg is keyed by ticker anyway.
|
|
338
|
+
const leaves = waterxLeavesOf(data);
|
|
339
|
+
// `length > 0` matters: `{ leaves: [] }` is shape-valid (`[].every(...)`
|
|
340
|
+
// is `true`), so a bare truthiness test would TAKE this branch, carry
|
|
341
|
+
// nothing, and `break` past the envelope branch below.
|
|
342
|
+
if (leaves && leaves.length > 0) {
|
|
343
|
+
const served = new Set(group.tickers);
|
|
344
|
+
for (const leaf of leaves) {
|
|
345
|
+
if (served.has(leaf.symbol))
|
|
346
|
+
waterxLeafByTicker.set(leaf.symbol, leaf);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
else {
|
|
350
|
+
const envelope = waterxEnvelopeOf(data);
|
|
351
|
+
if (envelope) {
|
|
352
|
+
for (const ticker of group.tickers)
|
|
353
|
+
waterxEnvelopeByTicker.set(ticker, envelope);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
// Fail the BUILD, not the chain. Both suppliers of `data` already
|
|
357
|
+
// guarantee full coverage — the live fetch through `assertCoverage`, a
|
|
358
|
+
// cached payload through `narrowUpdateData` (which returns `null`, i.e.
|
|
359
|
+
// "miss → live fetch", rather than a partial) — so reaching here with a
|
|
360
|
+
// ticker uncarried means one of those invariants broke. Left alone it
|
|
361
|
+
// emits a collector with no waterx leg, which surfaces MUCH later as an
|
|
362
|
+
// opaque on-chain `EMissingPriceSource` (or, if the rule is unweighted
|
|
363
|
+
// for that ticker, as a silently thinner weighted set). This names the
|
|
364
|
+
// tickers instead.
|
|
365
|
+
const uncarried = group.tickers.filter((t) => !waterxLeafByTicker.has(t) && !waterxEnvelopeByTicker.has(t));
|
|
366
|
+
if (uncarried.length > 0) {
|
|
367
|
+
throw new Error(`waterx_rule update data carries no signed price for ticker(s): ${uncarried.join(", ")}. ` +
|
|
368
|
+
"Expected one signed leaf per ticker (or a batch envelope covering all of " +
|
|
369
|
+
"them) — a payload that serves none of a group's tickers must be reported " +
|
|
370
|
+
"as a miss, not fed.");
|
|
327
371
|
}
|
|
328
372
|
break;
|
|
329
373
|
}
|
|
@@ -339,6 +383,7 @@ export async function refreshOraclePrices(tx, host, tickers, opts = {}) {
|
|
|
339
383
|
ticker,
|
|
340
384
|
priceInfoObjectId: priceInfoByTicker.get(ticker),
|
|
341
385
|
lazerUpdate: lazerUpdateByTicker.get(ticker),
|
|
386
|
+
waterxLeaf: waterxLeafByTicker.get(ticker),
|
|
342
387
|
waterxEnvelope: waterxEnvelopeByTicker.get(ticker),
|
|
343
388
|
});
|
|
344
389
|
}
|
|
@@ -96,7 +96,8 @@ export interface WaterxRuleFeedEntry {
|
|
|
96
96
|
/**
|
|
97
97
|
* `waterx_rule` deployment entry — the first-party Nautilus-TEE oracle rule.
|
|
98
98
|
* Read by `WaterxRule` (`rules/waterx-rule.ts`): `feeds` for ticker support,
|
|
99
|
-
* `config`/`enclave_config`/`enclave` for the
|
|
99
|
+
* `config`/`enclave_config`/`enclave` for the collect call
|
|
100
|
+
* (`collect_single_with_proof`, or `collect_batch_latest` on the fallback shape),
|
|
100
101
|
* `published_at` for the package address. The off-chain signed price is pulled
|
|
101
102
|
* from the quote-center (endpoint from the rule-owned `WATERX_INFRA` table in
|
|
102
103
|
* `rules/waterx-rule.ts`), not this JSON.
|
|
@@ -201,7 +202,7 @@ export interface WaterxAccessConfig {
|
|
|
201
202
|
* Quote-center base URL override (`waterxEndpoint` create option). A base
|
|
202
203
|
* PATH is preserved — the rule appends via `joinEndpointPath`, so
|
|
203
204
|
* `https://app.example/api/quote-center` resolves to
|
|
204
|
-
* `…/api/quote-center/v1/quotes/
|
|
205
|
+
* `…/api/quote-center/v1/quotes/leaves` and a proxy route is not rewritten
|
|
205
206
|
* away. A trailing slash is trimmed.
|
|
206
207
|
*/
|
|
207
208
|
endpoint?: string;
|
|
@@ -29,8 +29,8 @@ export { PythCoreRule } from "./rules/pyth-core-rule.ts";
|
|
|
29
29
|
export type { PythCoreUpdatePayload } from "./rules/pyth-core-rule.ts";
|
|
30
30
|
export { PythLazerRule, LazerApiKeyMissingError } from "./rules/pyth-lazer-rule.ts";
|
|
31
31
|
export type { PythLazerUpdatePayload } from "./rules/pyth-lazer-rule.ts";
|
|
32
|
-
export { WaterxRule, parseSignedEnvelope, BATCH_PRICE_INTENT, WATERX_INFRA, waterxQuoteCenterEndpoint, waterxEnvelopeOf, } from "./rules/waterx-rule.ts";
|
|
33
|
-
export type { WaterxUpdatePayload, WaterxSignedEnvelope, WaterxBatchItem, } from "./rules/waterx-rule.ts";
|
|
32
|
+
export { WaterxRule, parseSignedEnvelope, parseSignedLeaves, BATCH_PRICE_INTENT, MERKLE_ROOT_INTENT, WATERX_INFRA, waterxQuoteCenterEndpoint, waterxLeavesOf, waterxEnvelopeOf, } from "./rules/waterx-rule.ts";
|
|
33
|
+
export type { WaterxUpdatePayload, WaterxLeafPayload, WaterxEnvelopePayload, WaterxSignedEnvelope, WaterxSignedLeaf, WaterxBatchItem, } from "./rules/waterx-rule.ts";
|
|
34
34
|
export { OracleSourceNotImplementedError, resolveOracleRule } from "./rule-registry.ts";
|
|
35
35
|
export { aggregateTicker, aggregateTickerWithPyth, aggregateTickerWithConstant, refreshOraclePrices, } from "./aggregate.ts";
|
|
36
36
|
export { openPythSponsorFund, reimbursePythSponsor } from "./rules/sponsor.ts";
|
package/dist/src/oracle/index.js
CHANGED
|
@@ -60,12 +60,14 @@ export { PythCoreRule } from "./rules/pyth-core-rule.js";
|
|
|
60
60
|
export { PythLazerRule, LazerApiKeyMissingError } from "./rules/pyth-lazer-rule.js";
|
|
61
61
|
// `WATERX_INFRA` / `waterxQuoteCenterEndpoint` are the source's own infra table +
|
|
62
62
|
// read-plane accessor (mirrors `pythCoreHermesEndpoint`).
|
|
63
|
-
// WaterX quote-center rule (first-party ed25519 signed
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
63
|
+
// WaterX quote-center rule (first-party ed25519 signed prices; the `feedWaterxRule*`
|
|
64
|
+
// legs stay internal to `aggregate.ts`). Both wire shapes are exported because a
|
|
65
|
+
// BE prefetch cache holds whichever one its quote-center serves: per-symbol
|
|
66
|
+
// Merkle leaves (default) or one indivisible batch envelope (fallback).
|
|
67
|
+
export { WaterxRule, parseSignedEnvelope, parseSignedLeaves, BATCH_PRICE_INTENT, MERKLE_ROOT_INTENT, WATERX_INFRA, waterxQuoteCenterEndpoint,
|
|
68
|
+
// Rule-owned payload accessors (kind-check + unwrap in one place) — never
|
|
69
|
+
// hand-cast the payload shape, and never assume which variant it is.
|
|
70
|
+
waterxLeavesOf, waterxEnvelopeOf, } from "./rules/waterx-rule.js";
|
|
69
71
|
// `resolveOracleRule` is the ONE source→rule registry — exported so external
|
|
70
72
|
// consumers (e.g. a BE prefetch cache that keys per source and needs each
|
|
71
73
|
// source's `supportedTickers`/`fetchUpdateData`) resolve through it instead of
|
|
@@ -83,14 +83,22 @@ export declare function assertRuleUpdateData<T>(data: RuleUpdateData, kind: Pric
|
|
|
83
83
|
* return when its collector-feed leg needs a value produced by the update leg
|
|
84
84
|
* *within the same PTB*. Pyth Core needs none (its feed leg reads the shared
|
|
85
85
|
* `PriceInfoObject` the update leg refreshed), so it returns `void`. The Lazer
|
|
86
|
-
* rule returns the verified
|
|
86
|
+
* rule returns the verified-update result of its network's verify entry — one
|
|
87
87
|
* signature verification covers every feed in the payload, and
|
|
88
88
|
* `pyth_lazer_rule::feed` takes it by reference per ticker (see
|
|
89
89
|
* `aggregateTicker`'s `lazerUpdate` arg).
|
|
90
90
|
*/
|
|
91
91
|
export type RuleUpdateHandle = {
|
|
92
92
|
readonly kind: "pyth_lazer_rule";
|
|
93
|
-
/**
|
|
93
|
+
/**
|
|
94
|
+
* Opaque result of this network's `LAZER_INFRA.verify_entry` in this PTB,
|
|
95
|
+
* passed straight to `pyth_lazer_rule::feed`. The Move type is
|
|
96
|
+
* network-dependent and never named here: mainnet's
|
|
97
|
+
* `pyth_lazer::parse_and_verify_le_ecdsa_update_v2` yields
|
|
98
|
+
* `pyth_lazer::update_v2::Update`, testnet's v1
|
|
99
|
+
* `…_le_ecdsa_update` yields `pyth_lazer::update::Update`, and each
|
|
100
|
+
* network's `pyth_lazer_rule` is published bound to the matching one.
|
|
101
|
+
*/
|
|
94
102
|
readonly update: TransactionArgument;
|
|
95
103
|
};
|
|
96
104
|
/**
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
* appends per lazer-routed ticker. Fetches one `leEcdsa` payload for all
|
|
5
5
|
* requested integer feed ids from the Lazer HTTP API (Bearer-authenticated
|
|
6
6
|
* via the `pythApiKey` create option), verifies it ONCE on-chain via
|
|
7
|
-
* `pyth_lazer
|
|
8
|
-
* `Update` PTB value back through a `RuleUpdateHandle` for the feed
|
|
7
|
+
* `pyth_lazer`'s verify entry for that network (see `LAZER_INFRA`), and hands the
|
|
8
|
+
* resulting `Update` PTB value back through a `RuleUpdateHandle` for the feed
|
|
9
|
+
* calls.
|
|
9
10
|
*/
|
|
10
11
|
import type { Transaction, TransactionArgument } from "@mysten/sui/transactions";
|
|
11
12
|
import type { Network } from "../../constants.ts";
|
|
@@ -21,16 +22,33 @@ import { type PriceUpdateRule } from "../price-update-rule.ts";
|
|
|
21
22
|
* `POST /v1/latest_price` (Bearer-authenticated). The service is
|
|
22
23
|
* network-agnostic (one signed payload verifies on any chain that trusts the
|
|
23
24
|
* Lazer signers), so both networks share the production host.
|
|
24
|
-
* - `verifier_package` — the Sui package carrying
|
|
25
|
-
* `
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
25
|
+
* - `verifier_package` / `verify_entry` — the Sui package carrying the verify
|
|
26
|
+
* call, and which entry to call. These track what `pyth_lazer_rule` binds on
|
|
27
|
+
* that network, so they move together:
|
|
28
|
+
* - **mainnet** — the v2 package `0xefbfd064…` and the **v2** entry. The rule
|
|
29
|
+
* was republished v2-bound after a 2026-08-05 mainnet probe: the ORIGINAL
|
|
30
|
+
* package `0x7b502c…` now aborts `EDifferentVersion` (`state::current_cap`)
|
|
31
|
+
* for any payload — the shared `State` has been migrated past that code —
|
|
32
|
+
* and its v1 entry aborts `EInvalidChannel` on `fixed_rate@1000ms`, the only
|
|
33
|
+
* channel WaterX's Pyth Pro grant permits.
|
|
34
|
+
* - **testnet** — still the original v1 publish, which has no `update_v2`
|
|
35
|
+
* module at all, so the v1 entry is the only one that exists there.
|
|
36
|
+
* Both entries take `(state, clock, bytes)` and accept the same `leEcdsa`
|
|
37
|
+
* payload. Values mirror the contract repo's `pyth_lazer_rule/Move.toml`
|
|
38
|
+
* published-at pins.
|
|
29
39
|
*/
|
|
30
40
|
export declare const LAZER_INFRA: Record<Network, {
|
|
31
41
|
endpoint: string;
|
|
32
42
|
verifier_package: string;
|
|
43
|
+
verify_entry: LazerVerifyEntry;
|
|
44
|
+
channel: string;
|
|
33
45
|
}>;
|
|
46
|
+
/**
|
|
47
|
+
* The `pyth_lazer` verify entry a network's deployed rule consumes. `_v2`
|
|
48
|
+
* returns `update_v2::Update`; the v1 entry returns `update::Update`, and the
|
|
49
|
+
* two are NOT interchangeable — the rule's `feed` takes one concrete type.
|
|
50
|
+
*/
|
|
51
|
+
export type LazerVerifyEntry = "parse_and_verify_le_ecdsa_update" | "parse_and_verify_le_ecdsa_update_v2";
|
|
34
52
|
/** `pyth_lazer_rule`'s narrowed `RuleUpdateData.payload` shape. */
|
|
35
53
|
export interface PythLazerUpdatePayload {
|
|
36
54
|
/** One signed `leEcdsa` message carrying every requested feed. */
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
* appends per lazer-routed ticker. Fetches one `leEcdsa` payload for all
|
|
5
5
|
* requested integer feed ids from the Lazer HTTP API (Bearer-authenticated
|
|
6
6
|
* via the `pythApiKey` create option), verifies it ONCE on-chain via
|
|
7
|
-
* `pyth_lazer
|
|
8
|
-
* `Update` PTB value back through a `RuleUpdateHandle` for the feed
|
|
7
|
+
* `pyth_lazer`'s verify entry for that network (see `LAZER_INFRA`), and hands the
|
|
8
|
+
* resulting `Update` PTB value back through a `RuleUpdateHandle` for the feed
|
|
9
|
+
* calls.
|
|
9
10
|
*/
|
|
10
11
|
import { fromHex } from "@mysten/bcs";
|
|
11
12
|
import { ownEntry } from "../../utils/record.js";
|
|
@@ -21,20 +22,33 @@ import { fetchWithPolicy, joinEndpointPath, rethrowExhaustedFetch } from "../upd
|
|
|
21
22
|
* `POST /v1/latest_price` (Bearer-authenticated). The service is
|
|
22
23
|
* network-agnostic (one signed payload verifies on any chain that trusts the
|
|
23
24
|
* Lazer signers), so both networks share the production host.
|
|
24
|
-
* - `verifier_package` — the Sui package carrying
|
|
25
|
-
* `
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
25
|
+
* - `verifier_package` / `verify_entry` — the Sui package carrying the verify
|
|
26
|
+
* call, and which entry to call. These track what `pyth_lazer_rule` binds on
|
|
27
|
+
* that network, so they move together:
|
|
28
|
+
* - **mainnet** — the v2 package `0xefbfd064…` and the **v2** entry. The rule
|
|
29
|
+
* was republished v2-bound after a 2026-08-05 mainnet probe: the ORIGINAL
|
|
30
|
+
* package `0x7b502c…` now aborts `EDifferentVersion` (`state::current_cap`)
|
|
31
|
+
* for any payload — the shared `State` has been migrated past that code —
|
|
32
|
+
* and its v1 entry aborts `EInvalidChannel` on `fixed_rate@1000ms`, the only
|
|
33
|
+
* channel WaterX's Pyth Pro grant permits.
|
|
34
|
+
* - **testnet** — still the original v1 publish, which has no `update_v2`
|
|
35
|
+
* module at all, so the v1 entry is the only one that exists there.
|
|
36
|
+
* Both entries take `(state, clock, bytes)` and accept the same `leEcdsa`
|
|
37
|
+
* payload. Values mirror the contract repo's `pyth_lazer_rule/Move.toml`
|
|
38
|
+
* published-at pins.
|
|
29
39
|
*/
|
|
30
40
|
export const LAZER_INFRA = {
|
|
31
41
|
MAINNET: {
|
|
32
42
|
endpoint: "https://pyth-lazer.dourolabs.app",
|
|
33
43
|
verifier_package: "0xefbfd064480777699fd9c557a5804d72ace7bc82661fdc8d1f1a44ea6d92ee10",
|
|
44
|
+
verify_entry: "parse_and_verify_le_ecdsa_update_v2",
|
|
45
|
+
channel: "fixed_rate@1000ms",
|
|
34
46
|
},
|
|
35
47
|
TESTNET: {
|
|
36
48
|
endpoint: "https://pyth-lazer.dourolabs.app",
|
|
37
49
|
verifier_package: "0xf5bd2141967507050a91b58de3d95e77c432cd90d1799ee46effc27430a68c21",
|
|
50
|
+
verify_entry: "parse_and_verify_le_ecdsa_update",
|
|
51
|
+
channel: "fixed_rate@200ms",
|
|
38
52
|
},
|
|
39
53
|
};
|
|
40
54
|
/**
|
|
@@ -52,9 +66,15 @@ export const LAZER_INFRA = {
|
|
|
52
66
|
* and every xStock — are `min_channel: fixed_rate@200ms` (Lazer symbol
|
|
53
67
|
* registry, verified 2026-07-22: the same 29-feed batch 400s at
|
|
54
68
|
* `real_time`/`50ms` and serves 200 with the leEcdsa blob at `200ms`).
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
69
|
+
* The channel is therefore per-network (`LAZER_INFRA[network].channel`), and
|
|
70
|
+
* it is bounded from BOTH sides — by what the feeds publish and by what the
|
|
71
|
+
* grant allows:
|
|
72
|
+
* - **mainnet: `fixed_rate@1000ms`.** WaterX's Pyth Pro grant no longer
|
|
73
|
+
* permits anything faster ("Channel fixed_rate@200ms violates rate limit.
|
|
74
|
+
* Minimum allowed channel is 1000ms", measured 2026-08-05), and the
|
|
75
|
+
* mainnet rule is v2-bound, so it accepts that channel.
|
|
76
|
+
* - **testnet: `fixed_rate@200ms`.** Its rule is still v1-bound, and the v1
|
|
77
|
+
* on-chain `channel::from_u8` aborts on the 1000ms channel.
|
|
58
78
|
* - `formats: leEcdsa` + `jsonBinaryEncoding: hex` — the Sui verifier takes
|
|
59
79
|
* the `leEcdsa` framing; hex matches `fromHex` below.
|
|
60
80
|
*/
|
|
@@ -62,7 +82,6 @@ const LAZER_LATEST_PRICE_REQUEST = {
|
|
|
62
82
|
properties: ["price", "exponent", "confidence"],
|
|
63
83
|
formats: ["leEcdsa"],
|
|
64
84
|
jsonBinaryEncoding: "hex",
|
|
65
|
-
channel: "fixed_rate@200ms",
|
|
66
85
|
};
|
|
67
86
|
/**
|
|
68
87
|
* Shape check ONLY — the `kind` discriminant is checked separately by the
|
|
@@ -104,7 +123,7 @@ function requireLazerPackage(host) {
|
|
|
104
123
|
* retry/timeout/Bearer policy as `fetchPriceFeedsUpdateData`, unified so
|
|
105
124
|
* both oracle sources fail the same way under upstream degradation.
|
|
106
125
|
*/
|
|
107
|
-
async function fetchLazerSignedUpdate(endpoint, apiKey, feedIds, fetchOpts) {
|
|
126
|
+
async function fetchLazerSignedUpdate(endpoint, channel, apiKey, feedIds, fetchOpts) {
|
|
108
127
|
// joinEndpointPath preserves any base path on the endpoint — the same
|
|
109
128
|
// leading-slash `new URL` footgun that 404'd every feed on the Pyth Pro
|
|
110
129
|
// Hermes endpoint (see update-fetch.ts). Defensive here: the default
|
|
@@ -115,7 +134,7 @@ async function fetchLazerSignedUpdate(endpoint, apiKey, feedIds, fetchOpts) {
|
|
|
115
134
|
res = await fetchWithPolicy(url.toString(), {
|
|
116
135
|
method: "POST",
|
|
117
136
|
headers: { "Content-Type": "application/json" },
|
|
118
|
-
body: JSON.stringify({ priceFeedIds: feedIds, ...LAZER_LATEST_PRICE_REQUEST }),
|
|
137
|
+
body: JSON.stringify({ priceFeedIds: feedIds, ...LAZER_LATEST_PRICE_REQUEST, channel }),
|
|
119
138
|
}, { apiKey, ...fetchOpts });
|
|
120
139
|
}
|
|
121
140
|
catch (err) {
|
|
@@ -175,13 +194,13 @@ export const PythLazerRule = {
|
|
|
175
194
|
if (!apiKey) {
|
|
176
195
|
throw new LazerApiKeyMissingError();
|
|
177
196
|
}
|
|
178
|
-
const update = await fetchLazerSignedUpdate(LAZER_INFRA[host.network].endpoint, apiKey, feedIds, host.pyth.fetch);
|
|
197
|
+
const update = await fetchLazerSignedUpdate(LAZER_INFRA[host.network].endpoint, LAZER_INFRA[host.network].channel, apiKey, feedIds, host.pyth.fetch);
|
|
179
198
|
return { kind: "pyth_lazer_rule", payload: { update, feedIds } };
|
|
180
199
|
},
|
|
181
200
|
/**
|
|
182
201
|
* A Lazer payload is ONE signed `leEcdsa` message covering every feed it was
|
|
183
202
|
* fetched for — verification is a single flat signature check over the whole
|
|
184
|
-
* message (`parse_and_verify_le_ecdsa_update
|
|
203
|
+
* message (one `parse_and_verify_le_ecdsa_update*` call, no per-feed cost), so the
|
|
185
204
|
* payload is indivisible: it can only be served whole. Returns the whole
|
|
186
205
|
* payload iff every requested ticker's integer feed id is packed in THIS
|
|
187
206
|
* payload's `feedIds`; any coverage gap (unlisted ticker, or a feed this
|
|
@@ -200,19 +219,24 @@ export const PythLazerRule = {
|
|
|
200
219
|
return { kind: "pyth_lazer_rule", payload };
|
|
201
220
|
},
|
|
202
221
|
/**
|
|
203
|
-
* Appends the single `parse_and_verify_le_ecdsa_update(state, clock, bytes)`
|
|
222
|
+
* Appends the single `parse_and_verify_le_ecdsa_update*(state, clock, bytes)`
|
|
204
223
|
* call — one secp256k1 signature check covering every feed in the payload —
|
|
205
224
|
* and returns its `Update` result as the handle the per-ticker feed leg
|
|
206
225
|
* consumes. `opts.cache` / `opts.feeSource` are Pyth-Core-specific and
|
|
207
226
|
* ignored (Lazer verification charges no update fee).
|
|
227
|
+
*
|
|
228
|
+
* The entry name comes from `LAZER_INFRA[network].verify_entry`: mainnet's
|
|
229
|
+
* rule binds `update_v2`, testnet's is still the v1 publish. Both take
|
|
230
|
+
* `(state, clock, bytes)` and accept the same `leEcdsa` payload.
|
|
208
231
|
*/
|
|
209
232
|
buildUpdateCalls(tx, host, data, _opts) {
|
|
210
233
|
const payload = assertRuleUpdateData(data, "pyth_lazer_rule", isPythLazerUpdatePayloadShape, "{ update: Uint8Array; feedIds: number[] }");
|
|
211
234
|
if (!payload)
|
|
212
235
|
return undefined;
|
|
213
236
|
const lazer = requireLazerPackage(host);
|
|
237
|
+
const infra = LAZER_INFRA[host.network];
|
|
214
238
|
const [update] = tx.moveCall({
|
|
215
|
-
target: `${
|
|
239
|
+
target: `${infra.verifier_package}::pyth_lazer::${infra.verify_entry}`,
|
|
216
240
|
arguments: [tx.object(lazer.state), tx.object.clock(), tx.pure.vector("u8", payload.update)],
|
|
217
241
|
});
|
|
218
242
|
return { kind: "pyth_lazer_rule", update };
|