@waterx/sdk 4.3.2 → 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 +4 -1
- package/dist/cjs/src/oracle/aggregate.js +70 -26
- 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/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 +4 -1
- package/dist/src/oracle/aggregate.js +71 -27
- 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/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
|
@@ -1,36 +1,60 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `WaterxRule` — `PriceUpdateRule` for the first-party WaterX quote-center
|
|
3
|
-
* (Nautilus-TEE, ed25519), plus
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* API bundles the two). So `buildUpdateCalls` emits nothing and the signed
|
|
11
|
-
* envelope is handed straight to the per-ticker feed leg.
|
|
3
|
+
* (Nautilus-TEE, ed25519), plus the collector-feed legs `aggregateTicker`
|
|
4
|
+
* appends per waterx-routed ticker. Endpoint comes from `host.waterx` (the
|
|
5
|
+
* `waterxEndpoint`/`waterxFetch` create options), else this source's own
|
|
6
|
+
* `WATERX_INFRA`. Unlike Pyth Lazer, whose verify is a single shared PTB step,
|
|
7
|
+
* the Move API bundles verify AND feed into ONE call per collector, so
|
|
8
|
+
* `buildUpdateCalls` emits nothing and the signed data is handed straight to
|
|
9
|
+
* the per-ticker feed leg.
|
|
12
10
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
11
|
+
* TWO wire shapes carry the same prices, and this rule prefers the first:
|
|
12
|
+
*
|
|
13
|
+
* 1. **Merkle leaves** (default) — `GET /v1/quotes/leaves?symbols=…` returns one
|
|
14
|
+
* `SignedLeaf` per symbol: the price fields, a Merkle `proof`, and the
|
|
15
|
+
* enclave's signature over the snapshot ROOT (`MERKLE_ROOT_INTENT`). Fed via
|
|
16
|
+
* {@link feedWaterxRuleWithProof} → `waterx_rule::collect_single_with_proof`,
|
|
17
|
+
* which re-derives the root from the leaf + proof. One symbol costs ONE
|
|
18
|
+
* `new_batch_item` plus ~log2(n) 32-byte proof hashes.
|
|
19
|
+
* 2. **Batch envelope** (fallback) — `GET /v1/quotes/update?symbols=…` returns
|
|
20
|
+
* ONE signature over the whole item vector (`BATCH_PRICE_INTENT`). It is
|
|
21
|
+
* indivisible: {@link feedWaterxRule} must rebuild EVERY item in-PTB for
|
|
22
|
+
* `waterx_rule::collect_batch_latest` to re-verify, even to use one symbol's
|
|
23
|
+
* price. With the 29-feed mainnet registry that is 58 extra moveCalls and
|
|
24
|
+
* ~320 extra pure inputs on every trade, which is why it is no longer the
|
|
25
|
+
* default. Used only when the quote-center has no leaf route yet (404),
|
|
26
|
+
* and by callers that still push whole batches.
|
|
27
|
+
*
|
|
28
|
+
* Both collect entries are the dual-rule path: they feed `collector.symbol()`
|
|
29
|
+
* WITHOUT aggregating, so a waterx-routed ticker composes onto the same
|
|
30
|
+
* collector as Pyth/Supra (compose-then-aggregate). Their abort-vs-abstain
|
|
31
|
+
* disposition is identical: a config/integrity mismatch, a bad signature or a
|
|
32
|
+
* signed timestamp AHEAD of the on-chain `Clock` ABORTS; a freshness miss
|
|
33
|
+
* ABSTAINS so the other weighted rules cover a lagging TEE, and so does a
|
|
34
|
+
* REPLAYED signed timestamp (the per-symbol high-water mark of audit F-014 —
|
|
35
|
+
* already-recorded means the chain holds a price at least this fresh, so two
|
|
36
|
+
* PTBs carrying the same snapshot for the same symbol no longer kill each
|
|
37
|
+
* other; only the single-rule `feed_*` entries abort on a replay).
|
|
24
38
|
*/
|
|
25
39
|
import { fromHex } from "@mysten/bcs";
|
|
26
|
-
import { collectBatchLatest, newBatchItem, newBatchPayload, pushBatchItem, } from "../../generated/waterx_rule/waterx_rule.js";
|
|
40
|
+
import { collectBatchLatest, collectSingleWithProof, newBatchItem, newBatchPayload, pushBatchItem, } from "../../generated/waterx_rule/waterx_rule.js";
|
|
27
41
|
import { ownEntry } from "../../utils/record.js";
|
|
28
42
|
import { assertRuleUpdateData, } from "../price-update-rule.js";
|
|
29
43
|
import { FetchPolicyError, fetchWithPolicy, joinEndpointPath, } from "../update-fetch.js";
|
|
30
|
-
/**
|
|
31
|
-
* consumers can mirror the rule's own envelope intent check (a
|
|
32
|
-
* endpoint must be rejected by reads exactly as tx-builds reject it). */
|
|
44
|
+
/** Intent the quote-center signs a whole BATCH payload under — exported so
|
|
45
|
+
* read-plane consumers can mirror the rule's own envelope intent check (a
|
|
46
|
+
* mispointed endpoint must be rejected by reads exactly as tx-builds reject it). */
|
|
33
47
|
export const BATCH_PRICE_INTENT = 1;
|
|
48
|
+
/**
|
|
49
|
+
* Intent the quote-center signs a snapshot's Merkle ROOT under
|
|
50
|
+
* (`waterx_rule::MERKLE_ROOT_INTENT`). Distinct from
|
|
51
|
+
* {@link BATCH_PRICE_INTENT} on purpose: the intent byte is the first field of
|
|
52
|
+
* the signed `IntentMessage`, so a batch signature can never be replayed as a
|
|
53
|
+
* root signature or vice versa. Leaves carry no `intent` field of their own —
|
|
54
|
+
* they are only ever submitted through `collect_single_with_proof`, which pins
|
|
55
|
+
* the intent on-chain — so this exists to name the scheme, not to gate a parse.
|
|
56
|
+
*/
|
|
57
|
+
export const MERKLE_ROOT_INTENT = 2;
|
|
34
58
|
/**
|
|
35
59
|
* WaterX quote-center external infra — owned by THIS source, by network.
|
|
36
60
|
* Mirrors `PYTH_CORE_INFRA` (oracle/pyth.ts) and `LAZER_INFRA`
|
|
@@ -58,12 +82,7 @@ export const WATERX_INFRA = {
|
|
|
58
82
|
export function waterxQuoteCenterEndpoint(network) {
|
|
59
83
|
return WATERX_INFRA[network].endpoint;
|
|
60
84
|
}
|
|
61
|
-
|
|
62
|
-
* Shape check ONLY — the `kind` discriminant is checked separately by the
|
|
63
|
-
* caller before this runs (mirrors the other rules' guard split), so a
|
|
64
|
-
* same-shaped payload from a different rule can never silently pass.
|
|
65
|
-
*/
|
|
66
|
-
function isWaterxUpdatePayloadShape(payload) {
|
|
85
|
+
function isWaterxEnvelopePayloadShape(payload) {
|
|
67
86
|
const env = payload?.envelope;
|
|
68
87
|
return (typeof env === "object" &&
|
|
69
88
|
env !== null &&
|
|
@@ -71,33 +90,156 @@ function isWaterxUpdatePayloadShape(payload) {
|
|
|
71
90
|
typeof env.timestamp_ms === "bigint" &&
|
|
72
91
|
Array.isArray(env.payload?.items));
|
|
73
92
|
}
|
|
93
|
+
function isWaterxLeafPayloadShape(payload) {
|
|
94
|
+
const leaves = payload?.leaves;
|
|
95
|
+
return Array.isArray(leaves) && leaves.every(isSignedLeafShape);
|
|
96
|
+
}
|
|
97
|
+
/** Every u64 field of a leaf — each one is signed, so each must be present and exact. */
|
|
98
|
+
const LEAF_U64_FIELDS = [
|
|
99
|
+
"price_timestamp_ms",
|
|
100
|
+
"price_n",
|
|
101
|
+
"price_scale",
|
|
102
|
+
"confidence_n",
|
|
103
|
+
"confidence_scale",
|
|
104
|
+
"max_source_deviation_bps",
|
|
105
|
+
"signed_timestamp_ms",
|
|
106
|
+
];
|
|
74
107
|
/**
|
|
75
|
-
*
|
|
76
|
-
*
|
|
108
|
+
* FULL structural check on a leaf, not just the fields the feed leg happens to
|
|
109
|
+
* touch first.
|
|
77
110
|
*
|
|
78
|
-
*
|
|
79
|
-
* the
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
* still exact (`BigInt(number)`); a value ABOVE it throws loudly here rather
|
|
86
|
-
* than silently corrupting the payload into an on-chain abort. `num_sources`
|
|
87
|
-
* (u8) and `intent` are coerced back to `number` — both are tiny.
|
|
111
|
+
* Every field here is either part of the BCS bytes the enclave signed (so a
|
|
112
|
+
* missing one means the rebuilt item cannot match) or the signature/proof
|
|
113
|
+
* material itself. A partial guard let a 200 that omitted, say, `ticker` pass as
|
|
114
|
+
* a valid leaf and fail LATER, mid-PTB-build, as `Parameter ticker is required`
|
|
115
|
+
* — after `assertCoverage` had already declared the response good, and while the
|
|
116
|
+
* caller's `tx` was already being mutated. `parseSignedLeaves` promises to reject
|
|
117
|
+
* a malformed leaf before any PTB is touched; this is what makes that true.
|
|
88
118
|
*/
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
119
|
+
/** Hex characters only. Length is checked separately — see {@link isHexOfBytes}. */
|
|
120
|
+
const HEX_ONLY = /^[0-9a-fA-F]*$/;
|
|
121
|
+
/**
|
|
122
|
+
* `true` iff `hex` (± `0x`) is exactly `bytes` bytes of hex.
|
|
123
|
+
*
|
|
124
|
+
* A static pattern plus an explicit length compare, deliberately not a computed
|
|
125
|
+
* `new RegExp(`…{${bytes * 2}}`)`: that recompiles on every call and semgrep
|
|
126
|
+
* blocks it as `detect-non-literal-regexp`.
|
|
127
|
+
*/
|
|
128
|
+
function isHexOfBytes(hex, bytes) {
|
|
129
|
+
if (typeof hex !== "string")
|
|
130
|
+
return false;
|
|
131
|
+
const body = hex.startsWith("0x") ? hex.slice(2) : hex;
|
|
132
|
+
return body.length === bytes * 2 && HEX_ONLY.test(body);
|
|
133
|
+
}
|
|
134
|
+
function isSignedLeafShape(leaf) {
|
|
135
|
+
const l = leaf;
|
|
136
|
+
if (typeof l !== "object" || l === null)
|
|
137
|
+
return false;
|
|
138
|
+
if (typeof l.symbol !== "string" || l.symbol === "")
|
|
139
|
+
return false;
|
|
140
|
+
if (typeof l.ticker !== "string" || l.ticker === "")
|
|
141
|
+
return false;
|
|
142
|
+
if (typeof l.method !== "string" || l.method === "")
|
|
143
|
+
return false;
|
|
144
|
+
// `num_sources` is a u8, and this guard runs at TWO stages: on the freshly
|
|
145
|
+
// revived object (where every JSON integer, including this one, is a bigint)
|
|
146
|
+
// and again on a normalized payload from `narrowUpdateData` / a consumer cache
|
|
147
|
+
// (where `parseSignedLeaves` has coerced it to a number). Both are valid here;
|
|
148
|
+
// only the domain matters.
|
|
149
|
+
const n = l.num_sources;
|
|
150
|
+
if (typeof n === "bigint") {
|
|
151
|
+
if (n < 0n || n > 255n)
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
else if (typeof n === "number") {
|
|
155
|
+
if (!Number.isInteger(n) || n < 0 || n > 255)
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
// `sources` is a vector<u64>: exact bigints, like every other signed integer.
|
|
162
|
+
if (!Array.isArray(l.sources) || l.sources.length === 0)
|
|
163
|
+
return false;
|
|
164
|
+
if (l.sources.some((s) => typeof s !== "bigint" || s < 0n))
|
|
165
|
+
return false;
|
|
166
|
+
for (const field of LEAF_U64_FIELDS) {
|
|
167
|
+
const v = l[field];
|
|
168
|
+
if (typeof v !== "bigint" || v < 0n)
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
// ed25519 is always 64 bytes and the root is always a 32-byte keccak256; a
|
|
172
|
+
// wrong-length signature is an on-chain abort, so it is rejected here.
|
|
173
|
+
if (!isHexOfBytes(l.signature, 64))
|
|
174
|
+
return false;
|
|
175
|
+
if (!isHexOfBytes(l.root, 32))
|
|
176
|
+
return false;
|
|
177
|
+
if (!Array.isArray(l.proof) || l.proof.some((p) => typeof p !== "string"))
|
|
178
|
+
return false;
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Shape check ONLY — the `kind` discriminant is checked separately by the
|
|
183
|
+
* caller before this runs (mirrors the other rules' guard split), so a
|
|
184
|
+
* same-shaped payload from a different rule can never silently pass. Accepts
|
|
185
|
+
* either variant; the accessors below pick one out.
|
|
186
|
+
*/
|
|
187
|
+
function isWaterxUpdatePayloadShape(payload) {
|
|
188
|
+
return isWaterxLeafPayloadShape(payload) || isWaterxEnvelopePayloadShape(payload);
|
|
189
|
+
}
|
|
190
|
+
/** A JSON number token that is lexically an integer: no `.`, no `e`/`E`. */
|
|
191
|
+
const INTEGER_TOKEN = /^-?\d+$/;
|
|
192
|
+
/**
|
|
193
|
+
* `JSON.parse` with every integer decoded as an exact `bigint`.
|
|
194
|
+
*
|
|
195
|
+
* The signature is over `BCS(IntentMessage<…>)`, so every u64 the SDK rebuilds
|
|
196
|
+
* in-PTB must equal the enclave's byte-for-byte or the on-chain verify fails and
|
|
197
|
+
* ABORTS the whole trade PTB (a bad signature is not an abstain). A plain
|
|
198
|
+
* `JSON.parse` yields IEEE-754 doubles that lose precision above 2^53, so
|
|
199
|
+
* instead we recover each integer's exact source literal via the ES2023 reviver
|
|
200
|
+
* `context.source` (Node 21+ / modern browsers) and `BigInt()` it. On an older
|
|
201
|
+
* runtime that passes no `context`, a value within 2^53 is still exact
|
|
202
|
+
* (`BigInt(number)`); a value ABOVE it throws loudly here rather than silently
|
|
203
|
+
* corrupting the payload into an on-chain abort.
|
|
204
|
+
*
|
|
205
|
+
* Integrality is decided from the SOURCE TOKEN, never from the parsed value: a
|
|
206
|
+
* display float can be lexically `0.0` while `JSON.parse` hands back the number
|
|
207
|
+
* `0`, which `Number.isInteger` accepts — and `BigInt("0.0")` throws
|
|
208
|
+
* `SyntaxError`. The leaf endpoint really does emit that (Rust `f64` serializes
|
|
209
|
+
* a whole number as `0.0`), so keying off the value crashed every fetch that
|
|
210
|
+
* included, say, a `confidence: 0.0` leaf. Exponent tokens (`1e3`) throw the
|
|
211
|
+
* same way. Only `-?\d+` becomes a `bigint`; every other numeric token stays a
|
|
212
|
+
* number, which is right for the display-only `price` / `confidence` fields —
|
|
213
|
+
* they are not part of any signed byte string.
|
|
214
|
+
*/
|
|
215
|
+
function parseWithExactIntegers(text, what) {
|
|
216
|
+
return JSON.parse(text, (_key, value, context) => {
|
|
217
|
+
if (typeof value !== "number")
|
|
218
|
+
return value;
|
|
219
|
+
const token = context?.source;
|
|
220
|
+
if (token !== undefined) {
|
|
221
|
+
return INTEGER_TOKEN.test(token) ? BigInt(token) : value;
|
|
222
|
+
}
|
|
223
|
+
// No JSON source access on this runtime: fall back to the parsed value.
|
|
224
|
+
// A display float that happens to be whole (`0.0` → `0`) becomes a bigint
|
|
225
|
+
// here, which is harmless — no signed field is read off those two.
|
|
226
|
+
if (!Number.isInteger(value))
|
|
92
227
|
return value;
|
|
93
|
-
if (context?.source !== undefined)
|
|
94
|
-
return BigInt(context.source);
|
|
95
228
|
if (!Number.isSafeInteger(value)) {
|
|
96
|
-
throw new Error(
|
|
229
|
+
throw new Error(`waterx ${what} carries an integer above 2^53 and this runtime lacks JSON ` +
|
|
97
230
|
"source access — cannot preserve u64 precision for the signed payload");
|
|
98
231
|
}
|
|
99
232
|
return BigInt(value);
|
|
100
233
|
});
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Parse a quote-center `/v1/quotes/update` response body into a
|
|
237
|
+
* {@link WaterxSignedEnvelope} with the u64 fields decoded as `bigint`, exact
|
|
238
|
+
* (see {@link parseWithExactIntegers}). `num_sources` (u8) and `intent` are
|
|
239
|
+
* coerced back to `number` — both are tiny.
|
|
240
|
+
*/
|
|
241
|
+
export function parseSignedEnvelope(text) {
|
|
242
|
+
const raw = parseWithExactIntegers(text, "envelope");
|
|
101
243
|
if (typeof raw.signature !== "string" || !Array.isArray(raw.payload?.items)) {
|
|
102
244
|
throw new Error("WaterX quote-center returned a malformed signed envelope");
|
|
103
245
|
}
|
|
@@ -110,6 +252,47 @@ export function parseSignedEnvelope(text) {
|
|
|
110
252
|
},
|
|
111
253
|
};
|
|
112
254
|
}
|
|
255
|
+
/**
|
|
256
|
+
* A Merkle proof element must be a 32-byte keccak256 hash, and nothing else.
|
|
257
|
+
*
|
|
258
|
+
* The chain would only reject a malformed one at SUBMISSION, and opaquely: a
|
|
259
|
+
* short/long/garbage sibling folds to a root the enclave never signed, which
|
|
260
|
+
* surfaces as `EInvalidSignature` from `verify_merkle_root` — at that point
|
|
261
|
+
* indistinguishable from a genuinely forged signature. So it is checked twice,
|
|
262
|
+
* at both doors a leaf can come through: on the wire ({@link parseSignedLeaves})
|
|
263
|
+
* and again in the feed leg ({@link feedWaterxRuleWithProof}), which is the last
|
|
264
|
+
* gate for a leaf handed in by a consumer's prefetch cache instead of parsed
|
|
265
|
+
* here. Same reason the keeper's Rust builder checks the length.
|
|
266
|
+
*/
|
|
267
|
+
function assertHash32(symbol, sibling) {
|
|
268
|
+
if (!isHexOfBytes(sibling, 32)) {
|
|
269
|
+
throw new Error(`WaterX leaf for ${symbol} carries a proof element that is not a 32-byte hex hash: ` +
|
|
270
|
+
`'${sibling}'`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Parse a quote-center `/v1/quotes/leaves` response body (`{ leaves: [...] }`)
|
|
275
|
+
* into {@link WaterxSignedLeaf}s, u64s exact as `bigint`, rejecting a malformed
|
|
276
|
+
* leaf or proof element on the wire — before any PTB is touched.
|
|
277
|
+
*/
|
|
278
|
+
export function parseSignedLeaves(text) {
|
|
279
|
+
const raw = parseWithExactIntegers(text, "leaf");
|
|
280
|
+
if (!Array.isArray(raw.leaves)) {
|
|
281
|
+
throw new Error("WaterX quote-center returned a malformed leaf response (expected { leaves })");
|
|
282
|
+
}
|
|
283
|
+
return raw.leaves.map((leaf, i) => {
|
|
284
|
+
if (!isSignedLeafShape(leaf)) {
|
|
285
|
+
// Name the leaf when it carried a usable symbol — a coverage gap and a
|
|
286
|
+
// malformed field read very differently to whoever is paging through this.
|
|
287
|
+
const named = leaf?.symbol;
|
|
288
|
+
const which = typeof named === "string" && named !== "" ? `'${named}'` : `at index ${i}`;
|
|
289
|
+
throw new Error(`WaterX quote-center returned a malformed signed leaf ${which}`);
|
|
290
|
+
}
|
|
291
|
+
for (const sibling of leaf.proof)
|
|
292
|
+
assertHash32(leaf.symbol, sibling);
|
|
293
|
+
return { ...leaf, num_sources: Number(leaf.num_sources) };
|
|
294
|
+
});
|
|
295
|
+
}
|
|
113
296
|
/** The `waterx_rule` deployment entry; throws when the config carries none. */
|
|
114
297
|
function requireWaterxPackage(host) {
|
|
115
298
|
const entry = host.config.packages.waterx_rule;
|
|
@@ -148,75 +331,207 @@ function resolveWaterxInfra(host) {
|
|
|
148
331
|
* `https://app.example/v1/quotes/update`, bypassing the proxy). Same footgun
|
|
149
332
|
* that 404'd every Pyth Pro feed by dropping its `/hermes` prefix.
|
|
150
333
|
*/
|
|
151
|
-
async function
|
|
152
|
-
const url = joinEndpointPath(endpoint,
|
|
334
|
+
async function fetchQuoteCenter(endpoint, path, symbols, what, fetchOpts) {
|
|
335
|
+
const url = joinEndpointPath(endpoint, path);
|
|
153
336
|
url.searchParams.set("symbols", symbols.join(","));
|
|
154
|
-
let res;
|
|
155
337
|
try {
|
|
156
|
-
|
|
338
|
+
return await fetchWithPolicy(url.toString(), { method: "GET" }, { ...fetchOpts });
|
|
157
339
|
}
|
|
158
340
|
catch (err) {
|
|
159
341
|
if (err instanceof FetchPolicyError && err.status !== undefined) {
|
|
160
342
|
const body = err.bodySnippet ? ` ${err.bodySnippet}` : "";
|
|
161
|
-
throw new Error(`WaterX quote-center
|
|
343
|
+
throw new Error(`WaterX quote-center ${what} failed: ${err.status}${body} (retries exhausted after ${err.attempts} attempts)`, { cause: err });
|
|
162
344
|
}
|
|
163
345
|
throw err;
|
|
164
346
|
}
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Pull one enclave-signed batch envelope covering `symbols`. `fellBackFrom`, when
|
|
350
|
+
* set, names the leaf-route failure that sent us here, so a deployment whose
|
|
351
|
+
* quote-center serves NEITHER route reports both statuses instead of only the
|
|
352
|
+
* second one.
|
|
353
|
+
*/
|
|
354
|
+
async function fetchWaterxSignedUpdate(endpoint, symbols, fetchOpts, fellBackFrom) {
|
|
355
|
+
const context = fellBackFrom ? ` (fell back from ${fellBackFrom})` : "";
|
|
356
|
+
const res = await fetchQuoteCenter(endpoint, "v1/quotes/update", symbols, "fetch", fetchOpts);
|
|
165
357
|
if (!res.ok) {
|
|
166
|
-
throw new Error(`WaterX quote-center fetch failed: ${res.status} ${await res.text()}`);
|
|
358
|
+
throw new Error(`WaterX quote-center fetch failed: ${res.status} ${await res.text()}${context}`);
|
|
167
359
|
}
|
|
168
360
|
// Parse from raw text (not res.json()) so the u64 fields are decoded exact as
|
|
169
361
|
// bigint — see parseSignedEnvelope. Malformed-shape check lives there.
|
|
170
362
|
const envelope = parseSignedEnvelope(await res.text());
|
|
171
363
|
if (envelope.intent !== BATCH_PRICE_INTENT) {
|
|
172
|
-
throw new Error(`WaterX quote-center returned intent ${envelope.intent}, expected BATCH_PRICE_INTENT ${BATCH_PRICE_INTENT}`);
|
|
364
|
+
throw new Error(`WaterX quote-center returned intent ${envelope.intent}, expected BATCH_PRICE_INTENT ${BATCH_PRICE_INTENT}${context}`);
|
|
173
365
|
}
|
|
174
366
|
return envelope;
|
|
175
367
|
}
|
|
176
|
-
/**
|
|
368
|
+
/**
|
|
369
|
+
* Pull per-symbol signed Merkle leaves — the DEFAULT update-data shape (see the
|
|
370
|
+
* module header for why it beats the indivisible batch envelope on a trade path).
|
|
371
|
+
*
|
|
372
|
+
* Returns `{ unavailable }` on `404` — and ONLY on 404, the one status that
|
|
373
|
+
* means "this route isn't here": a quote-center older than `/v1/quotes/leaves`
|
|
374
|
+
* has no handler registered for the path. That is the version-skew case the
|
|
375
|
+
* caller answers by falling back to the batch envelope, so the SDK and the
|
|
376
|
+
* quote-center can be deployed in either order.
|
|
377
|
+
*
|
|
378
|
+
* Everything else THROWS rather than falling back, INCLUDING 5xx (`501` among
|
|
379
|
+
* them — `fetchWithPolicy` classifies every 5xx as retryable and has already
|
|
380
|
+
* spent its retry budget by the time one surfaces here). A degraded or
|
|
381
|
+
* unreachable quote-center would fail the envelope route the same way — same
|
|
382
|
+
* service, same enclave behind it — so falling back would only double the
|
|
383
|
+
* latency of an already-failing money-path build, and would report an outage as
|
|
384
|
+
* a version skew.
|
|
385
|
+
*
|
|
386
|
+
* A 404 can ALSO mean "unknown symbol" (the quote-center 404s a symbol missing
|
|
387
|
+
* from its feed registry). That is config drift between this SDK's `feeds` and
|
|
388
|
+
* the quote-center's registry, and the fallback surfaces it honestly: the
|
|
389
|
+
* envelope route 404s on the same symbol, and its error names both attempts.
|
|
390
|
+
*/
|
|
391
|
+
async function fetchWaterxSignedLeaves(endpoint, symbols, fetchOpts) {
|
|
392
|
+
const res = await fetchQuoteCenter(endpoint, "v1/quotes/leaves", symbols, "leaf fetch", fetchOpts);
|
|
393
|
+
if (res.status === 404) {
|
|
394
|
+
return { unavailable: `GET /v1/quotes/leaves → 404 ${(await res.text()).trim()}`.trim() };
|
|
395
|
+
}
|
|
396
|
+
if (!res.ok) {
|
|
397
|
+
throw new Error(`WaterX quote-center leaf fetch failed: ${res.status} ${await res.text()}`);
|
|
398
|
+
}
|
|
399
|
+
// Raw text, not res.json(): u64s must survive as exact bigints.
|
|
400
|
+
return { leaves: parseSignedLeaves(await res.text()) };
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Every requested ticker must be covered by what came back. A 200 whose items
|
|
404
|
+
* omit a requested symbol is a valid, well-signed response — nothing downstream
|
|
405
|
+
* would reject it, and the build would emit a collect call that abstains for the
|
|
406
|
+
* missing symbol, surfacing as an on-chain `EMissingPriceSource` (or a silently
|
|
407
|
+
* thinner weighted set) much later. Same coverage rule
|
|
408
|
+
* {@link WaterxRule.narrowUpdateData} enforces on a cached payload; the
|
|
409
|
+
* difference is disposition — a cache miss falls back to a live fetch, whereas
|
|
410
|
+
* the live source itself coming up short has no fallback left, so it throws.
|
|
411
|
+
*/
|
|
412
|
+
function assertCoverage(what, tickers, covered) {
|
|
413
|
+
const have = new Set(covered);
|
|
414
|
+
const missing = tickers.filter((t) => !have.has(t));
|
|
415
|
+
if (missing.length > 0) {
|
|
416
|
+
throw new Error(`WaterX quote-center ${what} does not cover ticker(s): ${missing.join(", ")} ` +
|
|
417
|
+
`(requested ${tickers.join(", ")}; served ${[...have].join(", ") || "none"})`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Narrow a `RuleUpdateData` to this rule's payload (either variant), or `null`.
|
|
422
|
+
* Kind/shape mismatches throw — see {@link assertRuleUpdateData}.
|
|
423
|
+
*/
|
|
424
|
+
function waterxPayloadOf(data) {
|
|
425
|
+
return assertRuleUpdateData(data, "waterx_rule", isWaterxUpdatePayloadShape, "{ leaves: [...] } or { envelope: { intent, timestamp_ms, payload: { items }, signature } }");
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Narrow a `RuleUpdateData` to its per-symbol {@link WaterxSignedLeaf}s, or
|
|
429
|
+
* `null` when it carries a batch envelope instead (the fallback shape).
|
|
430
|
+
*/
|
|
431
|
+
export function waterxLeavesOf(data) {
|
|
432
|
+
const payload = waterxPayloadOf(data);
|
|
433
|
+
return payload && "leaves" in payload ? payload.leaves : null;
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Narrow a `RuleUpdateData` to its `WaterxSignedEnvelope`, or `null` when it
|
|
437
|
+
* carries per-symbol leaves instead (the default shape).
|
|
438
|
+
*/
|
|
177
439
|
export function waterxEnvelopeOf(data) {
|
|
178
|
-
const payload =
|
|
179
|
-
return payload
|
|
440
|
+
const payload = waterxPayloadOf(data);
|
|
441
|
+
return payload && "envelope" in payload ? payload.envelope : null;
|
|
180
442
|
}
|
|
181
443
|
/** Strip an optional `0x` prefix, then decode hex → bytes. */
|
|
182
|
-
function
|
|
444
|
+
function decodeHex(hex) {
|
|
183
445
|
return fromHex(hex.startsWith("0x") ? hex.slice(2) : hex);
|
|
184
446
|
}
|
|
447
|
+
/**
|
|
448
|
+
* `new_batch_item` with the item fields passed through VERBATIM: the u64s are
|
|
449
|
+
* already exact bigints (see {@link parseWithExactIntegers}), so the BCS the
|
|
450
|
+
* chain rebuilds matches the bytes the enclave signed — whether it re-verifies
|
|
451
|
+
* them as one item of a batch payload or as a Merkle leaf.
|
|
452
|
+
*/
|
|
453
|
+
function newItemArg(tx, pkg, item) {
|
|
454
|
+
return newBatchItem({
|
|
455
|
+
package: pkg,
|
|
456
|
+
arguments: {
|
|
457
|
+
symbol: item.symbol,
|
|
458
|
+
ticker: item.ticker,
|
|
459
|
+
sources: item.sources,
|
|
460
|
+
method: item.method,
|
|
461
|
+
priceTimestampMs: item.price_timestamp_ms,
|
|
462
|
+
priceN: item.price_n,
|
|
463
|
+
priceScale: item.price_scale,
|
|
464
|
+
confidenceN: item.confidence_n,
|
|
465
|
+
confidenceScale: item.confidence_scale,
|
|
466
|
+
maxSourceDeviationBps: item.max_source_deviation_bps,
|
|
467
|
+
numSources: item.num_sources,
|
|
468
|
+
},
|
|
469
|
+
})(tx);
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* `waterx_rule::collect_single_with_proof(collector, config, clock,
|
|
473
|
+
* enclave_config, enclave, timestamp_ms, item, proof, sig)` — the DEFAULT feed
|
|
474
|
+
* leg. Rebuilds ONE item in-PTB and hands it over with its Merkle proof; on-chain
|
|
475
|
+
* the leaf is hashed, folded through the proof, and the enclave's signature over
|
|
476
|
+
* the resulting root is verified before the price reaches the collector.
|
|
477
|
+
*
|
|
478
|
+
* Cost is what makes this the default: one item + `proof.length` 32-byte hashes
|
|
479
|
+
* (~log2 of the snapshot width — 4 to 5 for the 29-feed mainnet registry),
|
|
480
|
+
* against {@link feedWaterxRule}'s obligation to rebuild every item the batch
|
|
481
|
+
* signature covers.
|
|
482
|
+
*
|
|
483
|
+
* Abort vs abstain is identical to the batch path (see the module header): a
|
|
484
|
+
* mismatched root, a bad signature, a future signed timestamp, or a config
|
|
485
|
+
* mismatch ABORTS; a freshness miss or a replayed signed timestamp abstains. One
|
|
486
|
+
* extra abort of its own — `ECollectorSymbolMismatch` if the leaf's symbol isn't
|
|
487
|
+
* the collector's — which `aggregateTicker` prevents by construction, since it
|
|
488
|
+
* looks the leaf up BY the ticker it just built the collector for.
|
|
489
|
+
*/
|
|
490
|
+
export function feedWaterxRuleWithProof(tx, host, collector, leaf) {
|
|
491
|
+
const wr = requireWaterxPackage(host);
|
|
492
|
+
const pkg = wr.published_at;
|
|
493
|
+
const item = newItemArg(tx, pkg, leaf);
|
|
494
|
+
collectSingleWithProof({
|
|
495
|
+
package: pkg,
|
|
496
|
+
arguments: {
|
|
497
|
+
collector,
|
|
498
|
+
config: tx.object(wr.config),
|
|
499
|
+
enclaveConfig: tx.object(wr.enclave_config),
|
|
500
|
+
enclave: tx.object(wr.enclave),
|
|
501
|
+
timestampMs: leaf.signed_timestamp_ms,
|
|
502
|
+
item,
|
|
503
|
+
// vector<vector<u8>>: sibling hashes in fold order, each re-checked as a
|
|
504
|
+
// 32-byte hash (see assertHash32 — a cached leaf never passed the parser).
|
|
505
|
+
proof: leaf.proof.map((sibling) => {
|
|
506
|
+
assertHash32(leaf.symbol, sibling);
|
|
507
|
+
return Array.from(decodeHex(sibling));
|
|
508
|
+
}),
|
|
509
|
+
sig: Array.from(decodeHex(leaf.signature)),
|
|
510
|
+
},
|
|
511
|
+
})(tx);
|
|
512
|
+
}
|
|
185
513
|
/**
|
|
186
514
|
* `waterx_rule::collect_batch_latest(collector, config, clock, enclave_config,
|
|
187
|
-
* enclave, timestamp_ms, payload, sig)` —
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
* `
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
515
|
+
* enclave, timestamp_ms, payload, sig)` — the FALLBACK feed leg, for a
|
|
516
|
+
* quote-center with no leaf route (and for callers that hold a whole batch).
|
|
517
|
+
* Rebuilds the enclave-signed batch payload in-PTB (`new_batch_payload` + one
|
|
518
|
+
* `new_batch_item`/`push_batch_item` per item, the exact shape the enclave
|
|
519
|
+
* signed) and contributes the price for `collector.symbol()` to the collector.
|
|
520
|
+
*
|
|
521
|
+
* Every item must be rebuilt, not just this collector's: the signature covers
|
|
522
|
+
* `BCS(IntentMessage)` over the whole vector, so a missing item is a failed
|
|
523
|
+
* verify. That is the cost {@link feedWaterxRuleWithProof} exists to avoid.
|
|
524
|
+
*
|
|
525
|
+
* On-chain it abstains (records `none`) when the symbol is stale, absent from the
|
|
526
|
+
* batch, or already recorded at this signed timestamp; it ABORTS on a bad
|
|
527
|
+
* signature, a signed timestamp ahead of the `Clock`, or a config mismatch.
|
|
196
528
|
*/
|
|
197
529
|
export function feedWaterxRule(tx, host, collector, envelope) {
|
|
198
530
|
const wr = requireWaterxPackage(host);
|
|
199
531
|
const pkg = wr.published_at;
|
|
200
532
|
const payload = newBatchPayload({ package: pkg })(tx);
|
|
201
533
|
for (const item of envelope.payload.items) {
|
|
202
|
-
|
|
203
|
-
// through verbatim so the rebuilt BCS matches the enclave's signed bytes.
|
|
204
|
-
const itemArg = newBatchItem({
|
|
205
|
-
package: pkg,
|
|
206
|
-
arguments: {
|
|
207
|
-
symbol: item.symbol,
|
|
208
|
-
ticker: item.ticker,
|
|
209
|
-
sources: item.sources,
|
|
210
|
-
method: item.method,
|
|
211
|
-
priceTimestampMs: item.price_timestamp_ms,
|
|
212
|
-
priceN: item.price_n,
|
|
213
|
-
priceScale: item.price_scale,
|
|
214
|
-
confidenceN: item.confidence_n,
|
|
215
|
-
confidenceScale: item.confidence_scale,
|
|
216
|
-
maxSourceDeviationBps: item.max_source_deviation_bps,
|
|
217
|
-
numSources: item.num_sources,
|
|
218
|
-
},
|
|
219
|
-
})(tx);
|
|
534
|
+
const itemArg = newItemArg(tx, pkg, item);
|
|
220
535
|
pushBatchItem({ package: pkg, arguments: { payload, item: itemArg } })(tx);
|
|
221
536
|
}
|
|
222
537
|
collectBatchLatest({
|
|
@@ -228,7 +543,7 @@ export function feedWaterxRule(tx, host, collector, envelope) {
|
|
|
228
543
|
enclave: tx.object(wr.enclave),
|
|
229
544
|
timestampMs: envelope.timestamp_ms,
|
|
230
545
|
payload,
|
|
231
|
-
sig: Array.from(
|
|
546
|
+
sig: Array.from(decodeHex(envelope.signature)),
|
|
232
547
|
},
|
|
233
548
|
})(tx);
|
|
234
549
|
}
|
|
@@ -242,17 +557,11 @@ export const WaterxRule = {
|
|
|
242
557
|
return Object.keys(host.config.packages.waterx_rule?.feeds ?? {});
|
|
243
558
|
},
|
|
244
559
|
/**
|
|
245
|
-
* Pulls
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
* `collect_batch_latest` that abstains for the missing symbol, surfacing as
|
|
251
|
-
* an on-chain `EMissingPriceSource` (or a silently thinner weighted set) much
|
|
252
|
-
* later. Same coverage rule the cached path enforces in
|
|
253
|
-
* {@link WaterxRule.narrowUpdateData}; the difference is disposition — a
|
|
254
|
-
* cache miss falls back to this live fetch, whereas the live source itself
|
|
255
|
-
* coming up short has no fallback left, so it throws deterministically here.
|
|
560
|
+
* Pulls per-symbol Merkle leaves for `tickers`, falling back to one batch
|
|
561
|
+
* envelope only when this quote-center has no leaf route (see
|
|
562
|
+
* {@link fetchWaterxSignedLeaves} for exactly which statuses mean that, and
|
|
563
|
+
* why nothing else falls back). Either way, returns only what covers ALL of
|
|
564
|
+
* `tickers` — see {@link assertCoverage}.
|
|
256
565
|
*/
|
|
257
566
|
async fetchUpdateData(host, tickers) {
|
|
258
567
|
if (tickers.length === 0)
|
|
@@ -268,25 +577,48 @@ export const WaterxRule = {
|
|
|
268
577
|
}
|
|
269
578
|
}
|
|
270
579
|
const { endpoint, fetch: fetchOpts } = resolveWaterxInfra(host);
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
throw new Error(`WaterX quote-center envelope does not cover ticker(s): ${missing.join(", ")} ` +
|
|
276
|
-
`(requested ${tickers.join(", ")}; served ${[...covered].join(", ") || "none"})`);
|
|
580
|
+
const pulled = await fetchWaterxSignedLeaves(endpoint, tickers, fetchOpts);
|
|
581
|
+
if ("leaves" in pulled) {
|
|
582
|
+
assertCoverage("leaves", tickers, pulled.leaves.map((l) => l.symbol));
|
|
583
|
+
return { kind: "waterx_rule", payload: { leaves: pulled.leaves } };
|
|
277
584
|
}
|
|
585
|
+
const envelope = await fetchWaterxSignedUpdate(endpoint, tickers, fetchOpts, pulled.unavailable);
|
|
586
|
+
assertCoverage("envelope", tickers, envelope.payload.items.map((i) => i.symbol));
|
|
278
587
|
return { kind: "waterx_rule", payload: { envelope } };
|
|
279
588
|
},
|
|
280
589
|
/**
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
590
|
+
* Divisibility differs by variant, which is the whole reason this method
|
|
591
|
+
* belongs to the rule and not to its consumers:
|
|
592
|
+
*
|
|
593
|
+
* - **Leaves** are per-symbol and independently verifiable (each carries its
|
|
594
|
+
* own proof + root signature), so a wider payload — e.g. a whole-universe
|
|
595
|
+
* prefetch cache — is SUBSET to exactly `tickers`. A trade then carries one
|
|
596
|
+
* leaf instead of the whole cached snapshot.
|
|
597
|
+
* - **A batch envelope** carries a single signature over its whole `payload`
|
|
598
|
+
* and is indivisible: it is returned whole, or not at all.
|
|
599
|
+
*
|
|
600
|
+
* Either way a ticker this payload cannot serve → `null` (miss), never a
|
|
285
601
|
* silent partial.
|
|
286
602
|
*/
|
|
287
603
|
narrowUpdateData(_host, data, tickers) {
|
|
604
|
+
if (tickers.length === 0)
|
|
605
|
+
return null;
|
|
606
|
+
const leaves = waterxLeavesOf(data);
|
|
607
|
+
if (leaves) {
|
|
608
|
+
// Indexed by symbol, then walked in REQUESTED order: a `filter` would let
|
|
609
|
+
// a payload that repeats one symbol and omits another pass on count alone.
|
|
610
|
+
const bySymbol = new Map(leaves.map((l) => [l.symbol, l]));
|
|
611
|
+
const subset = [];
|
|
612
|
+
for (const ticker of tickers) {
|
|
613
|
+
const leaf = bySymbol.get(ticker);
|
|
614
|
+
if (!leaf)
|
|
615
|
+
return null;
|
|
616
|
+
subset.push(leaf);
|
|
617
|
+
}
|
|
618
|
+
return { kind: "waterx_rule", payload: { leaves: subset } };
|
|
619
|
+
}
|
|
288
620
|
const envelope = waterxEnvelopeOf(data);
|
|
289
|
-
if (!envelope
|
|
621
|
+
if (!envelope)
|
|
290
622
|
return null;
|
|
291
623
|
const covered = new Set(envelope.payload.items.map((i) => i.symbol));
|
|
292
624
|
for (const ticker of tickers) {
|
|
@@ -296,11 +628,12 @@ export const WaterxRule = {
|
|
|
296
628
|
return { kind: "waterx_rule", payload: { envelope } };
|
|
297
629
|
},
|
|
298
630
|
/**
|
|
299
|
-
* No shared verify step: `waterx_rule
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
631
|
+
* No shared verify step: both `waterx_rule` collect entries bundle verify AND
|
|
632
|
+
* feed into one per-collector call, appended by
|
|
633
|
+
* {@link feedWaterxRuleWithProof} / {@link feedWaterxRule} in the per-ticker
|
|
634
|
+
* aggregate leg. So this emits nothing and returns `void` — the signed data
|
|
635
|
+
* reaches the feed leg via `aggregate.ts`'s per-ticker map (built from the
|
|
636
|
+
* group's fetched data), not a `RuleUpdateHandle`.
|
|
304
637
|
*/
|
|
305
638
|
buildUpdateCalls(_tx, _host, _data, _opts) {
|
|
306
639
|
return;
|