@waterx/sdk 4.0.0 → 4.1.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 (128) hide show
  1. package/README.md +52 -37
  2. package/dist/cjs/src/account/account.js +2 -1
  3. package/dist/cjs/src/account/config.d.ts +0 -16
  4. package/dist/cjs/src/account/funding/balance.d.ts +25 -2
  5. package/dist/cjs/src/account/funding/balance.js +36 -24
  6. package/dist/cjs/src/account/funding/credit.js +6 -10
  7. package/dist/cjs/src/constants.d.ts +15 -1
  8. package/dist/cjs/src/constants.js +18 -4
  9. package/dist/cjs/src/generated/waterx_rule/deps/bucket_v2_framework/float.d.ts +8 -0
  10. package/dist/cjs/src/generated/waterx_rule/deps/bucket_v2_framework/float.js +13 -0
  11. package/dist/cjs/src/generated/waterx_rule/deps/sui/vec_map.d.ts +36 -0
  12. package/dist/cjs/src/generated/waterx_rule/deps/sui/vec_map.js +31 -0
  13. package/dist/cjs/src/generated/waterx_rule/waterx_rule.d.ts +940 -0
  14. package/dist/cjs/src/generated/waterx_rule/waterx_rule.js +1092 -0
  15. package/dist/cjs/src/oracle/aggregate.d.ts +21 -21
  16. package/dist/cjs/src/oracle/aggregate.js +84 -71
  17. package/dist/cjs/src/oracle/config.d.ts +107 -52
  18. package/dist/cjs/src/oracle/config.js +15 -35
  19. package/dist/cjs/src/oracle/host.d.ts +13 -2
  20. package/dist/cjs/src/oracle/index.d.ts +4 -2
  21. package/dist/cjs/src/oracle/index.js +25 -6
  22. package/dist/cjs/src/oracle/price-update-rule.d.ts +3 -4
  23. package/dist/cjs/src/oracle/pyth.d.ts +68 -6
  24. package/dist/cjs/src/oracle/pyth.js +338 -22
  25. package/dist/cjs/src/oracle/rule-registry.d.ts +11 -6
  26. package/dist/cjs/src/oracle/rule-registry.js +13 -6
  27. package/dist/cjs/src/oracle/rules/pyth-core-rule.js +17 -2
  28. package/dist/cjs/src/oracle/rules/pyth-lazer-rule.d.ts +6 -6
  29. package/dist/cjs/src/oracle/rules/pyth-lazer-rule.js +25 -22
  30. package/dist/cjs/src/oracle/rules/pyth-rule.js +5 -0
  31. package/dist/cjs/src/oracle/rules/waterx-rule.d.ts +89 -0
  32. package/dist/cjs/src/oracle/rules/waterx-rule.js +272 -0
  33. package/dist/cjs/src/oracle/update-fetch.d.ts +32 -2
  34. package/dist/cjs/src/oracle/update-fetch.js +60 -3
  35. package/dist/cjs/src/perp/client.d.ts +71 -19
  36. package/dist/cjs/src/perp/client.js +30 -10
  37. package/dist/cjs/src/perp/config.d.ts +4 -7
  38. package/dist/cjs/src/perp/config.js +9 -12
  39. package/dist/cjs/src/perp/constants.d.ts +0 -6
  40. package/dist/cjs/src/perp/constants.js +11 -9
  41. package/dist/cjs/src/perp/fetch/account.js +3 -0
  42. package/dist/cjs/src/perp/fetch/bridge.js +2 -1
  43. package/dist/cjs/src/perp/fetch/market.js +2 -1
  44. package/dist/cjs/src/perp/fetch/positions.d.ts +16 -10
  45. package/dist/cjs/src/perp/fetch/positions.js +23 -20
  46. package/dist/cjs/src/perp/index.d.ts +8 -4
  47. package/dist/cjs/src/perp/index.js +12 -7
  48. package/dist/cjs/src/perp/liq-view.d.ts +64 -0
  49. package/dist/cjs/src/perp/liq-view.js +74 -0
  50. package/dist/cjs/src/perp/user/order.d.ts +13 -0
  51. package/dist/cjs/src/perp/user/order.js +30 -16
  52. package/dist/cjs/src/perp/user/staking.js +3 -2
  53. package/dist/cjs/src/perp/user/trading.js +25 -24
  54. package/dist/cjs/src/perp/user/wlp.js +6 -5
  55. package/dist/cjs/src/prediction/utils.d.ts +11 -2
  56. package/dist/cjs/src/prediction/utils.js +22 -22
  57. package/dist/cjs/src/unified-client.d.ts +49 -20
  58. package/dist/cjs/src/unified-client.js +4 -1
  59. package/dist/cjs/src/utils/format.d.ts +14 -0
  60. package/dist/cjs/src/utils/format.js +24 -0
  61. package/dist/cjs/src/utils/math.d.ts +304 -12
  62. package/dist/cjs/src/utils/math.js +397 -17
  63. package/dist/cjs/src/utils/validate.d.ts +69 -0
  64. package/dist/cjs/src/utils/validate.js +183 -0
  65. package/dist/src/account/account.js +2 -1
  66. package/dist/src/account/config.d.ts +0 -16
  67. package/dist/src/account/funding/balance.d.ts +25 -2
  68. package/dist/src/account/funding/balance.js +36 -24
  69. package/dist/src/account/funding/credit.js +6 -10
  70. package/dist/src/constants.d.ts +15 -1
  71. package/dist/src/constants.js +17 -3
  72. package/dist/src/generated/waterx_rule/deps/bucket_v2_framework/float.d.ts +8 -0
  73. package/dist/src/generated/waterx_rule/deps/bucket_v2_framework/float.js +10 -0
  74. package/dist/src/generated/waterx_rule/deps/sui/vec_map.d.ts +36 -0
  75. package/dist/src/generated/waterx_rule/deps/sui/vec_map.js +27 -0
  76. package/dist/src/generated/waterx_rule/waterx_rule.d.ts +940 -0
  77. package/dist/src/generated/waterx_rule/waterx_rule.js +991 -0
  78. package/dist/src/oracle/aggregate.d.ts +21 -21
  79. package/dist/src/oracle/aggregate.js +84 -71
  80. package/dist/src/oracle/config.d.ts +107 -52
  81. package/dist/src/oracle/config.js +14 -34
  82. package/dist/src/oracle/host.d.ts +13 -2
  83. package/dist/src/oracle/index.d.ts +4 -2
  84. package/dist/src/oracle/index.js +18 -7
  85. package/dist/src/oracle/price-update-rule.d.ts +3 -4
  86. package/dist/src/oracle/pyth.d.ts +68 -6
  87. package/dist/src/oracle/pyth.js +334 -22
  88. package/dist/src/oracle/rule-registry.d.ts +11 -6
  89. package/dist/src/oracle/rule-registry.js +13 -6
  90. package/dist/src/oracle/rules/pyth-core-rule.js +18 -3
  91. package/dist/src/oracle/rules/pyth-lazer-rule.d.ts +6 -6
  92. package/dist/src/oracle/rules/pyth-lazer-rule.js +26 -23
  93. package/dist/src/oracle/rules/pyth-rule.js +5 -0
  94. package/dist/src/oracle/rules/waterx-rule.d.ts +89 -0
  95. package/dist/src/oracle/rules/waterx-rule.js +266 -0
  96. package/dist/src/oracle/update-fetch.d.ts +32 -2
  97. package/dist/src/oracle/update-fetch.js +57 -3
  98. package/dist/src/perp/client.d.ts +71 -19
  99. package/dist/src/perp/client.js +31 -11
  100. package/dist/src/perp/config.d.ts +4 -7
  101. package/dist/src/perp/config.js +9 -12
  102. package/dist/src/perp/constants.d.ts +0 -6
  103. package/dist/src/perp/constants.js +10 -8
  104. package/dist/src/perp/fetch/account.js +3 -0
  105. package/dist/src/perp/fetch/bridge.js +2 -1
  106. package/dist/src/perp/fetch/market.js +2 -1
  107. package/dist/src/perp/fetch/positions.d.ts +16 -10
  108. package/dist/src/perp/fetch/positions.js +28 -20
  109. package/dist/src/perp/index.d.ts +8 -4
  110. package/dist/src/perp/index.js +5 -3
  111. package/dist/src/perp/liq-view.d.ts +64 -0
  112. package/dist/src/perp/liq-view.js +71 -0
  113. package/dist/src/perp/user/order.d.ts +13 -0
  114. package/dist/src/perp/user/order.js +30 -16
  115. package/dist/src/perp/user/staking.js +3 -2
  116. package/dist/src/perp/user/trading.js +25 -24
  117. package/dist/src/perp/user/wlp.js +6 -5
  118. package/dist/src/prediction/utils.d.ts +11 -2
  119. package/dist/src/prediction/utils.js +22 -22
  120. package/dist/src/unified-client.d.ts +49 -20
  121. package/dist/src/unified-client.js +4 -1
  122. package/dist/src/utils/format.d.ts +14 -0
  123. package/dist/src/utils/format.js +21 -0
  124. package/dist/src/utils/math.d.ts +304 -12
  125. package/dist/src/utils/math.js +394 -17
  126. package/dist/src/utils/validate.d.ts +69 -0
  127. package/dist/src/utils/validate.js +167 -0
  128. package/package.json +4 -1
@@ -3,7 +3,7 @@
3
3
  * updates, plus `feedLazerRule`, the collector-feed leg `aggregateTicker`
4
4
  * appends per lazer-routed ticker. Fetches one `leEcdsa` payload for all
5
5
  * requested integer feed ids from the Lazer HTTP API (Bearer-authenticated
6
- * via `config.pyth.api_key`), verifies it ONCE on-chain via
6
+ * via the `pythApiKey` create option), verifies it ONCE on-chain via
7
7
  * `pyth_lazer::parse_and_verify_le_ecdsa_update`, and hands the resulting
8
8
  * `Update` PTB value back through a `RuleUpdateHandle` for the feed calls.
9
9
  */
@@ -19,11 +19,11 @@ export interface PythLazerUpdatePayload {
19
19
  }
20
20
  /**
21
21
  * Thrown by {@link PythLazerRule.fetchUpdateData} when `pyth_lazer_rule` is
22
- * deployed in config but no `pyth.api_key` is set the Lazer HTTP API
23
- * requires a Bearer token and the SDK never reads `process.env` to find one.
24
- * `instanceof`-able (mirrors `OracleFeeSourceUnavailableError` in `pyth.ts`)
25
- * so a consumer can branch on the failure type directly instead of
26
- * string-matching `error.message`.
22
+ * deployed in config but no `pythApiKey` was supplied at client init the
23
+ * Lazer HTTP API requires a Bearer token and the SDK never reads
24
+ * `process.env` to find one. `instanceof`-able (mirrors
25
+ * `OracleFeeSourceUnavailableError` in `pyth.ts`) so a consumer can branch on
26
+ * the failure type directly instead of string-matching `error.message`.
27
27
  */
28
28
  export declare class LazerApiKeyMissingError extends Error {
29
29
  constructor();
@@ -3,14 +3,14 @@
3
3
  * updates, plus `feedLazerRule`, the collector-feed leg `aggregateTicker`
4
4
  * appends per lazer-routed ticker. Fetches one `leEcdsa` payload for all
5
5
  * requested integer feed ids from the Lazer HTTP API (Bearer-authenticated
6
- * via `config.pyth.api_key`), verifies it ONCE on-chain via
6
+ * via the `pythApiKey` create option), verifies it ONCE on-chain via
7
7
  * `pyth_lazer::parse_and_verify_le_ecdsa_update`, and hands the resulting
8
8
  * `Update` PTB value back through a `RuleUpdateHandle` for the feed calls.
9
9
  */
10
10
  import { fromHex } from "@mysten/bcs";
11
11
  import { LAZER_DEFAULTS } from "../config.js";
12
12
  import { assertRuleUpdateData, } from "../price-update-rule.js";
13
- import { FetchPolicyError, fetchWithPolicy } from "../update-fetch.js";
13
+ import { fetchWithPolicy, joinEndpointPath, rethrowExhaustedFetch } from "../update-fetch.js";
14
14
  /**
15
15
  * Signed-update request pins, mirroring what the on-chain rule consumes:
16
16
  * - `properties` — `price` + `exponent` are REQUIRED by
@@ -18,10 +18,17 @@ import { FetchPolicyError, fetchWithPolicy } from "../update-fetch.js";
18
18
  * `confidence` is optional on-chain but requested so the rule's
19
19
  * fail-closed confidence gate actually engages (a payload without
20
20
  * confidence passes the gate unchecked).
21
- * - `channel` — `real_time`: the deployed rule binds the v1 Lazer API, whose
22
- * `channel::from_u8` aborts on the 1000ms fixed-rate channel; real_time /
23
- * 50ms / 200ms are the safe subscriptions, and for an on-demand pull
24
- * real_time is the freshest.
21
+ * - `channel` — `fixed_rate@200ms`, NOT `real_time`: Lazer rejects a request
22
+ * whose channel is faster than ANY requested feed's `min_channel`, and it
23
+ * rejects the WHOLE batch (`400 Feeds do not support channel …`). Only the
24
+ * majors (BTC/ETH/SOL/USDC/DOGE/XRP/BNB/HYPE + EUR/JPY FX) publish
25
+ * `real_time`; the other 19 of the 29 configured feeds — including SUIUSD
26
+ * and every xStock — are `min_channel: fixed_rate@200ms` (Lazer symbol
27
+ * registry, verified 2026-07-22: the same 29-feed batch 400s at
28
+ * `real_time`/`50ms` and serves 200 with the leEcdsa blob at `200ms`).
29
+ * 200ms is the fastest channel every configured feed supports, and the
30
+ * deployed rule accepts it: the v1 on-chain `channel::from_u8` aborts only
31
+ * on the 1000ms fixed-rate channel (real_time / 50ms / 200ms are safe).
25
32
  * - `formats: leEcdsa` + `jsonBinaryEncoding: hex` — the Sui verifier takes
26
33
  * the `leEcdsa` framing; hex matches `fromHex` below.
27
34
  */
@@ -29,7 +36,7 @@ const LAZER_LATEST_PRICE_REQUEST = {
29
36
  properties: ["price", "exponent", "confidence"],
30
37
  formats: ["leEcdsa"],
31
38
  jsonBinaryEncoding: "hex",
32
- channel: "real_time",
39
+ channel: "fixed_rate@200ms",
33
40
  };
34
41
  /**
35
42
  * Shape check ONLY — the `kind` discriminant is checked separately by the
@@ -44,16 +51,16 @@ function isPythLazerUpdatePayloadShape(payload) {
44
51
  }
45
52
  /**
46
53
  * Thrown by {@link PythLazerRule.fetchUpdateData} when `pyth_lazer_rule` is
47
- * deployed in config but no `pyth.api_key` is set the Lazer HTTP API
48
- * requires a Bearer token and the SDK never reads `process.env` to find one.
49
- * `instanceof`-able (mirrors `OracleFeeSourceUnavailableError` in `pyth.ts`)
50
- * so a consumer can branch on the failure type directly instead of
51
- * string-matching `error.message`.
54
+ * deployed in config but no `pythApiKey` was supplied at client init the
55
+ * Lazer HTTP API requires a Bearer token and the SDK never reads
56
+ * `process.env` to find one. `instanceof`-able (mirrors
57
+ * `OracleFeeSourceUnavailableError` in `pyth.ts`) so a consumer can branch on
58
+ * the failure type directly instead of string-matching `error.message`.
52
59
  */
53
60
  export class LazerApiKeyMissingError extends Error {
54
61
  constructor() {
55
62
  super("LazerApiKeyMissing: pyth_lazer_rule requires a Pyth Lazer access token — " +
56
- "set `pyth.api_key` in the client config (the SDK never reads process.env)");
63
+ "pass `pythApiKey` when creating the client (the SDK never reads process.env)");
57
64
  this.name = "LazerApiKeyMissingError";
58
65
  }
59
66
  }
@@ -72,7 +79,11 @@ function requireLazerPackage(host) {
72
79
  * both oracle sources fail the same way under upstream degradation.
73
80
  */
74
81
  async function fetchLazerSignedUpdate(endpoint, apiKey, feedIds, fetchOpts) {
75
- const url = new URL("/v1/latest_price", endpoint);
82
+ // joinEndpointPath preserves any base path on the endpoint — the same
83
+ // leading-slash `new URL` footgun that 404'd every feed on the Pyth Pro
84
+ // Hermes endpoint (see update-fetch.ts). Defensive here: the default
85
+ // Lazer endpoint has no base path, but a config override may.
86
+ const url = joinEndpointPath(endpoint, "v1/latest_price");
76
87
  let res;
77
88
  try {
78
89
  res = await fetchWithPolicy(url.toString(), {
@@ -82,15 +93,7 @@ async function fetchLazerSignedUpdate(endpoint, apiKey, feedIds, fetchOpts) {
82
93
  }, { apiKey, ...fetchOpts });
83
94
  }
84
95
  catch (err) {
85
- // Mirrors fetchPriceFeedsUpdateData's reframing: a retryable status that
86
- // never recovered carries `status` on the FetchPolicyError — reformat
87
- // into this function's own message shape; a network-level exhaustion
88
- // (no status) propagates as-is.
89
- if (err instanceof FetchPolicyError && err.status !== undefined) {
90
- const body = err.bodySnippet ? ` ${err.bodySnippet}` : "";
91
- throw new Error(`Lazer price fetch failed: ${err.status}${body} (retries exhausted after ${err.attempts} attempts)`, { cause: err });
92
- }
93
- throw err;
96
+ rethrowExhaustedFetch(err, (e) => `Lazer price fetch failed: ${e.status}${e.bodySnippet ? ` ${e.bodySnippet}` : ""}`);
94
97
  }
95
98
  if (!res.ok)
96
99
  throw new Error(`Lazer price fetch failed: ${res.status} ${await res.text()}`);
@@ -13,6 +13,11 @@ export function feedPythRule(tx, host, collector, priceInfoObjectId) {
13
13
  arguments: {
14
14
  collector,
15
15
  config: tx.object(host.config.packages.pyth_rule.config),
16
+ // The deployed pyth_rule package is compiled against the Core pyth
17
+ // dependency, so its `&PythState` parameter is the Core-package-qualified
18
+ // type and `host.pyth` (the fixed per-network Core infra) is always the
19
+ // right state to pass. The config's price_info_object entries are Core
20
+ // objects to match.
16
21
  pythState: tx.object(host.pyth.state_id),
17
22
  pythPriceInfo: tx.object(priceInfoObjectId),
18
23
  },
@@ -0,0 +1,89 @@
1
+ /**
2
+ * `WaterxRule` — `PriceUpdateRule` for the first-party WaterX quote-center
3
+ * (Nautilus-TEE, ed25519), plus `feedWaterxRule`, the collector-feed leg
4
+ * `aggregateTicker` appends per waterx-routed ticker. Pulls one enclave-signed
5
+ * batch envelope covering every requested ticker from the quote-center
6
+ * (`GET /v1/quotes/update?symbols=…`, endpoint from `host.waterx` — the
7
+ * `waterxEndpoint`/`waterxFetch` create options — else `WATERX_DEFAULTS`), then —
8
+ * unlike Pyth Lazer, whose verify is a single shared PTB step — verifies AND
9
+ * feeds in ONE `waterx_rule::collect_batch_latest` call per collector (the Move
10
+ * API bundles the two). So `buildUpdateCalls` emits nothing and the signed
11
+ * envelope is handed straight to the per-ticker feed leg.
12
+ *
13
+ * `collect_batch_latest` is the dual-rule path: it feeds the item matching
14
+ * `collector.symbol()` WITHOUT aggregating, so a waterx-routed ticker composes
15
+ * onto the same collector as Pyth/Supra (compose-then-aggregate). On-chain a
16
+ * freshness miss / replayed timestamp ABSTAINS (the other weighted rules
17
+ * cover); a config/integrity mismatch or bad signature aborts.
18
+ */
19
+ import type { Transaction, TransactionArgument } from "@mysten/sui/transactions";
20
+ import type { OracleHost } from "../host.ts";
21
+ import { type PriceUpdateRule, type RuleUpdateData } from "../price-update-rule.ts";
22
+ /**
23
+ * One item inside a signed batch payload, mirroring the quote-center
24
+ * `/v1/quotes/update` JSON 1:1 (snake_case). The u64 integer fields are the
25
+ * EXACT values the enclave signed over BCS — `collect_batch_latest` rebuilds
26
+ * the payload on-chain and re-verifies, so they must round-trip byte-for-byte.
27
+ * They are `bigint` (not `number`): {@link parseSignedEnvelope} decodes them
28
+ * from the raw JSON integer literals so a value above `Number.MAX_SAFE_INTEGER`
29
+ * (2^53) can never lose precision and silently abort the on-chain signature
30
+ * check. `num_sources` is a `u8` (≤ 255) and stays a `number`.
31
+ */
32
+ export interface WaterxBatchItem {
33
+ symbol: string;
34
+ ticker: string;
35
+ sources: bigint[];
36
+ method: string;
37
+ price_timestamp_ms: bigint;
38
+ price_n: bigint;
39
+ price_scale: bigint;
40
+ confidence_n: bigint;
41
+ confidence_scale: bigint;
42
+ max_source_deviation_bps: bigint;
43
+ num_sources: number;
44
+ }
45
+ /** The enclave-signed batch envelope from `GET /v1/quotes/update`. */
46
+ export interface WaterxSignedEnvelope {
47
+ intent: number;
48
+ /** Enclave signing timestamp (ms) — the on-chain `timestamp_ms` argument. */
49
+ timestamp_ms: bigint;
50
+ payload: {
51
+ items: WaterxBatchItem[];
52
+ };
53
+ /** ed25519 signature over `BCS(IntentMessage<BatchPricePayload>)`, hex (± `0x`). */
54
+ signature: string;
55
+ }
56
+ /** `waterx_rule`'s narrowed `RuleUpdateData.payload` shape. */
57
+ export interface WaterxUpdatePayload {
58
+ readonly envelope: WaterxSignedEnvelope;
59
+ }
60
+ /**
61
+ * Parse a quote-center `/v1/quotes/update` response body into a
62
+ * {@link WaterxSignedEnvelope} with the u64 fields decoded as `bigint`, exact.
63
+ *
64
+ * The signature is over `BCS(IntentMessage<BatchPricePayload>)`, so every u64
65
+ * the SDK rebuilds in-PTB must equal the enclave's byte-for-byte or
66
+ * `collect_batch_latest` aborts the whole trade PTB (bad signature — not an
67
+ * abstain). A plain `JSON.parse` yields IEEE-754 doubles that lose precision
68
+ * above 2^53, so instead we recover each integer's exact source literal via the
69
+ * ES2023 reviver `context.source` (Node 21+ / modern browsers) and `BigInt()`
70
+ * it. On an older runtime that passes no `context`, a value within 2^53 is
71
+ * still exact (`BigInt(number)`); a value ABOVE it throws loudly here rather
72
+ * than silently corrupting the payload into an on-chain abort. `num_sources`
73
+ * (u8) and `intent` are coerced back to `number` — both are tiny.
74
+ */
75
+ export declare function parseSignedEnvelope(text: string): WaterxSignedEnvelope;
76
+ /** Narrow a `RuleUpdateData` to its `WaterxSignedEnvelope`, or `null`. */
77
+ export declare function waterxEnvelopeOf(data: RuleUpdateData): WaterxSignedEnvelope | null;
78
+ /**
79
+ * `waterx_rule::collect_batch_latest(collector, config, clock, enclave_config,
80
+ * enclave, timestamp_ms, payload, sig)` — rebuild the enclave-signed batch
81
+ * payload in-PTB (`new_batch_payload` + one `new_batch_item`/`push_batch_item`
82
+ * per item, the exact shape the enclave signed) and contribute the price for
83
+ * `collector.symbol()` to the collector. One collect call re-verifies the batch
84
+ * signature and picks this collector's symbol out of the batch; on-chain it
85
+ * abstains (records `none`) instead of aborting when the symbol is stale,
86
+ * absent from the batch, or its timestamp was already accepted (replay).
87
+ */
88
+ export declare function feedWaterxRule(tx: Transaction, host: OracleHost, collector: TransactionArgument, envelope: WaterxSignedEnvelope): void;
89
+ export declare const WaterxRule: PriceUpdateRule;
@@ -0,0 +1,266 @@
1
+ /**
2
+ * `WaterxRule` — `PriceUpdateRule` for the first-party WaterX quote-center
3
+ * (Nautilus-TEE, ed25519), plus `feedWaterxRule`, the collector-feed leg
4
+ * `aggregateTicker` appends per waterx-routed ticker. Pulls one enclave-signed
5
+ * batch envelope covering every requested ticker from the quote-center
6
+ * (`GET /v1/quotes/update?symbols=…`, endpoint from `host.waterx` — the
7
+ * `waterxEndpoint`/`waterxFetch` create options — else `WATERX_DEFAULTS`), then —
8
+ * unlike Pyth Lazer, whose verify is a single shared PTB step — verifies AND
9
+ * feeds in ONE `waterx_rule::collect_batch_latest` call per collector (the Move
10
+ * API bundles the two). So `buildUpdateCalls` emits nothing and the signed
11
+ * envelope is handed straight to the per-ticker feed leg.
12
+ *
13
+ * `collect_batch_latest` is the dual-rule path: it feeds the item matching
14
+ * `collector.symbol()` WITHOUT aggregating, so a waterx-routed ticker composes
15
+ * onto the same collector as Pyth/Supra (compose-then-aggregate). On-chain a
16
+ * freshness miss / replayed timestamp ABSTAINS (the other weighted rules
17
+ * cover); a config/integrity mismatch or bad signature aborts.
18
+ */
19
+ import { fromHex } from "@mysten/bcs";
20
+ import { collectBatchLatest, newBatchItem, newBatchPayload, pushBatchItem, } from "../../generated/waterx_rule/waterx_rule.js";
21
+ import { WATERX_DEFAULTS } from "../config.js";
22
+ import { assertRuleUpdateData, } from "../price-update-rule.js";
23
+ import { FetchPolicyError, fetchWithPolicy, joinEndpointPath, } from "../update-fetch.js";
24
+ /** The single signing intent (`BATCH_PRICE_INTENT`) the quote-center emits. */
25
+ const BATCH_PRICE_INTENT = 1;
26
+ /**
27
+ * Shape check ONLY — the `kind` discriminant is checked separately by the
28
+ * caller before this runs (mirrors the other rules' guard split), so a
29
+ * same-shaped payload from a different rule can never silently pass.
30
+ */
31
+ function isWaterxUpdatePayloadShape(payload) {
32
+ const env = payload?.envelope;
33
+ return (typeof env === "object" &&
34
+ env !== null &&
35
+ typeof env.signature === "string" &&
36
+ typeof env.timestamp_ms === "bigint" &&
37
+ Array.isArray(env.payload?.items));
38
+ }
39
+ /**
40
+ * Parse a quote-center `/v1/quotes/update` response body into a
41
+ * {@link WaterxSignedEnvelope} with the u64 fields decoded as `bigint`, exact.
42
+ *
43
+ * The signature is over `BCS(IntentMessage<BatchPricePayload>)`, so every u64
44
+ * the SDK rebuilds in-PTB must equal the enclave's byte-for-byte or
45
+ * `collect_batch_latest` aborts the whole trade PTB (bad signature — not an
46
+ * abstain). A plain `JSON.parse` yields IEEE-754 doubles that lose precision
47
+ * above 2^53, so instead we recover each integer's exact source literal via the
48
+ * ES2023 reviver `context.source` (Node 21+ / modern browsers) and `BigInt()`
49
+ * it. On an older runtime that passes no `context`, a value within 2^53 is
50
+ * still exact (`BigInt(number)`); a value ABOVE it throws loudly here rather
51
+ * than silently corrupting the payload into an on-chain abort. `num_sources`
52
+ * (u8) and `intent` are coerced back to `number` — both are tiny.
53
+ */
54
+ export function parseSignedEnvelope(text) {
55
+ const raw = JSON.parse(text, (_key, value, context) => {
56
+ if (typeof value !== "number" || !Number.isInteger(value))
57
+ return value;
58
+ if (context?.source !== undefined)
59
+ return BigInt(context.source);
60
+ if (!Number.isSafeInteger(value)) {
61
+ throw new Error("waterx envelope carries an integer above 2^53 and this runtime lacks JSON " +
62
+ "source access — cannot preserve u64 precision for the signed payload");
63
+ }
64
+ return BigInt(value);
65
+ });
66
+ if (typeof raw.signature !== "string" || !Array.isArray(raw.payload?.items)) {
67
+ throw new Error("WaterX quote-center returned a malformed signed envelope");
68
+ }
69
+ return {
70
+ intent: Number(raw.intent),
71
+ timestamp_ms: (raw.timestamp_ms ?? 0n),
72
+ signature: raw.signature,
73
+ payload: {
74
+ items: raw.payload.items.map((i) => ({ ...i, num_sources: Number(i.num_sources) })),
75
+ },
76
+ };
77
+ }
78
+ /** The `waterx_rule` deployment entry; throws when the config carries none. */
79
+ function requireWaterxPackage(host) {
80
+ const entry = host.config.packages.waterx_rule;
81
+ if (!entry) {
82
+ throw new Error("waterx_rule package is not deployed in this config");
83
+ }
84
+ return entry;
85
+ }
86
+ /**
87
+ * Resolve the quote-center infra for this host: the `waterxEndpoint` /
88
+ * `waterxFetch` create options when the client carries them, else the network
89
+ * default. The fetch policy falls back to the shared `pyth.fetch` policy so a
90
+ * consumer that already tuned timeouts/retries once keeps them here.
91
+ *
92
+ * This is the seam a browser consumer needs: the envelope is fetched FROM THE
93
+ * PAGE, so a front end whose origin the quote-center does not allow (CORS)
94
+ * points `endpoint` at a same-origin proxy, or supplies its own `fetchImpl`.
95
+ */
96
+ function resolveWaterxInfra(host) {
97
+ const infra = host.waterx ?? WATERX_DEFAULTS[host.network];
98
+ return { endpoint: infra.endpoint, fetch: infra.fetch ?? host.pyth.fetch };
99
+ }
100
+ /**
101
+ * Pull one enclave-signed batch envelope covering `symbols` from the
102
+ * quote-center. Goes through the shared `fetchWithPolicy` (`../update-fetch.ts`)
103
+ * — same retry/timeout policy as the Pyth/Lazer fetches. No auth: the
104
+ * quote-center read surface is public.
105
+ *
106
+ * The URL is built with `joinEndpointPath`, not `new URL(path, endpoint)`: a
107
+ * leading-slash path is ABSOLUTE and silently drops the endpoint's own base
108
+ * path, which is exactly what a `waterxEndpoint` proxy route is (a
109
+ * `https://app.example/api/quote-center` override would have been rewritten to
110
+ * `https://app.example/v1/quotes/update`, bypassing the proxy). Same footgun
111
+ * that 404'd every Pyth Pro feed by dropping its `/hermes` prefix.
112
+ */
113
+ async function fetchWaterxSignedUpdate(endpoint, symbols, fetchOpts) {
114
+ const url = joinEndpointPath(endpoint, "v1/quotes/update");
115
+ url.searchParams.set("symbols", symbols.join(","));
116
+ let res;
117
+ try {
118
+ res = await fetchWithPolicy(url.toString(), { method: "GET" }, { ...fetchOpts });
119
+ }
120
+ catch (err) {
121
+ if (err instanceof FetchPolicyError && err.status !== undefined) {
122
+ const body = err.bodySnippet ? ` ${err.bodySnippet}` : "";
123
+ throw new Error(`WaterX quote-center fetch failed: ${err.status}${body} (retries exhausted after ${err.attempts} attempts)`, { cause: err });
124
+ }
125
+ throw err;
126
+ }
127
+ if (!res.ok) {
128
+ throw new Error(`WaterX quote-center fetch failed: ${res.status} ${await res.text()}`);
129
+ }
130
+ // Parse from raw text (not res.json()) so the u64 fields are decoded exact as
131
+ // bigint — see parseSignedEnvelope. Malformed-shape check lives there.
132
+ const envelope = parseSignedEnvelope(await res.text());
133
+ if (envelope.intent !== BATCH_PRICE_INTENT) {
134
+ throw new Error(`WaterX quote-center returned intent ${envelope.intent}, expected BATCH_PRICE_INTENT ${BATCH_PRICE_INTENT}`);
135
+ }
136
+ return envelope;
137
+ }
138
+ /** Narrow a `RuleUpdateData` to its `WaterxSignedEnvelope`, or `null`. */
139
+ export function waterxEnvelopeOf(data) {
140
+ const payload = assertRuleUpdateData(data, "waterx_rule", isWaterxUpdatePayloadShape, "{ envelope: { intent, timestamp_ms, payload: { items }, signature } }");
141
+ return payload?.envelope ?? null;
142
+ }
143
+ /** Strip an optional `0x` prefix, then decode hex → bytes. */
144
+ function decodeSig(hex) {
145
+ return fromHex(hex.startsWith("0x") ? hex.slice(2) : hex);
146
+ }
147
+ /**
148
+ * `waterx_rule::collect_batch_latest(collector, config, clock, enclave_config,
149
+ * enclave, timestamp_ms, payload, sig)` — rebuild the enclave-signed batch
150
+ * payload in-PTB (`new_batch_payload` + one `new_batch_item`/`push_batch_item`
151
+ * per item, the exact shape the enclave signed) and contribute the price for
152
+ * `collector.symbol()` to the collector. One collect call re-verifies the batch
153
+ * signature and picks this collector's symbol out of the batch; on-chain it
154
+ * abstains (records `none`) instead of aborting when the symbol is stale,
155
+ * absent from the batch, or its timestamp was already accepted (replay).
156
+ */
157
+ export function feedWaterxRule(tx, host, collector, envelope) {
158
+ const wr = requireWaterxPackage(host);
159
+ const pkg = wr.published_at;
160
+ const payload = newBatchPayload({ package: pkg })(tx);
161
+ for (const item of envelope.payload.items) {
162
+ // u64 fields are already exact bigints (see parseSignedEnvelope) — passed
163
+ // through verbatim so the rebuilt BCS matches the enclave's signed bytes.
164
+ const itemArg = newBatchItem({
165
+ package: pkg,
166
+ arguments: {
167
+ symbol: item.symbol,
168
+ ticker: item.ticker,
169
+ sources: item.sources,
170
+ method: item.method,
171
+ priceTimestampMs: item.price_timestamp_ms,
172
+ priceN: item.price_n,
173
+ priceScale: item.price_scale,
174
+ confidenceN: item.confidence_n,
175
+ confidenceScale: item.confidence_scale,
176
+ maxSourceDeviationBps: item.max_source_deviation_bps,
177
+ numSources: item.num_sources,
178
+ },
179
+ })(tx);
180
+ pushBatchItem({ package: pkg, arguments: { payload, item: itemArg } })(tx);
181
+ }
182
+ collectBatchLatest({
183
+ package: pkg,
184
+ arguments: {
185
+ collector,
186
+ config: tx.object(wr.config),
187
+ enclaveConfig: tx.object(wr.enclave_config),
188
+ enclave: tx.object(wr.enclave),
189
+ timestampMs: envelope.timestamp_ms,
190
+ payload,
191
+ sig: Array.from(decodeSig(envelope.signature)),
192
+ },
193
+ })(tx);
194
+ }
195
+ export const WaterxRule = {
196
+ kind: "waterx_rule",
197
+ // Verification is an in-Move ed25519 check with no Coin argument — no
198
+ // update fee — see `PriceUpdateRule.requiresFeeSource`.
199
+ requiresFeeSource: false,
200
+ /** Tickers with a `waterx_rule.feeds` entry (keyed by oracle ticker). */
201
+ supportedTickers(host) {
202
+ return Object.keys(host.config.packages.waterx_rule?.feeds ?? {});
203
+ },
204
+ /**
205
+ * Pulls one enclave-signed batch envelope covering `tickers` from the
206
+ * quote-center, and only returns it when it actually covers ALL of them.
207
+ *
208
+ * A 200 whose `items` omit a requested symbol is a valid, well-signed
209
+ * envelope — nothing downstream would reject it, and the build would emit a
210
+ * `collect_batch_latest` that abstains for the missing symbol, surfacing as
211
+ * an on-chain `EMissingPriceSource` (or a silently thinner weighted set) much
212
+ * later. Same coverage rule the cached path enforces in
213
+ * {@link WaterxRule.narrowUpdateData}; the difference is disposition — a
214
+ * cache miss falls back to this live fetch, whereas the live source itself
215
+ * coming up short has no fallback left, so it throws deterministically here.
216
+ */
217
+ async fetchUpdateData(host, tickers) {
218
+ if (tickers.length === 0)
219
+ return null;
220
+ // Package-level check first: a config without the deployment must say so,
221
+ // not fail per ticker as if only that feed were missing.
222
+ const { feeds } = requireWaterxPackage(host);
223
+ for (const ticker of tickers) {
224
+ if (feeds[ticker] === undefined) {
225
+ throw new Error(`No waterx_rule feed listed for ticker: ${ticker}`);
226
+ }
227
+ }
228
+ const { endpoint, fetch: fetchOpts } = resolveWaterxInfra(host);
229
+ const envelope = await fetchWaterxSignedUpdate(endpoint, tickers, fetchOpts);
230
+ const covered = new Set(envelope.payload.items.map((i) => i.symbol));
231
+ const missing = tickers.filter((t) => !covered.has(t));
232
+ if (missing.length > 0) {
233
+ throw new Error(`WaterX quote-center envelope does not cover ticker(s): ${missing.join(", ")} ` +
234
+ `(requested ${tickers.join(", ")}; served ${[...covered].join(", ") || "none"})`);
235
+ }
236
+ return { kind: "waterx_rule", payload: { envelope } };
237
+ },
238
+ /**
239
+ * One signed batch envelope carries a single ed25519 signature over its whole
240
+ * `payload` — it is indivisible: it can only be served whole (re-verified from
241
+ * the full item set). Returns the whole payload iff every requested ticker's
242
+ * item is present in THIS envelope; any coverage gap → `null` (miss), never a
243
+ * silent partial.
244
+ */
245
+ narrowUpdateData(_host, data, tickers) {
246
+ const envelope = waterxEnvelopeOf(data);
247
+ if (!envelope || tickers.length === 0)
248
+ return null;
249
+ const covered = new Set(envelope.payload.items.map((i) => i.symbol));
250
+ for (const ticker of tickers) {
251
+ if (!covered.has(ticker))
252
+ return null;
253
+ }
254
+ return { kind: "waterx_rule", payload: { envelope } };
255
+ },
256
+ /**
257
+ * No shared verify step: `waterx_rule::collect_batch_latest` bundles verify
258
+ * AND feed into one per-collector call, appended by {@link feedWaterxRule} in
259
+ * the per-ticker aggregate leg. So this emits nothing and returns `void` — the
260
+ * signed envelope reaches the feed leg via `aggregate.ts`'s per-ticker map
261
+ * (built from the group's fetched data), not a `RuleUpdateHandle`.
262
+ */
263
+ buildUpdateCalls(_tx, _host, _data, _opts) {
264
+ return;
265
+ },
266
+ };
@@ -16,7 +16,11 @@
16
16
  * `Authorization` header at all). This is the Phase-0 invariant of the
17
17
  * Pyth Pro migration: existing keyless deployments see no behavior change.
18
18
  * - Retries on network errors, HTTP 429, and HTTP 5xx, with exponential
19
- * backoff (`retryDelayMs * 2^attempt`, capped at `MAX_BACKOFF_MS`). Other
19
+ * backoff (`retryDelayMs * 2^attempt`, capped at `MAX_BACKOFF_MS`). A 429
20
+ * carrying a numeric `Retry-After` header uses the SERVER'S delay instead,
21
+ * when it fits under the same cap — a longer ask degrades to normal
22
+ * backoff rather than stalling a money-path build for tens of seconds.
23
+ * Other
20
24
  * 4xx statuses (401/400/403/404/…) are NOT retried — auth/bad-request
21
25
  * failures are deterministic, so that `Response` (`ok: false`) is handed
22
26
  * back on the first attempt for the caller to format its own
@@ -46,7 +50,7 @@
46
50
  * - Retry worst case: with the defaults (15s timeout × 3 attempts + ~0.75s of
47
51
  * backoff between them) a FULL outage takes up to ~46s to surface as a
48
52
  * `FetchPolicyError`, vs ~15s pre-3.2.0's single bare-`fetch` attempt.
49
- * Tunable per client via `config.pyth.fetch.{timeoutMs,retries}`.
53
+ * Tunable per client via the `pythFetch` create option (`{timeoutMs,retries}`).
50
54
  */
51
55
  export interface FetchPolicy {
52
56
  /** Per-attempt timeout (ms). Default 15_000. */
@@ -75,6 +79,32 @@ export declare class FetchPolicyError extends Error {
75
79
  attempts: number;
76
80
  });
77
81
  }
82
+ /**
83
+ * Rethrow a `catch`-ed {@link fetchWithPolicy} failure. When it is a
84
+ * status-carrying `FetchPolicyError` — a retryable status (429/5xx) that never
85
+ * recovered — throw a new Error `${describe(err)} (retries exhausted after N
86
+ * attempts)` with the original as `cause`; otherwise (a network-level
87
+ * exhaustion with no status, or any non-`FetchPolicyError`) rethrow it verbatim,
88
+ * since there is no domain reframing to add. `describe` builds the
89
+ * status-bearing prefix so each caller keeps its own message shape (the e2e
90
+ * transient detector keys off those prefixes) while the guard, the `retries
91
+ * exhausted` suffix, and the `cause` wrapping live in one place. `: never` so a
92
+ * caller's `catch` block is understood not to fall through.
93
+ */
94
+ export declare function rethrowExhaustedFetch(err: unknown, describe: (err: FetchPolicyError) => string): never;
95
+ /**
96
+ * Join an API `path` onto an `endpoint` PRESERVING the endpoint's own base
97
+ * path. `new URL(path, endpoint)` is the footgun this replaces: a
98
+ * leading-slash path is *absolute* and silently discards the endpoint's path
99
+ * — harmless for a bare-origin endpoint (`https://hermes.pyth.network`) but
100
+ * it dropped the `/hermes` prefix of the Pyth Pro compat endpoint and 404'd
101
+ * every feed (see `fetchPriceFeedsUpdateData`). Every oracle fetch that
102
+ * targets `<endpoint><fixed path>` must build its URL here.
103
+ */
104
+ /** One canonical trailing-slash trim — `joinEndpointPath` (URL building) and
105
+ * `pyth.ts`'s `memoKey` (endpoint identity) must never drift apart on it. */
106
+ export declare function trimTrailingSlashes(endpoint: string): string;
107
+ export declare function joinEndpointPath(endpoint: string, path: string): URL;
78
108
  /**
79
109
  * `fetch` with per-attempt timeout, bounded retry + backoff, and optional
80
110
  * Bearer auth. See the module header for the full policy. Both `init.signal`
@@ -16,7 +16,11 @@
16
16
  * `Authorization` header at all). This is the Phase-0 invariant of the
17
17
  * Pyth Pro migration: existing keyless deployments see no behavior change.
18
18
  * - Retries on network errors, HTTP 429, and HTTP 5xx, with exponential
19
- * backoff (`retryDelayMs * 2^attempt`, capped at `MAX_BACKOFF_MS`). Other
19
+ * backoff (`retryDelayMs * 2^attempt`, capped at `MAX_BACKOFF_MS`). A 429
20
+ * carrying a numeric `Retry-After` header uses the SERVER'S delay instead,
21
+ * when it fits under the same cap — a longer ask degrades to normal
22
+ * backoff rather than stalling a money-path build for tens of seconds.
23
+ * Other
20
24
  * 4xx statuses (401/400/403/404/…) are NOT retried — auth/bad-request
21
25
  * failures are deterministic, so that `Response` (`ok: false`) is handed
22
26
  * back on the first attempt for the caller to format its own
@@ -46,7 +50,7 @@
46
50
  * - Retry worst case: with the defaults (15s timeout × 3 attempts + ~0.75s of
47
51
  * backoff between them) a FULL outage takes up to ~46s to surface as a
48
52
  * `FetchPolicyError`, vs ~15s pre-3.2.0's single bare-`fetch` attempt.
49
- * Tunable per client via `config.pyth.fetch.{timeoutMs,retries}`.
53
+ * Tunable per client via the `pythFetch` create option (`{timeoutMs,retries}`).
50
54
  */
51
55
  const DEFAULT_TIMEOUT_MS = 15_000;
52
56
  const DEFAULT_RETRIES = 2;
@@ -70,12 +74,60 @@ export class FetchPolicyError extends Error {
70
74
  this.attempts = opts.attempts;
71
75
  }
72
76
  }
77
+ /**
78
+ * Rethrow a `catch`-ed {@link fetchWithPolicy} failure. When it is a
79
+ * status-carrying `FetchPolicyError` — a retryable status (429/5xx) that never
80
+ * recovered — throw a new Error `${describe(err)} (retries exhausted after N
81
+ * attempts)` with the original as `cause`; otherwise (a network-level
82
+ * exhaustion with no status, or any non-`FetchPolicyError`) rethrow it verbatim,
83
+ * since there is no domain reframing to add. `describe` builds the
84
+ * status-bearing prefix so each caller keeps its own message shape (the e2e
85
+ * transient detector keys off those prefixes) while the guard, the `retries
86
+ * exhausted` suffix, and the `cause` wrapping live in one place. `: never` so a
87
+ * caller's `catch` block is understood not to fall through.
88
+ */
89
+ export function rethrowExhaustedFetch(err, describe) {
90
+ if (err instanceof FetchPolicyError && err.status !== undefined) {
91
+ throw new Error(`${describe(err)} (retries exhausted after ${err.attempts} attempts)`, {
92
+ cause: err,
93
+ });
94
+ }
95
+ throw err;
96
+ }
97
+ /**
98
+ * Join an API `path` onto an `endpoint` PRESERVING the endpoint's own base
99
+ * path. `new URL(path, endpoint)` is the footgun this replaces: a
100
+ * leading-slash path is *absolute* and silently discards the endpoint's path
101
+ * — harmless for a bare-origin endpoint (`https://hermes.pyth.network`) but
102
+ * it dropped the `/hermes` prefix of the Pyth Pro compat endpoint and 404'd
103
+ * every feed (see `fetchPriceFeedsUpdateData`). Every oracle fetch that
104
+ * targets `<endpoint><fixed path>` must build its URL here.
105
+ */
106
+ /** One canonical trailing-slash trim — `joinEndpointPath` (URL building) and
107
+ * `pyth.ts`'s `memoKey` (endpoint identity) must never drift apart on it. */
108
+ export function trimTrailingSlashes(endpoint) {
109
+ return endpoint.replace(/\/+$/, "");
110
+ }
111
+ export function joinEndpointPath(endpoint, path) {
112
+ return new URL(`${trimTrailingSlashes(endpoint)}/${path.replace(/^\/+/, "")}`);
113
+ }
73
114
  function isRetryableStatus(status) {
74
115
  return status === 429 || status >= 500;
75
116
  }
76
117
  function backoffMs(retryDelayMs, attempt) {
77
118
  return Math.min(retryDelayMs * 2 ** attempt, MAX_BACKOFF_MS);
78
119
  }
120
+ /**
121
+ * The server's own `Retry-After` (numeric-seconds form only), when present
122
+ * and within `MAX_BACKOFF_MS` — `undefined` otherwise (absent, HTTP-date
123
+ * form, zero/garbage, or an ask too long to honor inside a build path).
124
+ */
125
+ function retryAfterMs(response) {
126
+ // Optional-chained: minimal test doubles (and some fetch shims) carry no
127
+ // `headers` — a missing header must read as "no hint", never throw.
128
+ const ms = Number(response.headers?.get?.("retry-after")) * 1_000;
129
+ return ms > 0 && ms <= MAX_BACKOFF_MS ? ms : undefined;
130
+ }
79
131
  /** `host + pathname` only — never the query string (feed ids are noise, not diagnostic). */
80
132
  function describeTarget(url) {
81
133
  const parsed = new URL(url);
@@ -182,11 +234,13 @@ export async function fetchWithPolicy(url, init = {}, policy = {}, externalSigna
182
234
  let status;
183
235
  let bodySnippet;
184
236
  let cause;
237
+ let serverRetryDelay;
185
238
  try {
186
239
  const response = await doFetch(url, { ...init, headers, signal });
187
240
  if (response.ok || !isRetryableStatus(response.status))
188
241
  return response;
189
242
  status = response.status;
243
+ serverRetryDelay = retryAfterMs(response);
190
244
  if (attempt === retries) {
191
245
  bodySnippet = await readBodySnippet(response);
192
246
  }
@@ -213,7 +267,7 @@ export async function fetchWithPolicy(url, init = {}, policy = {}, externalSigna
213
267
  const detail = statusDetail ?? causeMessage(cause);
214
268
  throw new FetchPolicyError(`fetchWithPolicy: ${describeTarget(url)} failed after ${attempt + 1} attempt(s) — ${detail}`, { status, bodySnippet, cause, attempts: attempt + 1 });
215
269
  }
216
- await sleep(backoffMs(retryDelayMs, attempt), combinedExternalSignal);
270
+ await sleep(serverRetryDelay ?? backoffMs(retryDelayMs, attempt), combinedExternalSignal);
217
271
  }
218
272
  // Unreachable: the loop above always returns or throws on its final
219
273
  // (attempt === retries) iteration — this satisfies the compiler only.