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