@waterx/sdk 4.3.2 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/.claude/skills/waterx-sdk-integration/SKILL.md +225 -0
  2. package/README.md +216 -45
  3. package/SKILLS.md +34 -0
  4. package/dist/cjs/src/account/config.d.ts +3 -5
  5. package/dist/cjs/src/account/funding/wormhole.d.ts +1 -2
  6. package/dist/cjs/src/account/funding/wormhole.js +1 -2
  7. package/dist/cjs/src/generated/waterx_rule/waterx_rule.d.ts +140 -7
  8. package/dist/cjs/src/generated/waterx_rule/waterx_rule.js +163 -8
  9. package/dist/cjs/src/oracle/aggregate.d.ts +49 -62
  10. package/dist/cjs/src/oracle/aggregate.js +208 -164
  11. package/dist/cjs/src/oracle/config.d.ts +38 -51
  12. package/dist/cjs/src/oracle/config.js +1 -1
  13. package/dist/cjs/src/oracle/host.d.ts +14 -19
  14. package/dist/cjs/src/oracle/host.js +3 -3
  15. package/dist/cjs/src/oracle/index.d.ts +25 -16
  16. package/dist/cjs/src/oracle/index.js +87 -69
  17. package/dist/cjs/src/oracle/price-update-rule.d.ts +104 -66
  18. package/dist/cjs/src/oracle/price-update-rule.js +24 -16
  19. package/dist/cjs/src/oracle/pyth-pro-history.d.ts +36 -0
  20. package/dist/cjs/src/oracle/pyth-pro-history.js +41 -0
  21. package/dist/cjs/src/oracle/read-plane.d.ts +35 -53
  22. package/dist/cjs/src/oracle/read-plane.js +26 -48
  23. package/dist/cjs/src/oracle/read-prices.d.ts +105 -0
  24. package/dist/cjs/src/oracle/read-prices.js +162 -0
  25. package/dist/cjs/src/oracle/rule-registry.d.ts +12 -12
  26. package/dist/cjs/src/oracle/rule-registry.js +12 -14
  27. package/dist/cjs/src/oracle/rules/pyth-lazer-rule.d.ts +24 -3
  28. package/dist/cjs/src/oracle/rules/pyth-lazer-rule.js +40 -20
  29. package/dist/cjs/src/oracle/rules/waterx-rule.d.ts +271 -57
  30. package/dist/cjs/src/oracle/rules/waterx-rule.js +743 -144
  31. package/dist/cjs/src/oracle/schedule.d.ts +109 -0
  32. package/dist/cjs/src/oracle/schedule.js +991 -0
  33. package/dist/cjs/src/oracle/source-list.d.ts +31 -27
  34. package/dist/cjs/src/oracle/source-list.js +43 -45
  35. package/dist/cjs/src/oracle/symbol-catalog.d.ts +54 -0
  36. package/dist/cjs/src/oracle/symbol-catalog.js +69 -0
  37. package/dist/cjs/src/oracle/update-fetch.d.ts +11 -15
  38. package/dist/cjs/src/oracle/update-fetch.js +12 -19
  39. package/dist/cjs/src/oracle/validate.d.ts +105 -0
  40. package/dist/cjs/src/oracle/validate.js +185 -0
  41. package/dist/cjs/src/oracle/weight-coverage.d.ts +79 -0
  42. package/dist/cjs/src/oracle/weight-coverage.js +173 -0
  43. package/dist/cjs/src/perp/client.d.ts +40 -55
  44. package/dist/cjs/src/perp/client.js +44 -17
  45. package/dist/cjs/src/perp/config-view.d.ts +5 -14
  46. package/dist/cjs/src/perp/config-view.js +5 -16
  47. package/dist/cjs/src/perp/config.d.ts +3 -3
  48. package/dist/cjs/src/perp/config.js +11 -13
  49. package/dist/cjs/src/perp/index.d.ts +4 -6
  50. package/dist/cjs/src/perp/index.js +43 -18
  51. package/dist/cjs/src/perp/tx-builders/common.d.ts +86 -48
  52. package/dist/cjs/src/perp/tx-builders/common.js +115 -47
  53. package/dist/cjs/src/perp/tx-builders/wlp.d.ts +3 -14
  54. package/dist/cjs/src/perp/tx-builders/wlp.js +6 -35
  55. package/dist/cjs/src/perp/tx-builders.d.ts +6 -5
  56. package/dist/cjs/src/perp/tx-builders.js +16 -10
  57. package/dist/cjs/src/unified-client.d.ts +9 -35
  58. package/dist/cjs/src/unified-client.js +3 -4
  59. package/dist/cjs/src/utils/config.d.ts +0 -8
  60. package/dist/cjs/src/utils/config.js +5 -13
  61. package/dist/src/account/config.d.ts +3 -5
  62. package/dist/src/account/funding/wormhole.d.ts +1 -2
  63. package/dist/src/account/funding/wormhole.js +1 -2
  64. package/dist/src/generated/waterx_rule/waterx_rule.d.ts +140 -7
  65. package/dist/src/generated/waterx_rule/waterx_rule.js +151 -7
  66. package/dist/src/oracle/aggregate.d.ts +49 -62
  67. package/dist/src/oracle/aggregate.js +209 -164
  68. package/dist/src/oracle/config.d.ts +38 -51
  69. package/dist/src/oracle/config.js +1 -1
  70. package/dist/src/oracle/host.d.ts +14 -19
  71. package/dist/src/oracle/host.js +3 -3
  72. package/dist/src/oracle/index.d.ts +25 -16
  73. package/dist/src/oracle/index.js +61 -54
  74. package/dist/src/oracle/price-update-rule.d.ts +104 -66
  75. package/dist/src/oracle/price-update-rule.js +23 -16
  76. package/dist/src/oracle/pyth-pro-history.d.ts +36 -0
  77. package/dist/src/oracle/pyth-pro-history.js +38 -0
  78. package/dist/src/oracle/read-plane.d.ts +35 -53
  79. package/dist/src/oracle/read-plane.js +25 -47
  80. package/dist/src/oracle/read-prices.d.ts +105 -0
  81. package/dist/src/oracle/read-prices.js +156 -0
  82. package/dist/src/oracle/rule-registry.d.ts +12 -12
  83. package/dist/src/oracle/rule-registry.js +12 -14
  84. package/dist/src/oracle/rules/pyth-lazer-rule.d.ts +24 -3
  85. package/dist/src/oracle/rules/pyth-lazer-rule.js +39 -20
  86. package/dist/src/oracle/rules/waterx-rule.d.ts +271 -57
  87. package/dist/src/oracle/rules/waterx-rule.js +735 -144
  88. package/dist/src/oracle/schedule.d.ts +109 -0
  89. package/dist/src/oracle/schedule.js +985 -0
  90. package/dist/src/oracle/source-list.d.ts +31 -27
  91. package/dist/src/oracle/source-list.js +42 -43
  92. package/dist/src/oracle/symbol-catalog.d.ts +54 -0
  93. package/dist/src/oracle/symbol-catalog.js +65 -0
  94. package/dist/src/oracle/update-fetch.d.ts +11 -15
  95. package/dist/src/oracle/update-fetch.js +12 -18
  96. package/dist/src/oracle/validate.d.ts +105 -0
  97. package/dist/src/oracle/validate.js +177 -0
  98. package/dist/src/oracle/weight-coverage.d.ts +79 -0
  99. package/dist/src/oracle/weight-coverage.js +166 -0
  100. package/dist/src/perp/client.d.ts +40 -55
  101. package/dist/src/perp/client.js +45 -18
  102. package/dist/src/perp/config-view.d.ts +5 -14
  103. package/dist/src/perp/config-view.js +5 -16
  104. package/dist/src/perp/config.d.ts +3 -3
  105. package/dist/src/perp/config.js +11 -13
  106. package/dist/src/perp/index.d.ts +4 -6
  107. package/dist/src/perp/index.js +17 -5
  108. package/dist/src/perp/tx-builders/common.d.ts +86 -48
  109. package/dist/src/perp/tx-builders/common.js +114 -48
  110. package/dist/src/perp/tx-builders/wlp.d.ts +3 -14
  111. package/dist/src/perp/tx-builders/wlp.js +6 -35
  112. package/dist/src/perp/tx-builders.d.ts +6 -5
  113. package/dist/src/perp/tx-builders.js +15 -6
  114. package/dist/src/unified-client.d.ts +9 -35
  115. package/dist/src/unified-client.js +3 -4
  116. package/dist/src/utils/config.d.ts +0 -8
  117. package/dist/src/utils/config.js +5 -12
  118. package/package.json +8 -2
  119. package/dist/cjs/src/generated/pyth_sponsor_rule/deps/sui/balance.d.ts +0 -12
  120. package/dist/cjs/src/generated/pyth_sponsor_rule/deps/sui/balance.js +0 -17
  121. package/dist/cjs/src/generated/pyth_sponsor_rule/pyth_sponsor_rule.d.ts +0 -68
  122. package/dist/cjs/src/generated/pyth_sponsor_rule/pyth_sponsor_rule.js +0 -113
  123. package/dist/cjs/src/generated/waterx_pyth_rule/deps/sui/vec_map.d.ts +0 -36
  124. package/dist/cjs/src/generated/waterx_pyth_rule/deps/sui/vec_map.js +0 -31
  125. package/dist/cjs/src/generated/waterx_pyth_rule/pyth_rule.d.ts +0 -192
  126. package/dist/cjs/src/generated/waterx_pyth_rule/pyth_rule.js +0 -214
  127. package/dist/cjs/src/oracle/pyth.d.ts +0 -227
  128. package/dist/cjs/src/oracle/pyth.js +0 -652
  129. package/dist/cjs/src/oracle/rules/pyth-core-rule.d.ts +0 -15
  130. package/dist/cjs/src/oracle/rules/pyth-core-rule.js +0 -100
  131. package/dist/cjs/src/oracle/rules/pyth-rule.d.ts +0 -11
  132. package/dist/cjs/src/oracle/rules/pyth-rule.js +0 -29
  133. package/dist/cjs/src/oracle/rules/sponsor.d.ts +0 -32
  134. package/dist/cjs/src/oracle/rules/sponsor.js +0 -56
  135. package/dist/src/generated/pyth_sponsor_rule/deps/sui/balance.d.ts +0 -12
  136. package/dist/src/generated/pyth_sponsor_rule/deps/sui/balance.js +0 -14
  137. package/dist/src/generated/pyth_sponsor_rule/pyth_sponsor_rule.d.ts +0 -68
  138. package/dist/src/generated/pyth_sponsor_rule/pyth_sponsor_rule.js +0 -73
  139. package/dist/src/generated/waterx_pyth_rule/deps/sui/vec_map.d.ts +0 -36
  140. package/dist/src/generated/waterx_pyth_rule/deps/sui/vec_map.js +0 -27
  141. package/dist/src/generated/waterx_pyth_rule/pyth_rule.d.ts +0 -192
  142. package/dist/src/generated/waterx_pyth_rule/pyth_rule.js +0 -170
  143. package/dist/src/oracle/pyth.d.ts +0 -227
  144. package/dist/src/oracle/pyth.js +0 -638
  145. package/dist/src/oracle/rules/pyth-core-rule.d.ts +0 -15
  146. package/dist/src/oracle/rules/pyth-core-rule.js +0 -97
  147. package/dist/src/oracle/rules/pyth-rule.d.ts +0 -11
  148. package/dist/src/oracle/rules/pyth-rule.js +0 -26
  149. package/dist/src/oracle/rules/sponsor.d.ts +0 -32
  150. package/dist/src/oracle/rules/sponsor.js +0 -52
@@ -1,44 +1,70 @@
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 to NAME
45
+ * the signing scheme only consumers no longer mirror the intent gate
46
+ * themselves: every quote-center pull (tx-build fetch, read executor, BE
47
+ * prefetch) goes through {@link fetchWaterxSignedUpdate} /
48
+ * {@link fetchWaterxSignedLeaves}, which enforce it, so a mispointed endpoint
49
+ * is rejected identically on every path. */
33
50
  export const BATCH_PRICE_INTENT = 1;
51
+ /**
52
+ * Intent the quote-center signs a snapshot's Merkle ROOT under
53
+ * (`waterx_rule::MERKLE_ROOT_INTENT`). Distinct from
54
+ * {@link BATCH_PRICE_INTENT} on purpose: the intent byte is the first field of
55
+ * the signed `IntentMessage`, so a batch signature can never be replayed as a
56
+ * root signature or vice versa. Leaves carry no `intent` field of their own —
57
+ * they are only ever submitted through `collect_single_with_proof`, which pins
58
+ * the intent on-chain — so this exists to name the scheme, not to gate a parse.
59
+ */
60
+ export const MERKLE_ROOT_INTENT = 2;
34
61
  /**
35
62
  * WaterX quote-center external infra — owned by THIS source, by network.
36
- * Mirrors `PYTH_CORE_INFRA` (oracle/pyth.ts) and `LAZER_INFRA`
37
- * (rules/pyth-lazer-rule.ts): per-network constants for infrastructure the
38
- * source's operator runs, co-located with the only rule that reads them — no
39
- * other oracle source ever touches a quote-center endpoint. Public read (no
40
- * auth), so there is no api_key. `endpoint` has no trailing slash — the rule
41
- * appends the path.
63
+ * Mirrors `LAZER_INFRA` (rules/pyth-lazer-rule.ts): per-network constants for
64
+ * infrastructure the source's operator runs, co-located with the only rule
65
+ * that reads them no other oracle source ever touches a quote-center
66
+ * endpoint. Public read (no auth), so there is no api_key. `endpoint` has no
67
+ * trailing slash — the rule appends the path.
42
68
  *
43
69
  * These are the DEFAULTS behind the caller's `client.waterx` access slice
44
70
  * (`waterxEndpoint` / `waterxFetch` create options) — the browser-CORS proxy
@@ -51,65 +77,316 @@ export const WATERX_INFRA = {
51
77
  /**
52
78
  * The waterx source's quote-center base for `network` — the ONE accessor
53
79
  * consumers (BE/FE read planes) use when, and only when, their own
54
- * `ORACLE_SOURCE` resolves to `'waterx_rule'`. Mirrors
55
- * `pythCoreHermesEndpoint`. Under any other source the read endpoint is that
56
- * source's own configuration — never this one.
80
+ * the config wires `waterx_rule`. Under any other source the
81
+ * read endpoint is that source's own configuration never this one.
57
82
  */
58
83
  export function waterxQuoteCenterEndpoint(network) {
59
84
  return WATERX_INFRA[network].endpoint;
60
85
  }
61
86
  /**
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.
87
+ * Off-chain mirror of the on-chain `waterx_rule` `FeedConfig.max_age` DEFAULT
88
+ * (90s): a price older than this ABSTAINS on-chain, so a read plane serving it
89
+ * as live would show a price no trade could execute against. The single
90
+ * source of truth for consumers' post-cache freshness filters — import this,
91
+ * never re-declare the number. (A deployment that overrides `max_age`
92
+ * per-feed on-chain diverges from this mirror; none does today.)
65
93
  */
66
- function isWaterxUpdatePayloadShape(payload) {
94
+ export const WATERX_MAX_PRICE_AGE_MS = 90_000;
95
+ /**
96
+ * `true` iff a quote-center read entry is still within
97
+ * {@link WATERX_MAX_PRICE_AGE_MS} of `nowMs` — the freshness predicate
98
+ * consumers apply to `readQuoteCenterPrices` output (post-cache), matching
99
+ * the on-chain abstain boundary instead of each inventing a policy.
100
+ */
101
+ export function isFreshWaterxEntry(entry, nowMs) {
102
+ const age = nowMs - entry.publishTimeMs;
103
+ // A FUTURE timestamp is not fresh — it is a broken clock or a malformed
104
+ // payload, and a bare `age <= MAX` treats it as the freshest possible price.
105
+ // One tolerance of clock skew is allowed in the other direction, since the
106
+ // quote-center and the caller keep independent clocks; beyond that, reject.
107
+ if (age < -WATERX_CLOCK_SKEW_TOLERANCE_MS)
108
+ return false;
109
+ return age <= WATERX_MAX_PRICE_AGE_MS;
110
+ }
111
+ /**
112
+ * How far ahead of the reader's clock a quote-center timestamp may sit before
113
+ * it is treated as broken rather than merely skewed. Two independent clocks
114
+ * drift; a price minutes in the future does not.
115
+ */
116
+ export const WATERX_CLOCK_SKEW_TOLERANCE_MS = 5_000;
117
+ /**
118
+ * Structural gate for the ENVELOPE payload shape — symmetric with
119
+ * {@link isWaterxLeafPayloadShape}, which validates every leaf.
120
+ *
121
+ * This runs on payloads that never went through {@link parseSignedEnvelope}:
122
+ * a consumer's `UpdateDataProvider` hands back whatever its cache holds, and a
123
+ * value revived from JSON can have lost its bigints or been written half-built.
124
+ * A gate that checked only `signature: string` + `Array.isArray(items)` let
125
+ * those through, and the failure landed mid-PTB-build inside `newItemArg` —
126
+ * after legs were already appended to the caller's `tx`. Every item is checked
127
+ * with the SAME guard the wire door uses.
128
+ */
129
+ function isWaterxEnvelopePayloadShape(payload) {
67
130
  const env = payload?.envelope;
68
131
  return (typeof env === "object" &&
69
132
  env !== null &&
70
- typeof env.signature === "string" &&
133
+ // ed25519 over the batch — a wrong-length signature is an on-chain abort.
134
+ isHexOfBytes(env.signature, 64) &&
71
135
  typeof env.timestamp_ms === "bigint" &&
72
- Array.isArray(env.payload?.items));
136
+ env.timestamp_ms >= 0n &&
137
+ Array.isArray(env.payload?.items) &&
138
+ env.payload.items.every(isBatchItemShape));
73
139
  }
140
+ function isWaterxLeafPayloadShape(payload) {
141
+ const leaves = payload?.leaves;
142
+ return Array.isArray(leaves) && leaves.every(isSignedLeafShape);
143
+ }
144
+ /** Every u64 field of a leaf — each one is signed, so each must be present and exact. */
74
145
  /**
75
- * Parse a quote-center `/v1/quotes/update` response body into a
76
- * {@link WaterxSignedEnvelope} with the u64 fields decoded as `bigint`, exact.
146
+ * The signed u64 fields every quote-center ITEM carries the BCS bytes the
147
+ * enclave signed over. A leaf adds `signed_timestamp_ms` on top (see
148
+ * {@link isSignedLeafShape}); the batch envelope's items do not carry it.
149
+ */
150
+ const BATCH_ITEM_U64_FIELDS = [
151
+ "price_timestamp_ms",
152
+ "price_n",
153
+ "price_scale",
154
+ "confidence_n",
155
+ "confidence_scale",
156
+ "max_source_deviation_bps",
157
+ ];
158
+ /**
159
+ * FULL structural check on a leaf, not just the fields the feed leg happens to
160
+ * touch first.
77
161
  *
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.
162
+ * Every field here is either part of the BCS bytes the enclave signed (so a
163
+ * missing one means the rebuilt item cannot match) or the signature/proof
164
+ * material itself. A partial guard let a 200 that omitted, say, `ticker` pass as
165
+ * a valid leaf and fail LATER, mid-PTB-build, as `Parameter ticker is required`
166
+ * after `assertCoverage` had already declared the response good, and while the
167
+ * caller's `tx` was already being mutated. `parseSignedLeaves` promises to reject
168
+ * a malformed leaf before any PTB is touched; this is what makes that true.
88
169
  */
89
- export function parseSignedEnvelope(text) {
90
- const raw = JSON.parse(text, (_key, value, context) => {
91
- if (typeof value !== "number" || !Number.isInteger(value))
170
+ /** Hex characters only. Length is checked separately — see {@link isHexOfBytes}. */
171
+ const HEX_ONLY = /^[0-9a-fA-F]*$/;
172
+ /**
173
+ * `true` iff `hex` (± `0x`) is exactly `bytes` bytes of hex.
174
+ *
175
+ * A static pattern plus an explicit length compare, deliberately not a computed
176
+ * `new RegExp(`…{${bytes * 2}}`)`: that recompiles on every call and semgrep
177
+ * blocks it as `detect-non-literal-regexp`.
178
+ */
179
+ function isHexOfBytes(hex, bytes) {
180
+ if (typeof hex !== "string")
181
+ return false;
182
+ const body = hex.startsWith("0x") ? hex.slice(2) : hex;
183
+ return body.length === bytes * 2 && HEX_ONLY.test(body);
184
+ }
185
+ /**
186
+ * The ITEM half — every field the feed leg rebuilds into the BCS the enclave
187
+ * signed. Shared by BOTH wire doors: a leaf is an item plus its signature and
188
+ * proof material ({@link isSignedLeafShape}), and the batch envelope's items go
189
+ * through the same gate ({@link assertBatchItemShape}). One definition, so a
190
+ * new signed field cannot be validated at one door and waved through the other.
191
+ */
192
+ function isBatchItemShape(item) {
193
+ const l = item;
194
+ if (typeof l !== "object" || l === null)
195
+ return false;
196
+ if (typeof l.symbol !== "string" || l.symbol === "")
197
+ return false;
198
+ if (typeof l.ticker !== "string" || l.ticker === "")
199
+ return false;
200
+ if (typeof l.method !== "string" || l.method === "")
201
+ return false;
202
+ // `num_sources` is a u8, and this guard runs at TWO stages: on the freshly
203
+ // revived object (where every JSON integer, including this one, is a bigint)
204
+ // and again on a normalized payload from `narrowUpdateData` / a consumer cache
205
+ // (where `parseSignedLeaves` has coerced it to a number). Both are valid here;
206
+ // only the domain matters.
207
+ const n = l.num_sources;
208
+ if (typeof n === "bigint") {
209
+ if (n < 0n || n > 255n)
210
+ return false;
211
+ }
212
+ else if (typeof n === "number") {
213
+ if (!Number.isInteger(n) || n < 0 || n > 255)
214
+ return false;
215
+ }
216
+ else {
217
+ return false;
218
+ }
219
+ // `sources` is a vector<u64>: exact bigints, like every other signed integer.
220
+ if (!Array.isArray(l.sources) || l.sources.length === 0)
221
+ return false;
222
+ if (l.sources.some((s) => typeof s !== "bigint" || s < 0n))
223
+ return false;
224
+ for (const field of BATCH_ITEM_U64_FIELDS) {
225
+ const v = l[field];
226
+ if (typeof v !== "bigint" || v < 0n)
227
+ return false;
228
+ }
229
+ return true;
230
+ }
231
+ function isSignedLeafShape(leaf) {
232
+ if (!isBatchItemShape(leaf))
233
+ return false;
234
+ const l = leaf;
235
+ const signedAt = l.signed_timestamp_ms;
236
+ if (typeof signedAt !== "bigint" || signedAt < 0n)
237
+ return false;
238
+ // ed25519 is always 64 bytes and the root is always a 32-byte keccak256; a
239
+ // wrong-length signature is an on-chain abort, so it is rejected here.
240
+ if (!isHexOfBytes(l.signature, 64))
241
+ return false;
242
+ if (!isHexOfBytes(l.root, 32))
243
+ return false;
244
+ if (!Array.isArray(l.proof) || l.proof.some((p) => typeof p !== "string"))
245
+ return false;
246
+ return true;
247
+ }
248
+ /**
249
+ * Shape check ONLY — the `kind` discriminant is checked separately by the
250
+ * caller before this runs (mirrors the other rules' guard split), so a
251
+ * same-shaped payload from a different rule can never silently pass. Accepts
252
+ * either variant; the accessors below pick one out.
253
+ */
254
+ function isWaterxUpdatePayloadShape(payload) {
255
+ return isWaterxLeafPayloadShape(payload) || isWaterxEnvelopePayloadShape(payload);
256
+ }
257
+ /** A JSON number token that is lexically an integer: no `.`, no `e`/`E`. */
258
+ const INTEGER_TOKEN = /^-?\d+$/;
259
+ /**
260
+ * `JSON.parse` with every integer decoded as an exact `bigint`.
261
+ *
262
+ * The signature is over `BCS(IntentMessage<…>)`, so every u64 the SDK rebuilds
263
+ * in-PTB must equal the enclave's byte-for-byte or the on-chain verify fails and
264
+ * ABORTS the whole trade PTB (a bad signature is not an abstain). A plain
265
+ * `JSON.parse` yields IEEE-754 doubles that lose precision above 2^53, so
266
+ * instead we recover each integer's exact source literal via the ES2023 reviver
267
+ * `context.source` (Node 21+ / modern browsers) and `BigInt()` it. On an older
268
+ * runtime that passes no `context`, a value within 2^53 is still exact
269
+ * (`BigInt(number)`); a value ABOVE it throws loudly here rather than silently
270
+ * corrupting the payload into an on-chain abort.
271
+ *
272
+ * Integrality is decided from the SOURCE TOKEN, never from the parsed value: a
273
+ * display float can be lexically `0.0` while `JSON.parse` hands back the number
274
+ * `0`, which `Number.isInteger` accepts — and `BigInt("0.0")` throws
275
+ * `SyntaxError`. The leaf endpoint really does emit that (Rust `f64` serializes
276
+ * a whole number as `0.0`), so keying off the value crashed every fetch that
277
+ * included, say, a `confidence: 0.0` leaf. Exponent tokens (`1e3`) throw the
278
+ * same way. Only `-?\d+` becomes a `bigint`; every other numeric token stays a
279
+ * number, which is right for the display-only `price` / `confidence` fields —
280
+ * they are not part of any signed byte string.
281
+ */
282
+ function parseWithExactIntegers(text, what) {
283
+ return JSON.parse(text, (_key, value, context) => {
284
+ if (typeof value !== "number")
285
+ return value;
286
+ const token = context?.source;
287
+ if (token !== undefined) {
288
+ return INTEGER_TOKEN.test(token) ? BigInt(token) : value;
289
+ }
290
+ // No JSON source access on this runtime: fall back to the parsed value.
291
+ // A display float that happens to be whole (`0.0` → `0`) becomes a bigint
292
+ // here, which is harmless — no signed field is read off those two.
293
+ if (!Number.isInteger(value))
92
294
  return value;
93
- if (context?.source !== undefined)
94
- return BigInt(context.source);
95
295
  if (!Number.isSafeInteger(value)) {
96
- throw new Error("waterx envelope carries an integer above 2^53 and this runtime lacks JSON " +
296
+ throw new Error(`waterx ${what} carries an integer above 2^53 and this runtime lacks JSON ` +
97
297
  "source access — cannot preserve u64 precision for the signed payload");
98
298
  }
99
299
  return BigInt(value);
100
300
  });
301
+ }
302
+ /**
303
+ * Parse a quote-center `/v1/quotes/update` response body into a
304
+ * {@link WaterxSignedEnvelope} with the u64 fields decoded as `bigint`, exact
305
+ * (see {@link parseWithExactIntegers}). `num_sources` (u8) and `intent` are
306
+ * coerced back to `number` — both are tiny.
307
+ */
308
+ export function parseSignedEnvelope(text) {
309
+ const raw = parseWithExactIntegers(text, "envelope");
101
310
  if (typeof raw.signature !== "string" || !Array.isArray(raw.payload?.items)) {
102
311
  throw new Error("WaterX quote-center returned a malformed signed envelope");
103
312
  }
313
+ // `timestamp_ms` is signed OVER, so defaulting a missing one to `0n` does not
314
+ // produce a harmless zero — it produces a batch whose signature cannot verify,
315
+ // surfacing as an opaque on-chain `EInvalidSignature` indistinguishable from a
316
+ // forgery. Reject it at the door instead, where the message can say what is
317
+ // actually wrong.
318
+ if (typeof raw.timestamp_ms !== "bigint") {
319
+ throw new Error("WaterX quote-center envelope is missing an integer timestamp_ms");
320
+ }
321
+ const items = raw.payload.items.map((item) => {
322
+ // The leaf door is structurally validated; this one was not, so a 200 with
323
+ // half-built items passed the wire gate and blew up mid-PTB-build inside
324
+ // `newItemArg` — after commands had already been appended to the caller's
325
+ // transaction. Both doors now reject before anything is built.
326
+ assertBatchItemShape(item);
327
+ return { ...item, num_sources: Number(item.num_sources) };
328
+ });
104
329
  return {
105
330
  intent: Number(raw.intent),
106
- timestamp_ms: (raw.timestamp_ms ?? 0n),
331
+ timestamp_ms: raw.timestamp_ms,
107
332
  signature: raw.signature,
108
- payload: {
109
- items: raw.payload.items.map((i) => ({ ...i, num_sources: Number(i.num_sources) })),
110
- },
333
+ payload: { items },
111
334
  };
112
335
  }
336
+ /**
337
+ * The envelope door's throwing form of {@link isBatchItemShape} — same gate the
338
+ * leaf door applies, so neither shape can carry a half-built item into a PTB.
339
+ */
340
+ function assertBatchItemShape(item) {
341
+ if (!isBatchItemShape(item)) {
342
+ const symbol = item?.symbol;
343
+ const label = typeof symbol === "string" ? symbol : "<unknown symbol>";
344
+ throw new Error(`WaterX quote-center envelope item ${label} is malformed — expected symbol, ticker, ` +
345
+ `method, a u8 num_sources, a non-empty u64 sources vector, and integer ` +
346
+ `${BATCH_ITEM_U64_FIELDS.join(", ")}.`);
347
+ }
348
+ }
349
+ /**
350
+ * A Merkle proof element must be a 32-byte keccak256 hash, and nothing else.
351
+ *
352
+ * The chain would only reject a malformed one at SUBMISSION, and opaquely: a
353
+ * short/long/garbage sibling folds to a root the enclave never signed, which
354
+ * surfaces as `EInvalidSignature` from `verify_merkle_root` — at that point
355
+ * indistinguishable from a genuinely forged signature. So it is checked twice,
356
+ * at both doors a leaf can come through: on the wire ({@link parseSignedLeaves})
357
+ * and again in the feed leg ({@link feedWaterxRuleWithProof}), which is the last
358
+ * gate for a leaf handed in by a consumer's prefetch cache instead of parsed
359
+ * here. Same reason the keeper's Rust builder checks the length.
360
+ */
361
+ function assertHash32(symbol, sibling) {
362
+ if (!isHexOfBytes(sibling, 32)) {
363
+ throw new Error(`WaterX leaf for ${symbol} carries a proof element that is not a 32-byte hex hash: ` +
364
+ `'${sibling}'`);
365
+ }
366
+ }
367
+ /**
368
+ * Parse a quote-center `/v1/quotes/leaves` response body (`{ leaves: [...] }`)
369
+ * into {@link WaterxSignedLeaf}s, u64s exact as `bigint`, rejecting a malformed
370
+ * leaf or proof element on the wire — before any PTB is touched.
371
+ */
372
+ export function parseSignedLeaves(text) {
373
+ const raw = parseWithExactIntegers(text, "leaf");
374
+ if (!Array.isArray(raw.leaves)) {
375
+ throw new Error("WaterX quote-center returned a malformed leaf response (expected { leaves })");
376
+ }
377
+ return raw.leaves.map((leaf, i) => {
378
+ if (!isSignedLeafShape(leaf)) {
379
+ // Name the leaf when it carried a usable symbol — a coverage gap and a
380
+ // malformed field read very differently to whoever is paging through this.
381
+ const named = leaf?.symbol;
382
+ const which = typeof named === "string" && named !== "" ? `'${named}'` : `at index ${i}`;
383
+ throw new Error(`WaterX quote-center returned a malformed signed leaf ${which}`);
384
+ }
385
+ for (const sibling of leaf.proof)
386
+ assertHash32(leaf.symbol, sibling);
387
+ return { ...leaf, num_sources: Number(leaf.num_sources) };
388
+ });
389
+ }
113
390
  /** The `waterx_rule` deployment entry; throws when the config carries none. */
114
391
  function requireWaterxPackage(host) {
115
392
  const entry = host.config.packages.waterx_rule;
@@ -148,75 +425,261 @@ function resolveWaterxInfra(host) {
148
425
  * `https://app.example/v1/quotes/update`, bypassing the proxy). Same footgun
149
426
  * that 404'd every Pyth Pro feed by dropping its `/hermes` prefix.
150
427
  */
151
- async function fetchWaterxSignedUpdate(endpoint, symbols, fetchOpts) {
152
- const url = joinEndpointPath(endpoint, "v1/quotes/update");
428
+ async function fetchQuoteCenter(endpoint, path, symbols, what, fetchOpts) {
429
+ const url = joinEndpointPath(endpoint, path);
153
430
  url.searchParams.set("symbols", symbols.join(","));
154
- let res;
155
431
  try {
156
- res = await fetchWithPolicy(url.toString(), { method: "GET" }, { ...fetchOpts });
432
+ return await fetchWithPolicy(url.toString(), { method: "GET" }, { ...fetchOpts });
157
433
  }
158
434
  catch (err) {
159
435
  if (err instanceof FetchPolicyError && err.status !== undefined) {
160
436
  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 });
437
+ throw new Error(`WaterX quote-center ${what} failed: ${err.status}${body} (retries exhausted after ${err.attempts} attempts)`, { cause: err });
162
438
  }
163
439
  throw err;
164
440
  }
441
+ }
442
+ /**
443
+ * Pull one enclave-signed batch envelope covering `symbols` — the fallback
444
+ * update shape AND the read executor's transport
445
+ * (`readQuoteCenterPrices` in `../read-prices.ts`). Public seam (WL-2345):
446
+ * consumers that need the raw envelope (BE prefetch caches, read planes)
447
+ * call this instead of re-rolling the fetch + intent/shape gate.
448
+ * `fellBackFrom`, when set, names the leaf-route failure that sent us here,
449
+ * so a deployment whose quote-center serves NEITHER route reports both
450
+ * statuses instead of only the second one.
451
+ */
452
+ export async function fetchWaterxSignedUpdate(endpoint, symbols, fetchOpts, fellBackFrom) {
453
+ const context = fellBackFrom ? ` (fell back from ${fellBackFrom})` : "";
454
+ // Unlike leaves, this route cannot be chunked: the response is ONE signature
455
+ // over the whole batch, so two envelopes are two different snapshots and the
456
+ // payload shape holds one. Say so explicitly rather than let the enclave
457
+ // answer a non-retryable 400 that reads as a generic fetch failure.
458
+ if (symbols.length > WATERX_MAX_BATCH_SYMBOLS) {
459
+ throw new Error(`WaterX quote-center batch fetch needs ${String(symbols.length)} symbols but the enclave ` +
460
+ `signs at most ${String(WATERX_MAX_BATCH_SYMBOLS)} per request, and a batch envelope ` +
461
+ `cannot be split (one signature covers the whole batch). Request fewer tickers, or use ` +
462
+ `a quote-center that serves the per-symbol leaf route, which IS chunked.${context}`);
463
+ }
464
+ const res = await fetchQuoteCenter(endpoint, "v1/quotes/update", symbols, "fetch", fetchOpts);
165
465
  if (!res.ok) {
166
- throw new Error(`WaterX quote-center fetch failed: ${res.status} ${await res.text()}`);
466
+ throw new Error(`WaterX quote-center fetch failed: ${res.status} ${await res.text()}${context}`);
167
467
  }
168
468
  // Parse from raw text (not res.json()) so the u64 fields are decoded exact as
169
469
  // bigint — see parseSignedEnvelope. Malformed-shape check lives there.
170
470
  const envelope = parseSignedEnvelope(await res.text());
171
471
  if (envelope.intent !== BATCH_PRICE_INTENT) {
172
- throw new Error(`WaterX quote-center returned intent ${envelope.intent}, expected BATCH_PRICE_INTENT ${BATCH_PRICE_INTENT}`);
472
+ throw new Error(`WaterX quote-center returned intent ${envelope.intent}, expected BATCH_PRICE_INTENT ${BATCH_PRICE_INTENT}${context}`);
173
473
  }
174
474
  return envelope;
175
475
  }
176
- /** Narrow a `RuleUpdateData` to its `WaterxSignedEnvelope`, or `null`. */
476
+ /**
477
+ * Pull per-symbol signed Merkle leaves — the DEFAULT update-data shape (see the
478
+ * module header for why it beats the indivisible batch envelope on a trade path).
479
+ *
480
+ * Returns `{ unavailable }` on `404` — and ONLY on 404, the one status that
481
+ * means "this route isn't here": a quote-center older than `/v1/quotes/leaves`
482
+ * has no handler registered for the path. That is the version-skew case the
483
+ * caller answers by falling back to the batch envelope, so the SDK and the
484
+ * quote-center can be deployed in either order.
485
+ *
486
+ * Everything else THROWS rather than falling back, INCLUDING 5xx (`501` among
487
+ * them — `fetchWithPolicy` classifies every 5xx as retryable and has already
488
+ * spent its retry budget by the time one surfaces here). A degraded or
489
+ * unreachable quote-center would fail the envelope route the same way — same
490
+ * service, same enclave behind it — so falling back would only double the
491
+ * latency of an already-failing money-path build, and would report an outage as
492
+ * a version skew.
493
+ *
494
+ * A 404 can ALSO mean "unknown symbol" (the quote-center 404s a symbol missing
495
+ * from its feed registry). That is config drift between this SDK's `feeds` and
496
+ * the quote-center's registry, and the fallback surfaces it honestly: the
497
+ * envelope route 404s on the same symbol, and its error names both attempts.
498
+ *
499
+ * Public seam (WL-2345): consumers holding per-symbol leaves (BE prefetch
500
+ * caches) pull through this instead of re-rolling the fetch + parse gate.
501
+ */
502
+ export async function fetchWaterxSignedLeaves(endpoint, symbols, fetchOpts) {
503
+ // Chunked against the enclave's per-request cap. Leaves are independently
504
+ // verifiable per symbol — each carries its own proof against a signed root —
505
+ // so splitting the request changes nothing about what the PTB can do with
506
+ // them. Unchunked, a universe prefetch or an all-markets refresh takes a
507
+ // non-retryable 400 the moment a deployment crosses the cap, surfacing as a
508
+ // bare "leaf fetch failed: 400" with nothing pointing at batch size.
509
+ const chunks = chunkSymbols(symbols);
510
+ // Probe with the first chunk: a quote-center with no leaf route answers 404
511
+ // for every chunk, so there is no point spending the rest to learn it.
512
+ const first = await fetchLeafChunk(endpoint, chunks[0] ?? [], fetchOpts);
513
+ if ("unavailable" in first || chunks.length <= 1)
514
+ return first;
515
+ const rest = await Promise.all(chunks.slice(1).map((chunk) => fetchLeafChunk(endpoint, chunk, fetchOpts)));
516
+ const leaves = [...first.leaves];
517
+ for (const pull of rest) {
518
+ if ("unavailable" in pull)
519
+ return pull;
520
+ leaves.push(...pull.leaves);
521
+ }
522
+ return { leaves };
523
+ }
524
+ /**
525
+ * The quote-center enclave signs at most this many symbols per request
526
+ * (`MAX_BATCH_SIZE` in `quote-service`). Over the cap it answers a
527
+ * non-retryable 400.
528
+ */
529
+ export const WATERX_MAX_BATCH_SYMBOLS = 32;
530
+ function chunkSymbols(symbols) {
531
+ if (symbols.length <= WATERX_MAX_BATCH_SYMBOLS)
532
+ return [symbols];
533
+ const out = [];
534
+ for (let i = 0; i < symbols.length; i += WATERX_MAX_BATCH_SYMBOLS) {
535
+ out.push(symbols.slice(i, i + WATERX_MAX_BATCH_SYMBOLS));
536
+ }
537
+ return out;
538
+ }
539
+ async function fetchLeafChunk(endpoint, symbols, fetchOpts) {
540
+ const res = await fetchQuoteCenter(endpoint, "v1/quotes/leaves", symbols, "leaf fetch", fetchOpts);
541
+ if (res.status === 404) {
542
+ return { unavailable: `GET /v1/quotes/leaves → 404 ${(await res.text()).trim()}`.trim() };
543
+ }
544
+ if (!res.ok) {
545
+ throw new Error(`WaterX quote-center leaf fetch failed: ${res.status} ${await res.text()}`);
546
+ }
547
+ // Raw text, not res.json(): u64s must survive as exact bigints.
548
+ return { leaves: parseSignedLeaves(await res.text()) };
549
+ }
550
+ /**
551
+ * Every requested ticker must be covered by what came back. A 200 whose items
552
+ * omit a requested symbol is a valid, well-signed response — nothing downstream
553
+ * would reject it, and the build would emit a collect call that abstains for the
554
+ * missing symbol, surfacing as an on-chain `EMissingPriceSource` (or a silently
555
+ * thinner weighted set) much later. Same coverage rule
556
+ * {@link WaterxRule.narrowUpdateData} enforces on a cached payload; the
557
+ * difference is disposition — a cache miss falls back to a live fetch, whereas
558
+ * the live source itself coming up short has no fallback left, so it throws.
559
+ */
560
+ function assertCoverage(what, tickers, covered) {
561
+ const have = new Set(covered);
562
+ const missing = tickers.filter((t) => !have.has(t));
563
+ if (missing.length > 0) {
564
+ throw new Error(`WaterX quote-center ${what} does not cover ticker(s): ${missing.join(", ")} ` +
565
+ `(requested ${tickers.join(", ")}; served ${[...have].join(", ") || "none"})`);
566
+ }
567
+ }
568
+ /**
569
+ * Narrow a `RuleUpdateData` to this rule's payload (either variant), or `null`.
570
+ * Kind/shape mismatches throw — see {@link assertRuleUpdateData}.
571
+ */
572
+ function waterxPayloadOf(data) {
573
+ return assertRuleUpdateData(data, "waterx_rule", isWaterxUpdatePayloadShape, "{ leaves: [...] } or { envelope: { intent, timestamp_ms, payload: { items }, signature } }");
574
+ }
575
+ /**
576
+ * Narrow a `RuleUpdateData` to its per-symbol {@link WaterxSignedLeaf}s, or
577
+ * `null` when it carries a batch envelope instead (the fallback shape).
578
+ */
579
+ export function waterxLeavesOf(data) {
580
+ const payload = waterxPayloadOf(data);
581
+ return payload && "leaves" in payload ? payload.leaves : null;
582
+ }
583
+ /**
584
+ * Narrow a `RuleUpdateData` to its `WaterxSignedEnvelope`, or `null` when it
585
+ * carries per-symbol leaves instead (the default shape).
586
+ */
177
587
  export function waterxEnvelopeOf(data) {
178
- const payload = assertRuleUpdateData(data, "waterx_rule", isWaterxUpdatePayloadShape, "{ envelope: { intent, timestamp_ms, payload: { items }, signature } }");
179
- return payload?.envelope ?? null;
588
+ const payload = waterxPayloadOf(data);
589
+ return payload && "envelope" in payload ? payload.envelope : null;
180
590
  }
181
591
  /** Strip an optional `0x` prefix, then decode hex → bytes. */
182
- function decodeSig(hex) {
592
+ function decodeHex(hex) {
183
593
  return fromHex(hex.startsWith("0x") ? hex.slice(2) : hex);
184
594
  }
595
+ /**
596
+ * `new_batch_item` with the item fields passed through VERBATIM: the u64s are
597
+ * already exact bigints (see {@link parseWithExactIntegers}), so the BCS the
598
+ * chain rebuilds matches the bytes the enclave signed — whether it re-verifies
599
+ * them as one item of a batch payload or as a Merkle leaf.
600
+ */
601
+ function newItemArg(tx, pkg, item) {
602
+ return newBatchItem({
603
+ package: pkg,
604
+ arguments: {
605
+ symbol: item.symbol,
606
+ ticker: item.ticker,
607
+ sources: item.sources,
608
+ method: item.method,
609
+ priceTimestampMs: item.price_timestamp_ms,
610
+ priceN: item.price_n,
611
+ priceScale: item.price_scale,
612
+ confidenceN: item.confidence_n,
613
+ confidenceScale: item.confidence_scale,
614
+ maxSourceDeviationBps: item.max_source_deviation_bps,
615
+ numSources: item.num_sources,
616
+ },
617
+ })(tx);
618
+ }
619
+ /**
620
+ * `waterx_rule::collect_single_with_proof(collector, config, clock,
621
+ * enclave_config, enclave, timestamp_ms, item, proof, sig)` — the DEFAULT feed
622
+ * leg. Rebuilds ONE item in-PTB and hands it over with its Merkle proof; on-chain
623
+ * the leaf is hashed, folded through the proof, and the enclave's signature over
624
+ * the resulting root is verified before the price reaches the collector.
625
+ *
626
+ * Cost is what makes this the default: one item + `proof.length` 32-byte hashes
627
+ * (~log2 of the snapshot width — 4 to 5 for the 29-feed mainnet registry),
628
+ * against {@link feedWaterxRule}'s obligation to rebuild every item the batch
629
+ * signature covers.
630
+ *
631
+ * Abort vs abstain is identical to the batch path (see the module header): a
632
+ * mismatched root, a bad signature, a future signed timestamp, or a config
633
+ * mismatch ABORTS; a freshness miss or a replayed signed timestamp abstains. One
634
+ * extra abort of its own — `ECollectorSymbolMismatch` if the leaf's symbol isn't
635
+ * the collector's — which `aggregateTicker` prevents by construction, since it
636
+ * looks the leaf up BY the ticker it just built the collector for.
637
+ */
638
+ export function feedWaterxRuleWithProof(tx, host, collector, leaf) {
639
+ const wr = requireWaterxPackage(host);
640
+ const pkg = wr.published_at;
641
+ const item = newItemArg(tx, pkg, leaf);
642
+ collectSingleWithProof({
643
+ package: pkg,
644
+ arguments: {
645
+ collector,
646
+ config: tx.object(wr.config),
647
+ enclaveConfig: tx.object(wr.enclave_config),
648
+ enclave: tx.object(wr.enclave),
649
+ timestampMs: leaf.signed_timestamp_ms,
650
+ item,
651
+ // vector<vector<u8>>: sibling hashes in fold order, each re-checked as a
652
+ // 32-byte hash (see assertHash32 — a cached leaf never passed the parser).
653
+ proof: leaf.proof.map((sibling) => {
654
+ assertHash32(leaf.symbol, sibling);
655
+ return Array.from(decodeHex(sibling));
656
+ }),
657
+ sig: Array.from(decodeHex(leaf.signature)),
658
+ },
659
+ })(tx);
660
+ }
185
661
  /**
186
662
  * `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.
663
+ * enclave, timestamp_ms, payload, sig)` — the FALLBACK feed leg, for a
664
+ * quote-center with no leaf route (and for callers that hold a whole batch).
665
+ * Rebuilds the enclave-signed batch payload in-PTB (`new_batch_payload` + one
666
+ * `new_batch_item`/`push_batch_item` per item, the exact shape the enclave
667
+ * signed) and contributes the price for `collector.symbol()` to the collector.
668
+ *
669
+ * Every item must be rebuilt, not just this collector's: the signature covers
670
+ * `BCS(IntentMessage)` over the whole vector, so a missing item is a failed
671
+ * verify. That is the cost {@link feedWaterxRuleWithProof} exists to avoid.
672
+ *
673
+ * On-chain it abstains (records `none`) when the symbol is stale, absent from the
674
+ * batch, or already recorded at this signed timestamp; it ABORTS on a bad
675
+ * signature, a signed timestamp ahead of the `Clock`, or a config mismatch.
196
676
  */
197
677
  export function feedWaterxRule(tx, host, collector, envelope) {
198
678
  const wr = requireWaterxPackage(host);
199
679
  const pkg = wr.published_at;
200
680
  const payload = newBatchPayload({ package: pkg })(tx);
201
681
  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);
682
+ const itemArg = newItemArg(tx, pkg, item);
220
683
  pushBatchItem({ package: pkg, arguments: { payload, item: itemArg } })(tx);
221
684
  }
222
685
  collectBatchLatest({
@@ -228,65 +691,141 @@ export function feedWaterxRule(tx, host, collector, envelope) {
228
691
  enclave: tx.object(wr.enclave),
229
692
  timestampMs: envelope.timestamp_ms,
230
693
  payload,
231
- sig: Array.from(decodeSig(envelope.signature)),
694
+ sig: Array.from(decodeHex(envelope.signature)),
232
695
  },
233
696
  })(tx);
234
697
  }
698
+ /**
699
+ * THE quote-center route ladder, owned by the rule that owns the protocol:
700
+ * pull per-symbol Merkle leaves (the default), and fall back to one batch
701
+ * envelope only when this quote-center has no leaf route (see
702
+ * {@link fetchWaterxSignedLeaves} for exactly which statuses mean that, and
703
+ * why nothing else falls back).
704
+ *
705
+ * Both the write path ({@link pullWaterxData}, which layers coverage policy on
706
+ * top) and the READ executor (`readQuoteCenterPrices` in `../read-prices.ts`,
707
+ * which only decodes prices) go through here, so which route wins, which
708
+ * status falls back, and how the fallback context is threaded are stated once.
709
+ * `items` is the flat symbol-bearing view both callers actually want —
710
+ * `WaterxSignedLeaf extends WaterxBatchItem`, so leaves widen to it for free —
711
+ * while `payload` keeps the shape-specific form the on-chain feed leg needs.
712
+ */
713
+ export async function pullWaterxQuotes(endpoint, symbols, fetchOpts) {
714
+ const pulled = await fetchWaterxSignedLeaves(endpoint, symbols, fetchOpts);
715
+ if ("leaves" in pulled) {
716
+ return { route: "leaves", payload: { leaves: pulled.leaves }, items: pulled.leaves };
717
+ }
718
+ const envelope = await fetchWaterxSignedUpdate(endpoint, symbols, fetchOpts, pulled.unavailable);
719
+ return { route: "envelope", payload: { envelope }, items: envelope.payload.items };
720
+ }
721
+ /**
722
+ * THE quote-center pull — the one pipeline both coverage policies share:
723
+ * package guard → own-key feeds partition → leaf route (default) → batch
724
+ * envelope only when this quote-center has no leaf route (see
725
+ * {@link fetchWaterxSignedLeaves} for exactly which statuses mean that, and
726
+ * why nothing else falls back).
727
+ *
728
+ * `coverage` decides only what a GAP means, never how the pull runs:
729
+ *
730
+ * - `"strict"` — every requested ticker must be config-listed AND served, or
731
+ * this THROWS (the per-ticker unlisted message, before any network call;
732
+ * then {@link assertCoverage} naming the route). `missing` is always `[]`.
733
+ * - `"partial"` — an unlisted or unserved ticker lands in `missing` and the
734
+ * payload covers the rest, narrowed through the rule's own
735
+ * {@link WaterxRule.narrowUpdateData} so leaf-vs-envelope divisibility has
736
+ * exactly one definition.
737
+ */
738
+ async function pullWaterxData(host, tickers, coverage) {
739
+ // Package-level check first: a config without the deployment must say so,
740
+ // not fail per ticker as if only that feed were missing.
741
+ const { feeds } = requireWaterxPackage(host);
742
+ // One partition pass, own-keys-only: a prototype-key ticker ("toString")
743
+ // must read as unlisted, not pass as an inherited Function and reach the
744
+ // network.
745
+ const missing = [];
746
+ const listed = [];
747
+ for (const ticker of tickers) {
748
+ (ownEntry(feeds, ticker) === undefined ? missing : listed).push(ticker);
749
+ }
750
+ // Unlisted tickers never reach the network on EITHER policy — the
751
+ // quote-center 404s a whole batch on one unknown symbol. Strict surfaces
752
+ // the per-ticker message; partial just records the gap and pulls the rest.
753
+ if (coverage === "strict" && missing.length > 0) {
754
+ throw new Error(`No waterx_rule feed listed for ticker: ${missing[0]}`);
755
+ }
756
+ if (listed.length === 0)
757
+ return { data: null, missing };
758
+ const { endpoint, fetch: fetchOpts } = resolveWaterxInfra(host);
759
+ const { route, payload, items } = await pullWaterxQuotes(endpoint, listed, fetchOpts);
760
+ const served = new Set(items.map((item) => item.symbol));
761
+ if (coverage === "strict") {
762
+ assertCoverage(route, listed, served);
763
+ return { data: { kind: "waterx_rule", payload }, missing };
764
+ }
765
+ const covered = [];
766
+ for (const ticker of listed)
767
+ (served.has(ticker) ? covered : missing).push(ticker);
768
+ // Divisibility is the rule's own knowledge, so the subset decision is
769
+ // delegated rather than re-encoded here: leaves subset per symbol, an
770
+ // envelope is indivisible and passes whole (or `null` when it covers none).
771
+ return {
772
+ data: covered.length > 0
773
+ ? WaterxRule.narrowUpdateData(host, { kind: "waterx_rule", payload }, covered)
774
+ : null,
775
+ missing,
776
+ };
777
+ }
235
778
  export const WaterxRule = {
236
779
  kind: "waterx_rule",
237
- // Verification is an in-Move ed25519 check with no Coin argument — no
238
- // update fee — see `PriceUpdateRule.requiresFeeSource`.
239
- requiresFeeSource: false,
780
+ // No credential: the quote-center read surface is public (no `credential`
781
+ // declared — see `PriceUpdateRule.credential`).
240
782
  /** Tickers with a `waterx_rule.feeds` entry (keyed by oracle ticker). */
241
783
  supportedTickers(host) {
242
784
  return Object.keys(host.config.packages.waterx_rule?.feeds ?? {});
243
785
  },
244
786
  /**
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
- * envelope — nothing 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.
787
+ * Pulls per-symbol Merkle leaves for `tickers`, falling back to one batch
788
+ * envelope only when this quote-center has no leaf route. Returns only what
789
+ * covers ALL of `tickers` — the strict arm of {@link pullWaterxData}.
256
790
  */
257
791
  async fetchUpdateData(host, tickers) {
258
792
  if (tickers.length === 0)
259
793
  return null;
260
- // Package-level check first: a config without the deployment must say so,
261
- // not fail per ticker as if only that feed were missing.
262
- const { feeds } = requireWaterxPackage(host);
263
- for (const ticker of tickers) {
264
- // ownEntry: a prototype-key ticker ("toString") must throw here as
265
- // unlisted, not pass as an inherited Function and reach the network.
266
- if (ownEntry(feeds, ticker) === undefined) {
267
- throw new Error(`No waterx_rule feed listed for ticker: ${ticker}`);
268
- }
269
- }
270
- 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"})`);
277
- }
278
- return { kind: "waterx_rule", payload: { envelope } };
794
+ return (await pullWaterxData(host, tickers, "strict")).data;
279
795
  },
280
796
  /**
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
797
+ * Divisibility differs by variant, which is the whole reason this method
798
+ * belongs to the rule and not to its consumers:
799
+ *
800
+ * - **Leaves** are per-symbol and independently verifiable (each carries its
801
+ * own proof + root signature), so a wider payload — e.g. a whole-universe
802
+ * prefetch cache — is SUBSET to exactly `tickers`. A trade then carries one
803
+ * leaf instead of the whole cached snapshot.
804
+ * - **A batch envelope** carries a single signature over its whole `payload`
805
+ * and is indivisible: it is returned whole, or not at all.
806
+ *
807
+ * Either way a ticker this payload cannot serve → `null` (miss), never a
285
808
  * silent partial.
286
809
  */
287
810
  narrowUpdateData(_host, data, tickers) {
811
+ if (tickers.length === 0)
812
+ return null;
813
+ const leaves = waterxLeavesOf(data);
814
+ if (leaves) {
815
+ // Indexed by symbol, then walked in REQUESTED order: a `filter` would let
816
+ // a payload that repeats one symbol and omits another pass on count alone.
817
+ const bySymbol = new Map(leaves.map((l) => [l.symbol, l]));
818
+ const subset = [];
819
+ for (const ticker of tickers) {
820
+ const leaf = bySymbol.get(ticker);
821
+ if (!leaf)
822
+ return null;
823
+ subset.push(leaf);
824
+ }
825
+ return { kind: "waterx_rule", payload: { leaves: subset } };
826
+ }
288
827
  const envelope = waterxEnvelopeOf(data);
289
- if (!envelope || tickers.length === 0)
828
+ if (!envelope)
290
829
  return null;
291
830
  const covered = new Set(envelope.payload.items.map((i) => i.symbol));
292
831
  for (const ticker of tickers) {
@@ -296,13 +835,65 @@ export const WaterxRule = {
296
835
  return { kind: "waterx_rule", payload: { envelope } };
297
836
  },
298
837
  /**
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`.
838
+ * The on-chain F-014 single-use replay key, rule-owned (see
839
+ * `PriceUpdateRule.updateIdentityBySymbol`): each leaf's identity is its
840
+ * OWN `signed_timestamp_ms`; an envelope's one `timestamp_ms` is the
841
+ * identity of EVERY symbol it covers (one batch signature ⇒ one submission
842
+ * burns the mark for all of them). A consumer's serve-at-most-once cache
843
+ * keys off this map for BOTH payload shapes — including the leaf-first
844
+ * default an envelope-only identity check would miss.
845
+ */
846
+ updateIdentityBySymbol(data) {
847
+ // Narrowed ONCE: a serve-at-most-once cache calls this per serve, and the
848
+ // accessors each re-run the full structural payload validation.
849
+ const payload = waterxPayloadOf(data);
850
+ if (!payload)
851
+ return null;
852
+ return "leaves" in payload
853
+ ? new Map(payload.leaves.map((leaf) => [leaf.symbol, leaf.signed_timestamp_ms]))
854
+ : new Map(payload.envelope.payload.items.map((item) => [
855
+ item.symbol,
856
+ payload.envelope.timestamp_ms,
857
+ ]));
858
+ },
859
+ /**
860
+ * No shared verify step: both `waterx_rule` collect entries bundle verify AND
861
+ * feed into one per-collector call, appended by
862
+ * {@link feedWaterxRuleWithProof} / {@link feedWaterxRule} in the per-ticker
863
+ * aggregate leg. So this emits nothing and returns `void` — the signed data
864
+ * reaches the feed leg via `aggregate.ts`'s per-ticker map (built from the
865
+ * group's fetched data), not a `RuleUpdateHandle`.
304
866
  */
305
- buildUpdateCalls(_tx, _host, _data, _opts) {
867
+ buildUpdateCalls(_tx, _host, _data) {
306
868
  return;
307
869
  },
308
870
  };
871
+ /**
872
+ * Coverage-policy seam over the rule's quote-center pull (WL-2345): fetch
873
+ * signed waterx update data for `tickers` with the caller choosing what a
874
+ * coverage gap means.
875
+ *
876
+ * - `coverage: "strict"` (default) — exactly `WaterxRule.fetchUpdateData`:
877
+ * every requested ticker must be config-listed AND served, or the fetch
878
+ * THROWS (`assertCoverage`); `missing` is always `[]`. Trade-path semantics
879
+ * — `refreshOraclePrices` keeps consuming the rule's own strict fetch, so
880
+ * `aggregate.ts`'s uncarried-ticker throw (04117a1) still can't be reached
881
+ * by a payload that under-covers its group.
882
+ * - `coverage: "partial"` — universe-prefetch semantics (a BE cache warming
883
+ * every known ticker at once): a ticker with no `waterx_rule.feeds` entry,
884
+ * or one the quote-center response does not serve, lands in `missing`
885
+ * instead of throwing, and `data` covers the rest. On the leaf route the
886
+ * payload is the covering leaf SUBSET; on the envelope route the envelope
887
+ * is kept iff it covers ≥1 requested ticker (it is indivisible — an
888
+ * envelope serving none is `data: null`). `data: null` + all-missing when
889
+ * nothing is servable.
890
+ *
891
+ * Consumers must not hand a partial payload to a build for tickers in
892
+ * `missing` — those tickers are simply not servable by waterx right now (log
893
+ * the gap; the chain's weight tables decide whether that starves anything).
894
+ */
895
+ export async function fetchWaterxUpdateData(host, tickers, opts) {
896
+ if (tickers.length === 0)
897
+ return { data: null, missing: [] };
898
+ return pullWaterxData(host, tickers, opts?.coverage ?? "strict");
899
+ }