@waterx/sdk 4.3.3 → 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 (145) hide show
  1. package/.claude/skills/waterx-sdk-integration/SKILL.md +41 -35
  2. package/README.md +81 -77
  3. package/dist/cjs/src/account/config.d.ts +3 -5
  4. package/dist/cjs/src/account/funding/wormhole.d.ts +1 -2
  5. package/dist/cjs/src/account/funding/wormhole.js +1 -2
  6. package/dist/cjs/src/oracle/aggregate.d.ts +45 -61
  7. package/dist/cjs/src/oracle/aggregate.js +138 -138
  8. package/dist/cjs/src/oracle/config.d.ts +35 -49
  9. package/dist/cjs/src/oracle/config.js +1 -1
  10. package/dist/cjs/src/oracle/host.d.ts +14 -19
  11. package/dist/cjs/src/oracle/host.js +3 -3
  12. package/dist/cjs/src/oracle/index.d.ts +25 -16
  13. package/dist/cjs/src/oracle/index.js +79 -66
  14. package/dist/cjs/src/oracle/price-update-rule.d.ts +104 -66
  15. package/dist/cjs/src/oracle/price-update-rule.js +24 -16
  16. package/dist/cjs/src/oracle/pyth-pro-history.d.ts +36 -0
  17. package/dist/cjs/src/oracle/pyth-pro-history.js +41 -0
  18. package/dist/cjs/src/oracle/read-plane.d.ts +35 -53
  19. package/dist/cjs/src/oracle/read-plane.js +26 -48
  20. package/dist/cjs/src/oracle/read-prices.d.ts +105 -0
  21. package/dist/cjs/src/oracle/read-prices.js +162 -0
  22. package/dist/cjs/src/oracle/rule-registry.d.ts +12 -12
  23. package/dist/cjs/src/oracle/rule-registry.js +12 -14
  24. package/dist/cjs/src/oracle/rules/pyth-lazer-rule.d.ts +24 -3
  25. package/dist/cjs/src/oracle/rules/pyth-lazer-rule.js +40 -20
  26. package/dist/cjs/src/oracle/rules/waterx-rule.d.ts +137 -12
  27. package/dist/cjs/src/oracle/rules/waterx-rule.js +318 -55
  28. package/dist/cjs/src/oracle/schedule.d.ts +109 -0
  29. package/dist/cjs/src/oracle/schedule.js +991 -0
  30. package/dist/cjs/src/oracle/source-list.d.ts +31 -27
  31. package/dist/cjs/src/oracle/source-list.js +43 -45
  32. package/dist/cjs/src/oracle/symbol-catalog.d.ts +54 -0
  33. package/dist/cjs/src/oracle/symbol-catalog.js +69 -0
  34. package/dist/cjs/src/oracle/update-fetch.d.ts +11 -15
  35. package/dist/cjs/src/oracle/update-fetch.js +12 -19
  36. package/dist/cjs/src/oracle/validate.d.ts +105 -0
  37. package/dist/cjs/src/oracle/validate.js +185 -0
  38. package/dist/cjs/src/oracle/weight-coverage.d.ts +79 -0
  39. package/dist/cjs/src/oracle/weight-coverage.js +173 -0
  40. package/dist/cjs/src/perp/client.d.ts +35 -52
  41. package/dist/cjs/src/perp/client.js +44 -17
  42. package/dist/cjs/src/perp/config-view.d.ts +5 -14
  43. package/dist/cjs/src/perp/config-view.js +5 -16
  44. package/dist/cjs/src/perp/config.d.ts +3 -3
  45. package/dist/cjs/src/perp/config.js +11 -13
  46. package/dist/cjs/src/perp/index.d.ts +4 -6
  47. package/dist/cjs/src/perp/index.js +35 -18
  48. package/dist/cjs/src/perp/tx-builders/common.d.ts +86 -48
  49. package/dist/cjs/src/perp/tx-builders/common.js +115 -47
  50. package/dist/cjs/src/perp/tx-builders/wlp.d.ts +3 -14
  51. package/dist/cjs/src/perp/tx-builders/wlp.js +6 -35
  52. package/dist/cjs/src/perp/tx-builders.d.ts +6 -5
  53. package/dist/cjs/src/perp/tx-builders.js +16 -10
  54. package/dist/cjs/src/unified-client.d.ts +8 -34
  55. package/dist/cjs/src/unified-client.js +3 -4
  56. package/dist/cjs/src/utils/config.d.ts +0 -8
  57. package/dist/cjs/src/utils/config.js +5 -13
  58. package/dist/src/account/config.d.ts +3 -5
  59. package/dist/src/account/funding/wormhole.d.ts +1 -2
  60. package/dist/src/account/funding/wormhole.js +1 -2
  61. package/dist/src/oracle/aggregate.d.ts +45 -61
  62. package/dist/src/oracle/aggregate.js +138 -137
  63. package/dist/src/oracle/config.d.ts +35 -49
  64. package/dist/src/oracle/config.js +1 -1
  65. package/dist/src/oracle/host.d.ts +14 -19
  66. package/dist/src/oracle/host.js +3 -3
  67. package/dist/src/oracle/index.d.ts +25 -16
  68. package/dist/src/oracle/index.js +55 -50
  69. package/dist/src/oracle/price-update-rule.d.ts +104 -66
  70. package/dist/src/oracle/price-update-rule.js +23 -16
  71. package/dist/src/oracle/pyth-pro-history.d.ts +36 -0
  72. package/dist/src/oracle/pyth-pro-history.js +38 -0
  73. package/dist/src/oracle/read-plane.d.ts +35 -53
  74. package/dist/src/oracle/read-plane.js +25 -47
  75. package/dist/src/oracle/read-prices.d.ts +105 -0
  76. package/dist/src/oracle/read-prices.js +156 -0
  77. package/dist/src/oracle/rule-registry.d.ts +12 -12
  78. package/dist/src/oracle/rule-registry.js +12 -14
  79. package/dist/src/oracle/rules/pyth-lazer-rule.d.ts +24 -3
  80. package/dist/src/oracle/rules/pyth-lazer-rule.js +39 -20
  81. package/dist/src/oracle/rules/waterx-rule.d.ts +137 -12
  82. package/dist/src/oracle/rules/waterx-rule.js +314 -56
  83. package/dist/src/oracle/schedule.d.ts +109 -0
  84. package/dist/src/oracle/schedule.js +985 -0
  85. package/dist/src/oracle/source-list.d.ts +31 -27
  86. package/dist/src/oracle/source-list.js +42 -43
  87. package/dist/src/oracle/symbol-catalog.d.ts +54 -0
  88. package/dist/src/oracle/symbol-catalog.js +65 -0
  89. package/dist/src/oracle/update-fetch.d.ts +11 -15
  90. package/dist/src/oracle/update-fetch.js +12 -18
  91. package/dist/src/oracle/validate.d.ts +105 -0
  92. package/dist/src/oracle/validate.js +177 -0
  93. package/dist/src/oracle/weight-coverage.d.ts +79 -0
  94. package/dist/src/oracle/weight-coverage.js +166 -0
  95. package/dist/src/perp/client.d.ts +35 -52
  96. package/dist/src/perp/client.js +45 -18
  97. package/dist/src/perp/config-view.d.ts +5 -14
  98. package/dist/src/perp/config-view.js +5 -16
  99. package/dist/src/perp/config.d.ts +3 -3
  100. package/dist/src/perp/config.js +11 -13
  101. package/dist/src/perp/index.d.ts +4 -6
  102. package/dist/src/perp/index.js +11 -5
  103. package/dist/src/perp/tx-builders/common.d.ts +86 -48
  104. package/dist/src/perp/tx-builders/common.js +114 -48
  105. package/dist/src/perp/tx-builders/wlp.d.ts +3 -14
  106. package/dist/src/perp/tx-builders/wlp.js +6 -35
  107. package/dist/src/perp/tx-builders.d.ts +6 -5
  108. package/dist/src/perp/tx-builders.js +15 -6
  109. package/dist/src/unified-client.d.ts +8 -34
  110. package/dist/src/unified-client.js +3 -4
  111. package/dist/src/utils/config.d.ts +0 -8
  112. package/dist/src/utils/config.js +5 -12
  113. package/package.json +1 -1
  114. package/dist/cjs/src/generated/pyth_sponsor_rule/deps/sui/balance.d.ts +0 -12
  115. package/dist/cjs/src/generated/pyth_sponsor_rule/deps/sui/balance.js +0 -17
  116. package/dist/cjs/src/generated/pyth_sponsor_rule/pyth_sponsor_rule.d.ts +0 -68
  117. package/dist/cjs/src/generated/pyth_sponsor_rule/pyth_sponsor_rule.js +0 -113
  118. package/dist/cjs/src/generated/waterx_pyth_rule/deps/sui/vec_map.d.ts +0 -36
  119. package/dist/cjs/src/generated/waterx_pyth_rule/deps/sui/vec_map.js +0 -31
  120. package/dist/cjs/src/generated/waterx_pyth_rule/pyth_rule.d.ts +0 -192
  121. package/dist/cjs/src/generated/waterx_pyth_rule/pyth_rule.js +0 -214
  122. package/dist/cjs/src/oracle/pyth.d.ts +0 -227
  123. package/dist/cjs/src/oracle/pyth.js +0 -652
  124. package/dist/cjs/src/oracle/rules/pyth-core-rule.d.ts +0 -15
  125. package/dist/cjs/src/oracle/rules/pyth-core-rule.js +0 -100
  126. package/dist/cjs/src/oracle/rules/pyth-rule.d.ts +0 -11
  127. package/dist/cjs/src/oracle/rules/pyth-rule.js +0 -29
  128. package/dist/cjs/src/oracle/rules/sponsor.d.ts +0 -32
  129. package/dist/cjs/src/oracle/rules/sponsor.js +0 -56
  130. package/dist/src/generated/pyth_sponsor_rule/deps/sui/balance.d.ts +0 -12
  131. package/dist/src/generated/pyth_sponsor_rule/deps/sui/balance.js +0 -14
  132. package/dist/src/generated/pyth_sponsor_rule/pyth_sponsor_rule.d.ts +0 -68
  133. package/dist/src/generated/pyth_sponsor_rule/pyth_sponsor_rule.js +0 -73
  134. package/dist/src/generated/waterx_pyth_rule/deps/sui/vec_map.d.ts +0 -36
  135. package/dist/src/generated/waterx_pyth_rule/deps/sui/vec_map.js +0 -27
  136. package/dist/src/generated/waterx_pyth_rule/pyth_rule.d.ts +0 -192
  137. package/dist/src/generated/waterx_pyth_rule/pyth_rule.js +0 -170
  138. package/dist/src/oracle/pyth.d.ts +0 -227
  139. package/dist/src/oracle/pyth.js +0 -638
  140. package/dist/src/oracle/rules/pyth-core-rule.d.ts +0 -15
  141. package/dist/src/oracle/rules/pyth-core-rule.js +0 -97
  142. package/dist/src/oracle/rules/pyth-rule.d.ts +0 -11
  143. package/dist/src/oracle/rules/pyth-rule.js +0 -26
  144. package/dist/src/oracle/rules/sponsor.d.ts +0 -32
  145. package/dist/src/oracle/rules/sponsor.js +0 -52
@@ -41,9 +41,12 @@ import { collectBatchLatest, collectSingleWithProof, newBatchItem, newBatchPaylo
41
41
  import { ownEntry } from "../../utils/record.js";
42
42
  import { assertRuleUpdateData, } from "../price-update-rule.js";
43
43
  import { FetchPolicyError, fetchWithPolicy, joinEndpointPath, } from "../update-fetch.js";
44
- /** Intent the quote-center signs a whole BATCH payload under exported so
45
- * read-plane consumers can mirror the rule's own envelope intent check (a
46
- * mispointed endpoint must be rejected by reads exactly as tx-builds reject it). */
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. */
47
50
  export const BATCH_PRICE_INTENT = 1;
48
51
  /**
49
52
  * Intent the quote-center signs a snapshot's Merkle ROOT under
@@ -57,12 +60,11 @@ export const BATCH_PRICE_INTENT = 1;
57
60
  export const MERKLE_ROOT_INTENT = 2;
58
61
  /**
59
62
  * WaterX quote-center external infra — owned by THIS source, by network.
60
- * Mirrors `PYTH_CORE_INFRA` (oracle/pyth.ts) and `LAZER_INFRA`
61
- * (rules/pyth-lazer-rule.ts): per-network constants for infrastructure the
62
- * source's operator runs, co-located with the only rule that reads them — no
63
- * other oracle source ever touches a quote-center endpoint. Public read (no
64
- * auth), so there is no api_key. `endpoint` has no trailing slash — the rule
65
- * 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.
66
68
  *
67
69
  * These are the DEFAULTS behind the caller's `client.waterx` access slice
68
70
  * (`waterxEndpoint` / `waterxFetch` create options) — the browser-CORS proxy
@@ -75,34 +77,83 @@ export const WATERX_INFRA = {
75
77
  /**
76
78
  * The waterx source's quote-center base for `network` — the ONE accessor
77
79
  * consumers (BE/FE read planes) use when, and only when, their own
78
- * `ORACLE_SOURCE` resolves to `'waterx_rule'`. Mirrors
79
- * `pythCoreHermesEndpoint`. Under any other source the read endpoint is that
80
- * 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.
81
82
  */
82
83
  export function waterxQuoteCenterEndpoint(network) {
83
84
  return WATERX_INFRA[network].endpoint;
84
85
  }
86
+ /**
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.)
93
+ */
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
+ */
85
129
  function isWaterxEnvelopePayloadShape(payload) {
86
130
  const env = payload?.envelope;
87
131
  return (typeof env === "object" &&
88
132
  env !== null &&
89
- typeof env.signature === "string" &&
133
+ // ed25519 over the batch — a wrong-length signature is an on-chain abort.
134
+ isHexOfBytes(env.signature, 64) &&
90
135
  typeof env.timestamp_ms === "bigint" &&
91
- Array.isArray(env.payload?.items));
136
+ env.timestamp_ms >= 0n &&
137
+ Array.isArray(env.payload?.items) &&
138
+ env.payload.items.every(isBatchItemShape));
92
139
  }
93
140
  function isWaterxLeafPayloadShape(payload) {
94
141
  const leaves = payload?.leaves;
95
142
  return Array.isArray(leaves) && leaves.every(isSignedLeafShape);
96
143
  }
97
144
  /** Every u64 field of a leaf — each one is signed, so each must be present and exact. */
98
- const LEAF_U64_FIELDS = [
145
+ /**
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 = [
99
151
  "price_timestamp_ms",
100
152
  "price_n",
101
153
  "price_scale",
102
154
  "confidence_n",
103
155
  "confidence_scale",
104
156
  "max_source_deviation_bps",
105
- "signed_timestamp_ms",
106
157
  ];
107
158
  /**
108
159
  * FULL structural check on a leaf, not just the fields the feed leg happens to
@@ -131,8 +182,15 @@ function isHexOfBytes(hex, bytes) {
131
182
  const body = hex.startsWith("0x") ? hex.slice(2) : hex;
132
183
  return body.length === bytes * 2 && HEX_ONLY.test(body);
133
184
  }
134
- function isSignedLeafShape(leaf) {
135
- const l = leaf;
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;
136
194
  if (typeof l !== "object" || l === null)
137
195
  return false;
138
196
  if (typeof l.symbol !== "string" || l.symbol === "")
@@ -163,11 +221,20 @@ function isSignedLeafShape(leaf) {
163
221
  return false;
164
222
  if (l.sources.some((s) => typeof s !== "bigint" || s < 0n))
165
223
  return false;
166
- for (const field of LEAF_U64_FIELDS) {
224
+ for (const field of BATCH_ITEM_U64_FIELDS) {
167
225
  const v = l[field];
168
226
  if (typeof v !== "bigint" || v < 0n)
169
227
  return false;
170
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;
171
238
  // ed25519 is always 64 bytes and the root is always a 32-byte keccak256; a
172
239
  // wrong-length signature is an on-chain abort, so it is rejected here.
173
240
  if (!isHexOfBytes(l.signature, 64))
@@ -243,15 +310,42 @@ export function parseSignedEnvelope(text) {
243
310
  if (typeof raw.signature !== "string" || !Array.isArray(raw.payload?.items)) {
244
311
  throw new Error("WaterX quote-center returned a malformed signed envelope");
245
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
+ });
246
329
  return {
247
330
  intent: Number(raw.intent),
248
- timestamp_ms: (raw.timestamp_ms ?? 0n),
331
+ timestamp_ms: raw.timestamp_ms,
249
332
  signature: raw.signature,
250
- payload: {
251
- items: raw.payload.items.map((i) => ({ ...i, num_sources: Number(i.num_sources) })),
252
- },
333
+ payload: { items },
253
334
  };
254
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
+ }
255
349
  /**
256
350
  * A Merkle proof element must be a 32-byte keccak256 hash, and nothing else.
257
351
  *
@@ -346,13 +440,27 @@ async function fetchQuoteCenter(endpoint, path, symbols, what, fetchOpts) {
346
440
  }
347
441
  }
348
442
  /**
349
- * Pull one enclave-signed batch envelope covering `symbols`. `fellBackFrom`, when
350
- * set, names the leaf-route failure that sent us here, so a deployment whose
351
- * quote-center serves NEITHER route reports both statuses instead of only the
352
- * second one.
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.
353
451
  */
354
- async function fetchWaterxSignedUpdate(endpoint, symbols, fetchOpts, fellBackFrom) {
452
+ export async function fetchWaterxSignedUpdate(endpoint, symbols, fetchOpts, fellBackFrom) {
355
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
+ }
356
464
  const res = await fetchQuoteCenter(endpoint, "v1/quotes/update", symbols, "fetch", fetchOpts);
357
465
  if (!res.ok) {
358
466
  throw new Error(`WaterX quote-center fetch failed: ${res.status} ${await res.text()}${context}`);
@@ -387,8 +495,48 @@ async function fetchWaterxSignedUpdate(endpoint, symbols, fetchOpts, fellBackFro
387
495
  * from its feed registry). That is config drift between this SDK's `feeds` and
388
496
  * the quote-center's registry, and the fallback surfaces it honestly: the
389
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.
390
501
  */
391
- async function fetchWaterxSignedLeaves(endpoint, symbols, fetchOpts) {
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) {
392
540
  const res = await fetchQuoteCenter(endpoint, "v1/quotes/leaves", symbols, "leaf fetch", fetchOpts);
393
541
  if (res.status === 404) {
394
542
  return { unavailable: `GET /v1/quotes/leaves → 404 ${(await res.text()).trim()}`.trim() };
@@ -547,44 +695,103 @@ export function feedWaterxRule(tx, host, collector, envelope) {
547
695
  },
548
696
  })(tx);
549
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
+ }
550
778
  export const WaterxRule = {
551
779
  kind: "waterx_rule",
552
- // Verification is an in-Move ed25519 check with no Coin argument — no
553
- // update fee — see `PriceUpdateRule.requiresFeeSource`.
554
- requiresFeeSource: false,
780
+ // No credential: the quote-center read surface is public (no `credential`
781
+ // declared — see `PriceUpdateRule.credential`).
555
782
  /** Tickers with a `waterx_rule.feeds` entry (keyed by oracle ticker). */
556
783
  supportedTickers(host) {
557
784
  return Object.keys(host.config.packages.waterx_rule?.feeds ?? {});
558
785
  },
559
786
  /**
560
787
  * Pulls per-symbol Merkle leaves for `tickers`, falling back to one batch
561
- * envelope only when this quote-center has no leaf route (see
562
- * {@link fetchWaterxSignedLeaves} for exactly which statuses mean that, and
563
- * why nothing else falls back). Either way, returns only what covers ALL of
564
- * `tickers` — see {@link assertCoverage}.
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}.
565
790
  */
566
791
  async fetchUpdateData(host, tickers) {
567
792
  if (tickers.length === 0)
568
793
  return null;
569
- // Package-level check first: a config without the deployment must say so,
570
- // not fail per ticker as if only that feed were missing.
571
- const { feeds } = requireWaterxPackage(host);
572
- for (const ticker of tickers) {
573
- // ownEntry: a prototype-key ticker ("toString") must throw here as
574
- // unlisted, not pass as an inherited Function and reach the network.
575
- if (ownEntry(feeds, ticker) === undefined) {
576
- throw new Error(`No waterx_rule feed listed for ticker: ${ticker}`);
577
- }
578
- }
579
- const { endpoint, fetch: fetchOpts } = resolveWaterxInfra(host);
580
- const pulled = await fetchWaterxSignedLeaves(endpoint, tickers, fetchOpts);
581
- if ("leaves" in pulled) {
582
- assertCoverage("leaves", tickers, pulled.leaves.map((l) => l.symbol));
583
- return { kind: "waterx_rule", payload: { leaves: pulled.leaves } };
584
- }
585
- const envelope = await fetchWaterxSignedUpdate(endpoint, tickers, fetchOpts, pulled.unavailable);
586
- assertCoverage("envelope", tickers, envelope.payload.items.map((i) => i.symbol));
587
- return { kind: "waterx_rule", payload: { envelope } };
794
+ return (await pullWaterxData(host, tickers, "strict")).data;
588
795
  },
589
796
  /**
590
797
  * Divisibility differs by variant, which is the whole reason this method
@@ -627,6 +834,28 @@ export const WaterxRule = {
627
834
  }
628
835
  return { kind: "waterx_rule", payload: { envelope } };
629
836
  },
837
+ /**
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
+ },
630
859
  /**
631
860
  * No shared verify step: both `waterx_rule` collect entries bundle verify AND
632
861
  * feed into one per-collector call, appended by
@@ -635,7 +864,36 @@ export const WaterxRule = {
635
864
  * reaches the feed leg via `aggregate.ts`'s per-ticker map (built from the
636
865
  * group's fetched data), not a `RuleUpdateHandle`.
637
866
  */
638
- buildUpdateCalls(_tx, _host, _data, _opts) {
867
+ buildUpdateCalls(_tx, _host, _data) {
639
868
  return;
640
869
  },
641
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
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * `schedule.ts` — Pyth `attributes.schedule` grammar parser + the pure
3
+ * market-status walker (WL-2345): the ONE implementation both consumers fold
4
+ * onto (the FE fallback `/api/markets` route and the BE
5
+ * `PythScheduleService` / markets service carried drifting twins).
6
+ *
7
+ * Parser grammar (https://docs.pyth.network/price-feeds/market-hours):
8
+ *
9
+ * {IANA_timezone};{Mon},{Tue},{Wed},{Thu},{Fri},{Sat},{Sun};{holidays_csv}
10
+ *
11
+ * Each weekday slot is `Open`/`O`/`open`, `Closed`/`C`/`closed`, one
12
+ * `HHMM-HHMM` range, `&`-joined ranges (the new multi-session encoding), or
13
+ * comma-joined ranges (the old encoding — disambiguated from the weekday
14
+ * separator by lookahead). Holidays are `MMDD` (old) or `MMDD/C` (new; a
15
+ * `MMDD/HHMM-HHMM` modified-hours entry is NOT a closure and is skipped, as
16
+ * are non-MMDD sentinels like Pyth's `"0"`). This is the reconciled SUPERSET
17
+ * of the two prior ports — accepting either era's tokens is what lets one
18
+ * parser serve the `v1/symbols` catalog and any cached Hermes-era strings.
19
+ *
20
+ * Pyth weekday order is Mon=0..Sun=6; `TradingHours.days` uses ISO-ish
21
+ * Sun=0..Sat=6 (converted in `groupIntoSessions`).
22
+ *
23
+ * The schedule catalog itself comes from `fetchPythSymbolCatalog`
24
+ * (`symbol-catalog.ts`); pairing a record's `schedule` with this parser and
25
+ * `getMarketStatus` is the whole market-hours pipeline.
26
+ */
27
+ export interface TradingSession {
28
+ /** HH:MM (24h) open time in the market's timezone */
29
+ open: string;
30
+ /** HH:MM (24h) close time — may be next day (e.g. "17:00" Sunday open to "17:00" Friday) */
31
+ close: string;
32
+ /** ISO day-of-week: 0=Sunday … 6=Saturday */
33
+ days: number[];
34
+ }
35
+ /**
36
+ * Year-agnostic `(month, day)` pair, matching Pyth's `MMDD` holiday encoding.
37
+ * The same `{month, day}` matches in *every* year, so moving holidays
38
+ * (Good Friday, MLK Day, Thanksgiving, etc.) need to be republished by Pyth
39
+ * each year and picked up by the consumer's next catalog refresh.
40
+ */
41
+ export interface HolidayDate {
42
+ /** 1..12, in the schedule's timezone */
43
+ month: number;
44
+ /** 1..31, in the schedule's timezone */
45
+ day: number;
46
+ /**
47
+ * MODIFIED hours for this date, in place of the weekly schedule — an early
48
+ * close (`1224/0930-1300`) or a split session (`1224/0000-1430&1800-2400`).
49
+ * Absent means a FULL closure, the `MMDD/C` form.
50
+ *
51
+ * These were previously discarded, which meant the venue fell back to its
52
+ * NORMAL weekly hours on an early-close day and reported itself open after it
53
+ * had shut. They are not an edge case: the live Pyth catalog carries
54
+ * thousands (`0930-1300` alone appears ~2.5k times).
55
+ */
56
+ sessions?: {
57
+ open: string;
58
+ close: string;
59
+ }[];
60
+ }
61
+ export interface TradingHours {
62
+ /** IANA timezone identifier, e.g. "America/New_York" */
63
+ timezone: string;
64
+ sessions: TradingSession[];
65
+ /**
66
+ * Days when the venue is fully closed regardless of the weekly schedule.
67
+ * Optional — many feeds (crypto, FX) have none. Encoded as `(month, day)`
68
+ * in `timezone`'s local calendar, matching Pyth's `attributes.schedule`
69
+ * holiday list.
70
+ */
71
+ holidays?: HolidayDate[];
72
+ }
73
+ export interface ParsedPythSchedule {
74
+ tradingHours: TradingHours;
75
+ /** True iff every weekday is fully open and there are no holidays — callers treat as `tradingHours = null` (24/7, no schedule needed). */
76
+ alwaysOpen: boolean;
77
+ }
78
+ export declare class PythScheduleParseError extends Error {
79
+ constructor(message: string);
80
+ }
81
+ /**
82
+ * Parse a Pyth schedule string. Throws {@link PythScheduleParseError} on
83
+ * malformed input — callers guard with try/catch so one bad feed does not break
84
+ * a whole market list.
85
+ *
86
+ * DEGRADE CLOSED, not open. The obvious fallback — treat an unparseable
87
+ * schedule as "no schedule (24/7)" — is the wrong direction: the shapes that
88
+ * actually fail here are venues WITH sessions (a lunch-break equity schedule in
89
+ * the legacy comma encoding, an ambiguous weekday fold), and calling those 24/7
90
+ * reports a closed venue as tradable. That is the failure class this module
91
+ * exists to prevent. Prefer marking the market unavailable, or reusing the last
92
+ * schedule that parsed; use 24/7 only where a wrong "open" is harmless.
93
+ */
94
+ export declare function parsePythSchedule(input: string): ParsedPythSchedule;
95
+ export interface MarketStatusResult {
96
+ status: "open" | "closed" | "paused";
97
+ /** Milliseconds until next status change; null for 24/7 or paused. */
98
+ nextStatusChangeIn: number | null;
99
+ }
100
+ /**
101
+ * Pure function — calculates market open/closed status from trading hours + now.
102
+ *
103
+ * @param tradingHours null means 24/7 (crypto)
104
+ * @param paused forces `paused`, short-circuiting the schedule. Checked
105
+ * BEFORE the 24/7 branch, so an on-chain-paused crypto
106
+ * market (tradingHours === null) still reports paused.
107
+ * @param now injectable for testing; defaults to new Date()
108
+ */
109
+ export declare function getMarketStatus(tradingHours: TradingHours | null, paused: boolean, now?: Date): MarketStatusResult;