@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.
@@ -1,36 +1,60 @@
1
1
  /**
2
2
  * `WaterxRule` — `PriceUpdateRule` for the first-party WaterX quote-center
3
- * (Nautilus-TEE, ed25519), plus `feedWaterxRule`, the collector-feed leg
4
- * `aggregateTicker` appends per waterx-routed ticker. Pulls one enclave-signed
5
- * batch envelope covering every requested ticker from the quote-center
6
- * (`GET /v1/quotes/update?symbols=…`, endpoint from `host.waterx` the
7
- * `waterxEndpoint`/`waterxFetch` create options else its own `WATERX_INFRA`), then —
8
- * unlike Pyth Lazer, whose verify is a single shared PTB step — verifies AND
9
- * feeds in ONE `waterx_rule::collect_batch_latest` call per collector (the Move
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
- * `collect_batch_latest` is the dual-rule path: it feeds the item matching
14
- * `collector.symbol()` WITHOUT aggregating, so a waterx-routed ticker composes
15
- * onto the same collector as Pyth/Supra (compose-then-aggregate). On-chain a
16
- * FRESHNESS miss ABSTAINS (the other weighted rules cover); a config/
17
- * integrity mismatch, bad signature, future timestamp or a REPLAYED signed
18
- * timestamp ABORTS (`EReplayedSignature`, audit F-014: a signed tuple is
19
- * single-use per symbol, enforced by a per-symbol high-water mark BEFORE any
20
- * weight arbitration). Consequence for concurrent builds: two PTBs carrying
21
- * the same envelope for the same symbol cannot both land the second aborts
22
- * even if the rule is unweighted for that ticker. Never share one fetched
23
- * envelope across builds that may execute concurrently for the same symbol.
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
- /** The single signing intent the quote-center emits — exported so read-plane
31
- * consumers can mirror the rule's own envelope intent check (a mispointed
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
- * Parse a quote-center `/v1/quotes/update` response body into a
76
- * {@link WaterxSignedEnvelope} with the u64 fields decoded as `bigint`, exact.
108
+ * FULL structural check on a leaf, not just the fields the feed leg happens to
109
+ * touch first.
77
110
  *
78
- * The signature is over `BCS(IntentMessage<BatchPricePayload>)`, so every u64
79
- * the SDK rebuilds in-PTB must equal the enclave's byte-for-byte or
80
- * `collect_batch_latest` aborts the whole trade PTB (bad signature not an
81
- * abstain). A plain `JSON.parse` yields IEEE-754 doubles that lose precision
82
- * above 2^53, so instead we recover each integer's exact source literal via the
83
- * ES2023 reviver `context.source` (Node 21+ / modern browsers) and `BigInt()`
84
- * it. On an older runtime that passes no `context`, a value within 2^53 is
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
- export function parseSignedEnvelope(text) {
90
- const raw = JSON.parse(text, (_key, value, context) => {
91
- if (typeof value !== "number" || !Number.isInteger(value))
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("waterx envelope carries an integer above 2^53 and this runtime lacks JSON " +
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 fetchWaterxSignedUpdate(endpoint, symbols, fetchOpts) {
152
- const url = joinEndpointPath(endpoint, "v1/quotes/update");
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
- res = await fetchWithPolicy(url.toString(), { method: "GET" }, { ...fetchOpts });
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 fetch failed: ${err.status}${body} (retries exhausted after ${err.attempts} attempts)`, { cause: err });
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
- /** Narrow a `RuleUpdateData` to its `WaterxSignedEnvelope`, or `null`. */
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 = assertRuleUpdateData(data, "waterx_rule", isWaterxUpdatePayloadShape, "{ envelope: { intent, timestamp_ms, payload: { items }, signature } }");
179
- return payload?.envelope ?? null;
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 decodeSig(hex) {
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)` — rebuild the enclave-signed batch
188
- * payload in-PTB (`new_batch_payload` + one `new_batch_item`/`push_batch_item`
189
- * per item, the exact shape the enclave signed) and contribute the price for
190
- * `collector.symbol()` to the collector. One collect call re-verifies the batch
191
- * signature and picks this collector's symbol out of the batch; on-chain it
192
- * abstains (records `none`) when the symbol is stale or absent from the batch,
193
- * but ABORTS `EReplayedSignature` when the symbol's signed timestamp was
194
- * already accepted (per-symbol high-water mark, audit F-014) see the module
195
- * header for the concurrent-build consequence.
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
- // u64 fields are already exact bigints (see parseSignedEnvelope) — passed
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(decodeSig(envelope.signature)),
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 one enclave-signed batch envelope covering `tickers` from the
246
- * quote-center, and only returns it when it actually covers ALL of them.
247
- *
248
- * A 200 whose `items` omit a requested symbol is a valid, well-signed
249
- * envelopenothing downstream would reject it, and the build would emit a
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 envelope = await fetchWaterxSignedUpdate(endpoint, tickers, fetchOpts);
272
- const covered = new Set(envelope.payload.items.map((i) => i.symbol));
273
- const missing = tickers.filter((t) => !covered.has(t));
274
- if (missing.length > 0) {
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
- * One signed batch envelope carries a single ed25519 signature over its whole
282
- * `payload` it is indivisible: it can only be served whole (re-verified from
283
- * the full item set). Returns the whole payload iff every requested ticker's
284
- * item is present in THIS envelope; any coverage gap → `null` (miss), never a
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 || tickers.length === 0)
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::collect_batch_latest` bundles verify
300
- * AND feed into one per-collector call, appended by {@link feedWaterxRule} in
301
- * the per-ticker aggregate leg. So this emits nothing and returns `void` — the
302
- * signed envelope reaches the feed leg via `aggregate.ts`'s per-ticker map
303
- * (built from the group's fetched data), not a `RuleUpdateHandle`.
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;