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