@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
@@ -38,22 +38,30 @@
38
38
  * other; only the single-rule `feed_*` entries abort on a replay).
39
39
  */
40
40
  Object.defineProperty(exports, "__esModule", { value: true });
41
- exports.WaterxRule = exports.WATERX_INFRA = exports.MERKLE_ROOT_INTENT = 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;
42
42
  exports.waterxQuoteCenterEndpoint = waterxQuoteCenterEndpoint;
43
+ exports.isFreshWaterxEntry = isFreshWaterxEntry;
43
44
  exports.parseSignedEnvelope = parseSignedEnvelope;
44
45
  exports.parseSignedLeaves = parseSignedLeaves;
46
+ exports.fetchWaterxSignedUpdate = fetchWaterxSignedUpdate;
47
+ exports.fetchWaterxSignedLeaves = fetchWaterxSignedLeaves;
45
48
  exports.waterxLeavesOf = waterxLeavesOf;
46
49
  exports.waterxEnvelopeOf = waterxEnvelopeOf;
47
50
  exports.feedWaterxRuleWithProof = feedWaterxRuleWithProof;
48
51
  exports.feedWaterxRule = feedWaterxRule;
52
+ exports.pullWaterxQuotes = pullWaterxQuotes;
53
+ exports.fetchWaterxUpdateData = fetchWaterxUpdateData;
49
54
  const bcs_1 = require("@mysten/bcs");
50
55
  const waterx_rule_ts_1 = require("../../generated/waterx_rule/waterx_rule.js");
51
56
  const record_ts_1 = require("../../utils/record.js");
52
57
  const price_update_rule_ts_1 = require("../price-update-rule.js");
53
58
  const update_fetch_ts_1 = require("../update-fetch.js");
54
- /** Intent the quote-center signs a whole BATCH payload under exported so
55
- * read-plane consumers can mirror the rule's own envelope intent check (a
56
- * mispointed endpoint must be rejected by reads exactly as tx-builds reject it). */
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. */
57
65
  exports.BATCH_PRICE_INTENT = 1;
58
66
  /**
59
67
  * Intent the quote-center signs a snapshot's Merkle ROOT under
@@ -67,12 +75,11 @@ exports.BATCH_PRICE_INTENT = 1;
67
75
  exports.MERKLE_ROOT_INTENT = 2;
68
76
  /**
69
77
  * WaterX quote-center external infra — owned by THIS source, by network.
70
- * Mirrors `PYTH_CORE_INFRA` (oracle/pyth.ts) and `LAZER_INFRA`
71
- * (rules/pyth-lazer-rule.ts): per-network constants for infrastructure the
72
- * source's operator runs, co-located with the only rule that reads them — no
73
- * other oracle source ever touches a quote-center endpoint. Public read (no
74
- * auth), so there is no api_key. `endpoint` has no trailing slash — the rule
75
- * 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.
76
83
  *
77
84
  * These are the DEFAULTS behind the caller's `client.waterx` access slice
78
85
  * (`waterxEndpoint` / `waterxFetch` create options) — the browser-CORS proxy
@@ -85,34 +92,83 @@ exports.WATERX_INFRA = {
85
92
  /**
86
93
  * The waterx source's quote-center base for `network` — the ONE accessor
87
94
  * consumers (BE/FE read planes) use when, and only when, their own
88
- * `ORACLE_SOURCE` resolves to `'waterx_rule'`. Mirrors
89
- * `pythCoreHermesEndpoint`. Under any other source the read endpoint is that
90
- * 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.
91
97
  */
92
98
  function waterxQuoteCenterEndpoint(network) {
93
99
  return exports.WATERX_INFRA[network].endpoint;
94
100
  }
101
+ /**
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.)
108
+ */
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
+ */
95
144
  function isWaterxEnvelopePayloadShape(payload) {
96
145
  const env = payload?.envelope;
97
146
  return (typeof env === "object" &&
98
147
  env !== null &&
99
- typeof env.signature === "string" &&
148
+ // ed25519 over the batch — a wrong-length signature is an on-chain abort.
149
+ isHexOfBytes(env.signature, 64) &&
100
150
  typeof env.timestamp_ms === "bigint" &&
101
- Array.isArray(env.payload?.items));
151
+ env.timestamp_ms >= 0n &&
152
+ Array.isArray(env.payload?.items) &&
153
+ env.payload.items.every(isBatchItemShape));
102
154
  }
103
155
  function isWaterxLeafPayloadShape(payload) {
104
156
  const leaves = payload?.leaves;
105
157
  return Array.isArray(leaves) && leaves.every(isSignedLeafShape);
106
158
  }
107
159
  /** Every u64 field of a leaf — each one is signed, so each must be present and exact. */
108
- const LEAF_U64_FIELDS = [
160
+ /**
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 = [
109
166
  "price_timestamp_ms",
110
167
  "price_n",
111
168
  "price_scale",
112
169
  "confidence_n",
113
170
  "confidence_scale",
114
171
  "max_source_deviation_bps",
115
- "signed_timestamp_ms",
116
172
  ];
117
173
  /**
118
174
  * FULL structural check on a leaf, not just the fields the feed leg happens to
@@ -141,8 +197,15 @@ function isHexOfBytes(hex, bytes) {
141
197
  const body = hex.startsWith("0x") ? hex.slice(2) : hex;
142
198
  return body.length === bytes * 2 && HEX_ONLY.test(body);
143
199
  }
144
- function isSignedLeafShape(leaf) {
145
- const l = leaf;
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;
146
209
  if (typeof l !== "object" || l === null)
147
210
  return false;
148
211
  if (typeof l.symbol !== "string" || l.symbol === "")
@@ -173,11 +236,20 @@ function isSignedLeafShape(leaf) {
173
236
  return false;
174
237
  if (l.sources.some((s) => typeof s !== "bigint" || s < 0n))
175
238
  return false;
176
- for (const field of LEAF_U64_FIELDS) {
239
+ for (const field of BATCH_ITEM_U64_FIELDS) {
177
240
  const v = l[field];
178
241
  if (typeof v !== "bigint" || v < 0n)
179
242
  return false;
180
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;
181
253
  // ed25519 is always 64 bytes and the root is always a 32-byte keccak256; a
182
254
  // wrong-length signature is an on-chain abort, so it is rejected here.
183
255
  if (!isHexOfBytes(l.signature, 64))
@@ -253,15 +325,42 @@ function parseSignedEnvelope(text) {
253
325
  if (typeof raw.signature !== "string" || !Array.isArray(raw.payload?.items)) {
254
326
  throw new Error("WaterX quote-center returned a malformed signed envelope");
255
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
+ });
256
344
  return {
257
345
  intent: Number(raw.intent),
258
- timestamp_ms: (raw.timestamp_ms ?? 0n),
346
+ timestamp_ms: raw.timestamp_ms,
259
347
  signature: raw.signature,
260
- payload: {
261
- items: raw.payload.items.map((i) => ({ ...i, num_sources: Number(i.num_sources) })),
262
- },
348
+ payload: { items },
263
349
  };
264
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
+ }
265
364
  /**
266
365
  * A Merkle proof element must be a 32-byte keccak256 hash, and nothing else.
267
366
  *
@@ -356,13 +455,27 @@ async function fetchQuoteCenter(endpoint, path, symbols, what, fetchOpts) {
356
455
  }
357
456
  }
358
457
  /**
359
- * Pull one enclave-signed batch envelope covering `symbols`. `fellBackFrom`, when
360
- * set, names the leaf-route failure that sent us here, so a deployment whose
361
- * quote-center serves NEITHER route reports both statuses instead of only the
362
- * second one.
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.
363
466
  */
364
467
  async function fetchWaterxSignedUpdate(endpoint, symbols, fetchOpts, fellBackFrom) {
365
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
+ }
366
479
  const res = await fetchQuoteCenter(endpoint, "v1/quotes/update", symbols, "fetch", fetchOpts);
367
480
  if (!res.ok) {
368
481
  throw new Error(`WaterX quote-center fetch failed: ${res.status} ${await res.text()}${context}`);
@@ -397,8 +510,48 @@ async function fetchWaterxSignedUpdate(endpoint, symbols, fetchOpts, fellBackFro
397
510
  * from its feed registry). That is config drift between this SDK's `feeds` and
398
511
  * the quote-center's registry, and the fallback surfaces it honestly: the
399
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.
400
516
  */
401
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) {
402
555
  const res = await fetchQuoteCenter(endpoint, "v1/quotes/leaves", symbols, "leaf fetch", fetchOpts);
403
556
  if (res.status === 404) {
404
557
  return { unavailable: `GET /v1/quotes/leaves → 404 ${(await res.text()).trim()}`.trim() };
@@ -557,44 +710,103 @@ function feedWaterxRule(tx, host, collector, envelope) {
557
710
  },
558
711
  })(tx);
559
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
+ }
560
793
  exports.WaterxRule = {
561
794
  kind: "waterx_rule",
562
- // Verification is an in-Move ed25519 check with no Coin argument — no
563
- // update fee — see `PriceUpdateRule.requiresFeeSource`.
564
- requiresFeeSource: false,
795
+ // No credential: the quote-center read surface is public (no `credential`
796
+ // declared — see `PriceUpdateRule.credential`).
565
797
  /** Tickers with a `waterx_rule.feeds` entry (keyed by oracle ticker). */
566
798
  supportedTickers(host) {
567
799
  return Object.keys(host.config.packages.waterx_rule?.feeds ?? {});
568
800
  },
569
801
  /**
570
802
  * Pulls per-symbol Merkle leaves for `tickers`, falling back to one batch
571
- * envelope only when this quote-center has no leaf route (see
572
- * {@link fetchWaterxSignedLeaves} for exactly which statuses mean that, and
573
- * why nothing else falls back). Either way, returns only what covers ALL of
574
- * `tickers` — see {@link assertCoverage}.
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}.
575
805
  */
576
806
  async fetchUpdateData(host, tickers) {
577
807
  if (tickers.length === 0)
578
808
  return null;
579
- // Package-level check first: a config without the deployment must say so,
580
- // not fail per ticker as if only that feed were missing.
581
- const { feeds } = requireWaterxPackage(host);
582
- for (const ticker of tickers) {
583
- // ownEntry: a prototype-key ticker ("toString") must throw here as
584
- // unlisted, not pass as an inherited Function and reach the network.
585
- if ((0, record_ts_1.ownEntry)(feeds, ticker) === undefined) {
586
- throw new Error(`No waterx_rule feed listed for ticker: ${ticker}`);
587
- }
588
- }
589
- const { endpoint, fetch: fetchOpts } = resolveWaterxInfra(host);
590
- const pulled = await fetchWaterxSignedLeaves(endpoint, tickers, fetchOpts);
591
- if ("leaves" in pulled) {
592
- assertCoverage("leaves", tickers, pulled.leaves.map((l) => l.symbol));
593
- return { kind: "waterx_rule", payload: { leaves: pulled.leaves } };
594
- }
595
- const envelope = await fetchWaterxSignedUpdate(endpoint, tickers, fetchOpts, pulled.unavailable);
596
- assertCoverage("envelope", tickers, envelope.payload.items.map((i) => i.symbol));
597
- return { kind: "waterx_rule", payload: { envelope } };
809
+ return (await pullWaterxData(host, tickers, "strict")).data;
598
810
  },
599
811
  /**
600
812
  * Divisibility differs by variant, which is the whole reason this method
@@ -637,6 +849,28 @@ exports.WaterxRule = {
637
849
  }
638
850
  return { kind: "waterx_rule", payload: { envelope } };
639
851
  },
852
+ /**
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
+ },
640
874
  /**
641
875
  * No shared verify step: both `waterx_rule` collect entries bundle verify AND
642
876
  * feed into one per-collector call, appended by
@@ -645,7 +879,36 @@ exports.WaterxRule = {
645
879
  * reaches the feed leg via `aggregate.ts`'s per-ticker map (built from the
646
880
  * group's fetched data), not a `RuleUpdateHandle`.
647
881
  */
648
- buildUpdateCalls(_tx, _host, _data, _opts) {
882
+ buildUpdateCalls(_tx, _host, _data) {
649
883
  return;
650
884
  },
651
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
+ }
@@ -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;