@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.
- package/.claude/skills/waterx-sdk-integration/SKILL.md +225 -0
- package/README.md +216 -45
- package/SKILLS.md +34 -0
- package/dist/cjs/src/account/config.d.ts +3 -5
- package/dist/cjs/src/account/funding/wormhole.d.ts +1 -2
- package/dist/cjs/src/account/funding/wormhole.js +1 -2
- package/dist/cjs/src/generated/waterx_rule/waterx_rule.d.ts +140 -7
- package/dist/cjs/src/generated/waterx_rule/waterx_rule.js +163 -8
- package/dist/cjs/src/oracle/aggregate.d.ts +49 -62
- package/dist/cjs/src/oracle/aggregate.js +208 -164
- package/dist/cjs/src/oracle/config.d.ts +38 -51
- package/dist/cjs/src/oracle/config.js +1 -1
- package/dist/cjs/src/oracle/host.d.ts +14 -19
- package/dist/cjs/src/oracle/host.js +3 -3
- package/dist/cjs/src/oracle/index.d.ts +25 -16
- package/dist/cjs/src/oracle/index.js +87 -69
- package/dist/cjs/src/oracle/price-update-rule.d.ts +104 -66
- package/dist/cjs/src/oracle/price-update-rule.js +24 -16
- package/dist/cjs/src/oracle/pyth-pro-history.d.ts +36 -0
- package/dist/cjs/src/oracle/pyth-pro-history.js +41 -0
- package/dist/cjs/src/oracle/read-plane.d.ts +35 -53
- package/dist/cjs/src/oracle/read-plane.js +26 -48
- package/dist/cjs/src/oracle/read-prices.d.ts +105 -0
- package/dist/cjs/src/oracle/read-prices.js +162 -0
- package/dist/cjs/src/oracle/rule-registry.d.ts +12 -12
- package/dist/cjs/src/oracle/rule-registry.js +12 -14
- package/dist/cjs/src/oracle/rules/pyth-lazer-rule.d.ts +24 -3
- package/dist/cjs/src/oracle/rules/pyth-lazer-rule.js +40 -20
- package/dist/cjs/src/oracle/rules/waterx-rule.d.ts +271 -57
- package/dist/cjs/src/oracle/rules/waterx-rule.js +743 -144
- package/dist/cjs/src/oracle/schedule.d.ts +109 -0
- package/dist/cjs/src/oracle/schedule.js +991 -0
- package/dist/cjs/src/oracle/source-list.d.ts +31 -27
- package/dist/cjs/src/oracle/source-list.js +43 -45
- package/dist/cjs/src/oracle/symbol-catalog.d.ts +54 -0
- package/dist/cjs/src/oracle/symbol-catalog.js +69 -0
- package/dist/cjs/src/oracle/update-fetch.d.ts +11 -15
- package/dist/cjs/src/oracle/update-fetch.js +12 -19
- package/dist/cjs/src/oracle/validate.d.ts +105 -0
- package/dist/cjs/src/oracle/validate.js +185 -0
- package/dist/cjs/src/oracle/weight-coverage.d.ts +79 -0
- package/dist/cjs/src/oracle/weight-coverage.js +173 -0
- package/dist/cjs/src/perp/client.d.ts +40 -55
- package/dist/cjs/src/perp/client.js +44 -17
- package/dist/cjs/src/perp/config-view.d.ts +5 -14
- package/dist/cjs/src/perp/config-view.js +5 -16
- package/dist/cjs/src/perp/config.d.ts +3 -3
- package/dist/cjs/src/perp/config.js +11 -13
- package/dist/cjs/src/perp/index.d.ts +4 -6
- package/dist/cjs/src/perp/index.js +43 -18
- package/dist/cjs/src/perp/tx-builders/common.d.ts +86 -48
- package/dist/cjs/src/perp/tx-builders/common.js +115 -47
- package/dist/cjs/src/perp/tx-builders/wlp.d.ts +3 -14
- package/dist/cjs/src/perp/tx-builders/wlp.js +6 -35
- package/dist/cjs/src/perp/tx-builders.d.ts +6 -5
- package/dist/cjs/src/perp/tx-builders.js +16 -10
- package/dist/cjs/src/unified-client.d.ts +9 -35
- package/dist/cjs/src/unified-client.js +3 -4
- package/dist/cjs/src/utils/config.d.ts +0 -8
- package/dist/cjs/src/utils/config.js +5 -13
- package/dist/src/account/config.d.ts +3 -5
- package/dist/src/account/funding/wormhole.d.ts +1 -2
- package/dist/src/account/funding/wormhole.js +1 -2
- package/dist/src/generated/waterx_rule/waterx_rule.d.ts +140 -7
- package/dist/src/generated/waterx_rule/waterx_rule.js +151 -7
- package/dist/src/oracle/aggregate.d.ts +49 -62
- package/dist/src/oracle/aggregate.js +209 -164
- package/dist/src/oracle/config.d.ts +38 -51
- package/dist/src/oracle/config.js +1 -1
- package/dist/src/oracle/host.d.ts +14 -19
- package/dist/src/oracle/host.js +3 -3
- package/dist/src/oracle/index.d.ts +25 -16
- package/dist/src/oracle/index.js +61 -54
- package/dist/src/oracle/price-update-rule.d.ts +104 -66
- package/dist/src/oracle/price-update-rule.js +23 -16
- package/dist/src/oracle/pyth-pro-history.d.ts +36 -0
- package/dist/src/oracle/pyth-pro-history.js +38 -0
- package/dist/src/oracle/read-plane.d.ts +35 -53
- package/dist/src/oracle/read-plane.js +25 -47
- package/dist/src/oracle/read-prices.d.ts +105 -0
- package/dist/src/oracle/read-prices.js +156 -0
- package/dist/src/oracle/rule-registry.d.ts +12 -12
- package/dist/src/oracle/rule-registry.js +12 -14
- package/dist/src/oracle/rules/pyth-lazer-rule.d.ts +24 -3
- package/dist/src/oracle/rules/pyth-lazer-rule.js +39 -20
- package/dist/src/oracle/rules/waterx-rule.d.ts +271 -57
- package/dist/src/oracle/rules/waterx-rule.js +735 -144
- package/dist/src/oracle/schedule.d.ts +109 -0
- package/dist/src/oracle/schedule.js +985 -0
- package/dist/src/oracle/source-list.d.ts +31 -27
- package/dist/src/oracle/source-list.js +42 -43
- package/dist/src/oracle/symbol-catalog.d.ts +54 -0
- package/dist/src/oracle/symbol-catalog.js +65 -0
- package/dist/src/oracle/update-fetch.d.ts +11 -15
- package/dist/src/oracle/update-fetch.js +12 -18
- package/dist/src/oracle/validate.d.ts +105 -0
- package/dist/src/oracle/validate.js +177 -0
- package/dist/src/oracle/weight-coverage.d.ts +79 -0
- package/dist/src/oracle/weight-coverage.js +166 -0
- package/dist/src/perp/client.d.ts +40 -55
- package/dist/src/perp/client.js +45 -18
- package/dist/src/perp/config-view.d.ts +5 -14
- package/dist/src/perp/config-view.js +5 -16
- package/dist/src/perp/config.d.ts +3 -3
- package/dist/src/perp/config.js +11 -13
- package/dist/src/perp/index.d.ts +4 -6
- package/dist/src/perp/index.js +17 -5
- package/dist/src/perp/tx-builders/common.d.ts +86 -48
- package/dist/src/perp/tx-builders/common.js +114 -48
- package/dist/src/perp/tx-builders/wlp.d.ts +3 -14
- package/dist/src/perp/tx-builders/wlp.js +6 -35
- package/dist/src/perp/tx-builders.d.ts +6 -5
- package/dist/src/perp/tx-builders.js +15 -6
- package/dist/src/unified-client.d.ts +9 -35
- package/dist/src/unified-client.js +3 -4
- package/dist/src/utils/config.d.ts +0 -8
- package/dist/src/utils/config.js +5 -12
- package/package.json +8 -2
- package/dist/cjs/src/generated/pyth_sponsor_rule/deps/sui/balance.d.ts +0 -12
- package/dist/cjs/src/generated/pyth_sponsor_rule/deps/sui/balance.js +0 -17
- package/dist/cjs/src/generated/pyth_sponsor_rule/pyth_sponsor_rule.d.ts +0 -68
- package/dist/cjs/src/generated/pyth_sponsor_rule/pyth_sponsor_rule.js +0 -113
- package/dist/cjs/src/generated/waterx_pyth_rule/deps/sui/vec_map.d.ts +0 -36
- package/dist/cjs/src/generated/waterx_pyth_rule/deps/sui/vec_map.js +0 -31
- package/dist/cjs/src/generated/waterx_pyth_rule/pyth_rule.d.ts +0 -192
- package/dist/cjs/src/generated/waterx_pyth_rule/pyth_rule.js +0 -214
- package/dist/cjs/src/oracle/pyth.d.ts +0 -227
- package/dist/cjs/src/oracle/pyth.js +0 -652
- package/dist/cjs/src/oracle/rules/pyth-core-rule.d.ts +0 -15
- package/dist/cjs/src/oracle/rules/pyth-core-rule.js +0 -100
- package/dist/cjs/src/oracle/rules/pyth-rule.d.ts +0 -11
- package/dist/cjs/src/oracle/rules/pyth-rule.js +0 -29
- package/dist/cjs/src/oracle/rules/sponsor.d.ts +0 -32
- package/dist/cjs/src/oracle/rules/sponsor.js +0 -56
- package/dist/src/generated/pyth_sponsor_rule/deps/sui/balance.d.ts +0 -12
- package/dist/src/generated/pyth_sponsor_rule/deps/sui/balance.js +0 -14
- package/dist/src/generated/pyth_sponsor_rule/pyth_sponsor_rule.d.ts +0 -68
- package/dist/src/generated/pyth_sponsor_rule/pyth_sponsor_rule.js +0 -73
- package/dist/src/generated/waterx_pyth_rule/deps/sui/vec_map.d.ts +0 -36
- package/dist/src/generated/waterx_pyth_rule/deps/sui/vec_map.js +0 -27
- package/dist/src/generated/waterx_pyth_rule/pyth_rule.d.ts +0 -192
- package/dist/src/generated/waterx_pyth_rule/pyth_rule.js +0 -170
- package/dist/src/oracle/pyth.d.ts +0 -227
- package/dist/src/oracle/pyth.js +0 -638
- package/dist/src/oracle/rules/pyth-core-rule.d.ts +0 -15
- package/dist/src/oracle/rules/pyth-core-rule.js +0 -97
- package/dist/src/oracle/rules/pyth-rule.d.ts +0 -11
- package/dist/src/oracle/rules/pyth-rule.js +0 -26
- package/dist/src/oracle/rules/sponsor.d.ts +0 -32
- 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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
-
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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 `
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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
|
-
*
|
|
62
|
-
*
|
|
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
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
83
|
-
*
|
|
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
|
-
*
|
|
86
|
-
* the
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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(
|
|
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:
|
|
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
|
|
159
|
-
const url = (0, update_fetch_ts_1.joinEndpointPath)(endpoint,
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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 = (
|
|
186
|
-
return payload
|
|
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
|
|
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)` —
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
* `
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
245
|
-
//
|
|
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
|
|
253
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
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
|
|
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
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
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
|
|
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
|
+
}
|