@rhinestone/service-sdk 0.2.11 → 0.2.12

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/dist/chains.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { FeeValuesEIP1559, PublicClient } from 'viem';
2
2
  /** Client methods required to estimate gas fees. */
3
- export type FeeEstimationClient = Pick<PublicClient, 'estimateFeesPerGas' | 'getFeeHistory'>;
3
+ export type FeeEstimationClient = Pick<PublicClient, 'estimateFeesPerGas' | 'getFeeHistory'> & Partial<Pick<PublicClient, 'chain'>>;
4
4
  /** Options for {@link estimateGasFees}. */
5
5
  export type EstimateGasFeesOptions = {
6
6
  /**
@@ -16,6 +16,11 @@ export type EstimateGasFeesOptions = {
16
16
  /**
17
17
  * Estimates gas fees for a chain and returns EIP-1559-shaped fee values.
18
18
  *
19
+ * `maxFeePerGas` is built from the NEXT block's base fee (not the last mined
20
+ * one) times a per-chain multiplier, plus the priority fee. The orchestrator
21
+ * bills this number, so unused headroom is charged to the user — see
22
+ * BASE_FEE_MULTIPLIER_PCT.
23
+ *
19
24
  * Pass `{ mode: 'legacy' }` to source the estimate from the RPC's legacy gas
20
25
  * price. The price is used for both EIP-1559 fee caps so it remains the
21
26
  * effective-price ceiling. On a no-priority local fork where the base fee
@@ -1 +1 @@
1
- {"version":3,"file":"chains.d.ts","sourceRoot":"","sources":["../src/chains.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAmB,YAAY,EAAE,MAAM,MAAM,CAAA;AAa3E,oDAAoD;AACpD,MAAM,MAAM,mBAAmB,GAAG,IAAI,CACpC,YAAY,EACZ,oBAAoB,GAAG,eAAe,CACvC,CAAA;AAED,2CAA2C;AAC3C,MAAM,MAAM,sBAAsB,GAAG;IACnC;;;;;;;OAOG;IACH,IAAI,CAAC,EAAE,QAAQ,CAAA;CAChB,CAAA;AA8GD;;;;;;;;;GASG;AACH,wBAAsB,eAAe,CACnC,MAAM,EAAE,mBAAmB,EAC3B,OAAO,EAAE,MAAM,EACf,OAAO,CAAC,EAAE,sBAAsB,GAC/B,OAAO,CAAC,gBAAgB,CAAC,CAsB3B"}
1
+ {"version":3,"file":"chains.d.ts","sourceRoot":"","sources":["../src/chains.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAmB,YAAY,EAAE,MAAM,MAAM,CAAA;AAmB3E,oDAAoD;AAMpD,MAAM,MAAM,mBAAmB,GAAG,IAAI,CACpC,YAAY,EACZ,oBAAoB,GAAG,eAAe,CACvC,GACC,OAAO,CAAC,IAAI,CAAC,YAAY,EAAE,OAAO,CAAC,CAAC,CAAA;AAEtC,2CAA2C;AAC3C,MAAM,MAAM,sBAAsB,GAAG;IACnC;;;;;;;OAOG;IACH,IAAI,CAAC,EAAE,QAAQ,CAAA;CAChB,CAAA;AAyTD;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,eAAe,CACnC,MAAM,EAAE,mBAAmB,EAC3B,OAAO,EAAE,MAAM,EACf,OAAO,CAAC,EAAE,sBAAsB,GAC/B,OAAO,CAAC,gBAAgB,CAAC,CA2C3B"}
package/dist/chains.js CHANGED
@@ -1,7 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.estimateGasFees = estimateGasFees;
4
+ const api_1 = require("@opentelemetry/api");
4
5
  const chains_1 = require("viem/chains");
6
+ const tipClamp_1 = require("./tipClamp");
7
+ const viemDefaults_1 = require("./viemDefaults");
5
8
  // Chains that price fees from a windowed eth_feeHistory instead of viem's
6
9
  // default estimator. Two membership rationales:
7
10
  // - volatile-base L1s (mainnet, sepolia): windowing the next-block base fee
@@ -20,35 +23,74 @@ const HISTORICAL_FEE_CHAINS = new Set([
20
23
  ]);
21
24
  const DEFAULT_FEE_CONFIG = {
22
25
  historyBlocks: 20, // Geth FullNodeGPO window
23
- baseFeeMultiplier: 2n, // ~6 blocks / ~70s headroom (cap only — free)
24
- tipPercentile: 50, // calm-market per-block tip (real mainnet p50 ≈ 0.03 gwei)
26
+ // Matches the viem-path default rather than the old 2.0x. Every chain on the
27
+ // historical path overrides this today (and Plasma's base fee is 0, so it is
28
+ // inert there), but leaving 2.0x here would silently hand the next chain added
29
+ // to HISTORICAL_FEE_CHAINS exactly the over-charge this PR removes.
30
+ baseFeeMultiplierPct: 105n,
31
+ tipPercentile: 50, // calm-market per-block tip; per-chain blocks override it
25
32
  congestedTipPercentile: 75, // escalate to p75 under load (not p90 — ~45x cost)
26
33
  congestionRatio: 0.5, // "gasUsedRatio trends > 0.5"
27
34
  windowTipPercentile: 50, // nearest-rank p50 across the window's per-block tips
28
35
  // 0.1 gwei. Enough to clear the zero-tip stall (the tip is the only builder
29
- // incentive — base fee is burned), without over-buffering: real mainnet p50 is
30
- // ~0.03 gwei, so this is ~3x the median, while 1 gwei would be ~30x.
36
+ // incentive — base fee is burned) without over-buffering. Chains that fill
37
+ // often enough to measure set their own floor from realized data; this is the
38
+ // fallback for one that does not.
31
39
  tipFloorWei: 100000000n,
32
40
  };
33
41
  // Per-chain overrides for the windowed feeHistory path; any omitted field falls
34
42
  // back to DEFAULT_FEE_CONFIG.
35
43
  const FEE_CONFIG_OVERRIDES = {
36
- // Testnet — not real money: aggressive percentile, no floor.
44
+ // Testnet: no tip floor — testnet inclusion is
45
+ // flaky and the tip is self-consistent (we bid it, we pay it, so it does not
46
+ // skew charged-vs-actual). The base multiplier is the part that was
47
+ // over-charging: billing 2.0x base while paying 1.0x put sepolia at A/C 0.70,
48
+ // the same defect as every other chain. Its measured base dispersion is 1.33x,
49
+ // so 1.30x sits just under the observed max rather than on the mean. The tip came
50
+ // down from p75 for the same reason: a 7d block replay puts sepolia's p75 at
51
+ // 1.0 gwei against a 0.0375 market p50, which alone held it at A/C 0.50.
37
52
  [chains_1.sepolia.id]: {
38
- tipPercentile: 75,
39
- congestedTipPercentile: 90,
53
+ baseFeeMultiplierPct: 130n,
54
+ tipPercentile: 50,
55
+ congestedTipPercentile: 75,
40
56
  tipFloorWei: 0n,
41
57
  },
42
- // Plasma is here for its broken tip oracle, not a volatile base: base fee ~0
43
- // and rewards flat at the chain-min (probed 2026-07-06, matching the RHI-4468
44
- // audit). Floor at the chain-min instead of the 0.1 gwei default — that
45
- // default is a 20x overpay at this chain's fee scale — so a quiet/empty
46
- // reward window can't stall a tx while flat markets are never overpaid.
47
- // Percentiles stay on defaults: the reward distribution is flat in calm
48
- // markets, so they only bite during real congestion, where the p50→p75
49
- // escalation is the wanted behavior.
58
+ // Mainnet's reward distribution is inflated by MEV-boost blocks — searchers
59
+ // pay enormous priority fees, so the feeHistory p50 prices our plain fills
60
+ // against searcher competition: 7d prod shows charged tip 0.152 gwei vs
61
+ // 0.011 realized (13.8x). Sample p25 and floor at the realized clearing
62
+ // price (the floor still clears the zero-tip stall — the tip is the only
63
+ // builder incentive on L1). Base is separate, and mainnet is the one chain
64
+ // where quote-time base is a poor predictor of fill-time base: 7d prod shows a
65
+ // charged base of 0.2199 at the old 2.0x (so a 0.110 base at quote) against a
66
+ // 0.1647 realized base — a measured 1.50x drift between quote and fill. The
67
+ // multiplier has to cover that drift, not the next block: 1.60x sits above the
68
+ // 1.50x mean so the cap still clears at fill time, and lands at A/C 0.95.
69
+ [chains_1.mainnet.id]: {
70
+ baseFeeMultiplierPct: 160n,
71
+ tipPercentile: 25,
72
+ congestedTipPercentile: 50,
73
+ tipFloorWei: 11000000n, // 0.011 gwei — the realized clearing tip; ~2x the 0.0054 market p50
74
+ },
75
+ // Plasma is on the historical path for its broken tip oracle, not a volatile
76
+ // base (its base fee is 0). Its reward distribution is a step function, not a
77
+ // gradient — probed live 2026-07-24 over 40 blocks:
78
+ // p1..p10 = 0 p15..p50 = 0.005 gwei
79
+ // so every percentile from 15 up reports the same 0.005, while real fills
80
+ // clear at ~0.0015 (third-party solvers are included on-chain at that price,
81
+ // so it demonstrably clears). Sampling anywhere above p10 therefore prices at
82
+ // 0.005 and the floor never binds — the tip must be read at p10, where the
83
+ // window median is genuinely 0, so the measured floor below sets the price.
50
84
  [chains_1.plasma.id]: {
51
- tipFloorWei: 5000000n, // 0.005 gwei chain-min priority fee
85
+ // Explicit, not inherited. Plasma's base fee is ~0 almost always, so this
86
+ // multiplier is free — but "almost" matters: a 7d block replay puts 0.4% of
87
+ // quotes under the base fee one block later at 1.05x, and 1.5% ten blocks
88
+ // later, against 0% at 2.0x. On 69k fills/wk that is real. Inheriting the
89
+ // shared default would silently re-introduce it.
90
+ baseFeeMultiplierPct: 200n,
91
+ tipPercentile: 10,
92
+ congestedTipPercentile: 25,
93
+ tipFloorWei: 1700000n, // 0.0017 gwei — measured clearing price (2026-07-24)
52
94
  },
53
95
  };
54
96
  // Chains where viem's default base-fee estimate is fine but its tip oracle
@@ -61,21 +103,182 @@ const TIP_FLOOR_WEI = {
61
103
  [chains_1.base.id]: 3000000n, // 0.003 gwei — oracle ~0.001 (network ~p25); real fills ~0.003-0.0036 (probed 2026-07-08)
62
104
  [chains_1.bsc.id]: 50000000n, // 0.05 gwei — post-Maxwell chain-min; oracle intermittently returns 0 through some upstreams, and with BSC's zero base fee that zeroes the ENTIRE bid (probed 2026-07-13)
63
105
  [chains_1.polygon.id]: 45000000000n, // 45 gwei — Bor min 30 gwei x1.5; real p50 tip ~200 gwei
64
- [chains_1.gnosis.id]: 1000000000n, // 1.0 gwei — viem oracle ~21 wei; real p90 inclusion ~1.0 gwei (re-probed 2026-06-25)
65
- [chains_1.hyperEvm.id]: 150000000n, // 0.15 gwei — oracle returns 0; realized p50 tip ~0.15 gwei
66
- [chains_1.monad.id]: 22000000000n, // 22 gwei — oracle 2 gwei; competitive tip p50/mode ~22 gwei
106
+ // 100 wei. Gnosis operates at a scale where gwei is the wrong unit to think
107
+ // in: its median base fee is 118 WEI (p10 24, p90 278k), and included-tx tips
108
+ // are 0 at p50 and p75. The old 1.0 gwei floor was ~8,500,000x the median base
109
+ // fee; an earlier revision of this PR cut it to 100_000 wei, which was still
110
+ // 850x the base and left the floor as the dominant term in the whole quote —
111
+ // it alone pinned Gnosis at A/C 0.00 no matter what the base multiplier did.
112
+ //
113
+ // 21 wei — viem's own oracle reading on this chain, so the floor effectively
114
+ // never binds and simply guards a zero-reporting upstream. Note the BSC
115
+ // failure mode (a 0 oracle zeroing the ENTIRE bid) cannot occur here: Gnosis'
116
+ // base fee is nonzero, so `base * mult` alone keeps the bid includable.
117
+ //
118
+ // Deliberately NOT sized for congestion. Gnosis does have busy episodes
119
+ // needing ~263,000 wei, but no static floor is the right tool — it would
120
+ // over-charge the calm majority without covering the tail. If that tail ever
121
+ // matters (it does not at 3 fills/wk), the fix is moving Gnosis to
122
+ // HISTORICAL_FEE_CHAINS for the windowed path's congestion escalation, not a
123
+ // bigger floor.
124
+ [chains_1.gnosis.id]: 21n,
125
+ // Left at its existing value deliberately. HyperEVM over-charges the tip by
126
+ // ~16x (0.158 charged vs 0.00953 realized) but under-charges the base (0.189
127
+ // charged vs 0.354 realized), and the two errors cancel to an overall A/C of
128
+ // 1.05. Correcting only the tip would turn that balance into a ~1.65
129
+ // under-charge, so the tip stays and the base carries the correction instead
130
+ // — see BASE_FEE_MULTIPLIER_PCT_OVERRIDES. Revisit with a fill-side base probe.
131
+ [chains_1.hyperEvm.id]: 150000000n, // 0.15 gwei
132
+ // 3 gwei — feeHistory p50 2.0 / p75 3.0 of included txs, over 2,100 blocks
133
+ // spanning 5.1d (2026-07-24). This contradicts the previous comment's
134
+ // "competitive tip p50/mode ~22 gwei" (2026-06-25); both claim a p50 and they
135
+ // differ 7x. The old figure is not reproducible against current chain data.
136
+ // Monad rides the shared 1.05x base multiplier with no cushion, so an error
137
+ // here surfaces directly; the lag replay puts it at 0% under-capped at every
138
+ // lag tested.
139
+ [chains_1.monad.id]: 3000000000n,
67
140
  };
68
141
  // Chains whose real tip drifts above the static floor in busy markets — also
69
142
  // window the feeHistory tip and take the max with the floor. Polygon: real tip
70
143
  // runs ~6.7x its oracle. Base: realized tip drifts above the floor while the
71
144
  // oracle stays pinned — see TIP_FLOOR_WEI for the measured values. Both have
72
- // stable base fees, so the base stays on viem's default.
145
+ // stable base fees, but the base still gets the shared next-block treatment.
73
146
  const WINDOWED_TIP_CHAINS = new Set([chains_1.polygon.id, chains_1.base.id]);
147
+ // Upper bound on the priority fee, per chain.
148
+ //
149
+ // The counterpart to TIP_FLOOR_WEI: a floor can only ever RAISE a tip, so there
150
+ // was previously no way to correct a chain whose eth_maxPriorityFeePerGas
151
+ // OVER-reports. Several do, by a wide margin — the oracle quotes a price far
152
+ // above what transactions on that chain actually pay to get included.
153
+ //
154
+ // Each value is set from the strongest evidence that chain has. Where we fill
155
+ // often enough for our own realized tip to mean something (Optimism, Soneium,
156
+ // BSC) the cap sits at that realized price. Where we do not (Sonic, Avalanche:
157
+ // 3-5 fills/wk, too few to be anything but noise — Gnosis' realized price over
158
+ // 3 fills came out BELOW its own base fee, which is impossible) it is set from
159
+ // eth_feeHistory rewards, the tips included transactions actually paid, at
160
+ // roughly the market p75/p90 — demonstrably enough to be included, never the
161
+ // median.
162
+ const TIP_CAP_WEI = {
163
+ // Arbitrum, Unichain and Katana are deliberately absent: their charged tip
164
+ // already matches realized. Unichain and Katana sit at A/C 0.94/0.91, so a cap
165
+ // there would under-charge; Arbitrum's oracle returns 0 outright (38,387
166
+ // samples over 7d, all zero — it has no priority-fee auction), so a cap there
167
+ // would simply be a no-op.
168
+ [chains_1.optimism.id]: 400000n, // 0.0004 gwei — realized tip 0.00038 vs a flat 0.001 oracle quote
169
+ [chains_1.soneium.id]: 700000n, // 0.0007 gwei — realized tip 0.000706 across 1198 fills/wk (so this clamps a hair under realized), vs a flat 0.001 oracle quote
170
+ [chains_1.sonic.id]: 100000n, // 0.0001 gwei — deliberately 5x the 0.00002 market p90, since 3 fills/wk is too thin to trust; still a ~5000x cut from the 0.525 the oracle drove
171
+ [chains_1.avalanche.id]: 1000000n, // 0.001 gwei — market p75/p90; charged tip was 0.0117
172
+ [chains_1.bsc.id]: 55000000n, // 0.055 gwei — realized tip 0.0542, market p50 0.052; the oracle drifts to 0.0629 above the 0.05 chain-min
173
+ };
74
174
  const WINDOWED_TIP_HISTORY_BLOCKS = 20;
175
+ // Stays at p50 for both windowed chains. Polygon looked like a candidate for a
176
+ // lower percentile, but its market p25 (167 gwei, probed 2026-07-24) is ABOVE
177
+ // what we already charge (~154) while our own fills clear at ~85 — our fills
178
+ // price below the entire included-tx distribution, which no percentile of that
179
+ // distribution can express.
75
180
  const WINDOWED_TIP_PERCENTILE = 50; // p50 per block, nearest-rank across the window
181
+ // Base-fee headroom applied on the viem estimator path, as a percentage.
182
+ //
183
+ // This is pure CAP headroom against a base fee that rises between quote and
184
+ // submission: on-chain you only ever pay `base + tip`, so unused headroom is
185
+ // never spent — but the orchestrator BILLS this number (and sizes gas-refund
186
+ // ceilings and bridged native reserves from it), so over-buffering is charged
187
+ // to the user.
188
+ //
189
+ // It is applied to the NEXT block's base fee, which eth_feeHistory reports
190
+ // directly (the historical path has always used it). That matters: viem's own
191
+ // estimate scales the LATEST MINED block's base by 1.2x, and since EIP-1559
192
+ // allows the base to grow at most 12.5% per block, viem's 1.2x is really
193
+ // "one worst-case block (1.125x) plus ~6% margin" — not 20% of slack. Pricing
194
+ // off the next block's actual base instead of that worst case is where the
195
+ // over-charge disappears: 7d of prod fills show the stable-base chains on this
196
+ // path billing exactly 1.20x what they pay (Base 0.00602 charged vs 0.00501
197
+ // realized; Arbitrum 0.02412 vs 0.02008), because their blocks are not full
198
+ // and the worst case never materialises.
199
+ //
200
+ // 1.05x of the real next-block base keeps genuine headroom while removing that
201
+ // systematic over-charge. If the base outruns it, the relayer's tx queue
202
+ // re-prices and bumps unbounded (relayer/src/tx_queue/nonce-slot.ts, reprice /
203
+ // bumpedFees) — a latency cost, not an inclusion failure.
204
+ //
205
+ // That unbounded bump is a phase-1 posture, not a permanent property: the
206
+ // relayer omits `feeCeiling` and `gasPriceCap` "isn't wired from the intent
207
+ // yet" (relayer/src/tx_queue/config.ts). Once it is, `exceedsCap` flips the
208
+ // slot to `flipToCancelTx` and the fill is ABANDONED rather than delayed — and
209
+ // since that cap derives from the quoted maxFeePerGas, shrinking headroom
210
+ // shrinks the abandon threshold with it. Revisit this multiplier then.
211
+ const BASE_FEE_MULTIPLIER_PCT = 105n;
212
+ // Per-chain base-fee multiplier for the viem paths. Only HyperEVM needs one: its
213
+ // realized base runs ~2.25x the base quoted to us (0.354 vs 0.157, 7d prod),
214
+ // which no next-block reading can see. It is also the one chain whose two errors
215
+ // currently cancel — the tip over-charges ~16x and the base under-charges, netting
216
+ // A/C 1.05 — so dropping it to the shared 1.05 would unbalance it to 1.12. The
217
+ // tip is deliberately left alone for the same reason; 1.30x restores A/C ~1.00.
218
+ // Sized from a 7d block replay measuring, per chain, the share of quotes whose
219
+ // cap falls below the base fee of the block they would land in at a 1-5 block
220
+ // lag. The shared 1.05x holds every chain at 0% except these two.
221
+ const BASE_FEE_MULTIPLIER_PCT_OVERRIDES = {
222
+ [chains_1.hyperEvm.id]: 130n,
223
+ // Gnosis' tip is ~0 in calm markets (market p50 AND p75 are both zero), so
224
+ // its A/C is bounded almost entirely by this multiplier — we bill the cap, so
225
+ // A/C ~= 1/mult. That makes the multiplier the only real lever, and the
226
+ // question is how little headroom its base movement actually needs.
227
+ //
228
+ // Measured over 7d: 1.15x leaves 0% of quotes under-capped at one block, 1.7%
229
+ // at five and 4.3% at ten, at A/C 0.98. The shared 1.05x would be 14%/28%,
230
+ // which is why Gnosis needs an override at all.
231
+ //
232
+ // Be clear that this IS a trade, not a free win. main is 0% under-capped at
233
+ // every lag — but only because its 1.0 gwei tip floor (8,500,000x this chain's
234
+ // median base fee) accidentally supplied enormous cap headroom while pinning
235
+ // A/C at 0.00. Removing that floor is what surfaces the exposure. What we are
236
+ // buying: A/C 0.00 -> 0.98. What we are paying: a relayer re-price on roughly
237
+ // 1.7% of quotes, on a chain filling ~3x/week — about one bump every seven
238
+ // months, and a re-price is latency, not a lost fill.
239
+ //
240
+ // 1.30x is the conservative alternative: A/C 0.88 at 0.1%/0.2%. Prefer it if
241
+ // Gnosis volume ever becomes non-trivial.
242
+ [chains_1.gnosis.id]: 115n,
243
+ // 0.9% under-capped at one block and 8.1% at five, against 0% today. 1.15x
244
+ // returns it to 0% through a ten-block lag while still improving A/C from
245
+ // 0.70 to 0.86 — better than today on both axes, which 1.05x is not.
246
+ [chains_1.avalanche.id]: 115n,
247
+ };
248
+ const baseFeeMultiplierPct = (chainId) => BASE_FEE_MULTIPLIER_PCT_OVERRIDES[chainId] ?? BASE_FEE_MULTIPLIER_PCT;
249
+ /**
250
+ * Next-block base fee, as reported by eth_feeHistory (last entry).
251
+ *
252
+ * Returns `null` — never 0n — when the RPC omits it or returns an empty array:
253
+ * a 0 base would silently price the cap at the tip alone, which under-prices
254
+ * on every chain with a nonzero base. `null` routes the caller to viem's own
255
+ * (strictly more conservative) cap instead.
256
+ */
257
+ async function nextBlockBaseFee(client, chainId) {
258
+ const feeHistory = await client.getFeeHistory({
259
+ blockCount: 1,
260
+ rewardPercentiles: [],
261
+ });
262
+ const baseFee = feeHistory.baseFeePerGas?.at(-1);
263
+ if (baseFee === undefined) {
264
+ // Recorded HERE rather than at the caller's `.catch`, because this is a
265
+ // SUCCESSFUL response carrying no base fee — it never throws, so the catch
266
+ // does not see it. That is the same failure class the windowed path records
267
+ // as `feehistory_missing_base`, and leaving it out would make the one case
268
+ // viem's transport retries cannot absorb the one case with no signal.
269
+ recordDegradedEstimate(chainId, 'feehistory_missing_base');
270
+ return null;
271
+ }
272
+ return baseFee;
273
+ }
76
274
  /**
77
275
  * Estimates gas fees for a chain and returns EIP-1559-shaped fee values.
78
276
  *
277
+ * `maxFeePerGas` is built from the NEXT block's base fee (not the last mined
278
+ * one) times a per-chain multiplier, plus the priority fee. The orchestrator
279
+ * bills this number, so unused headroom is charged to the user — see
280
+ * BASE_FEE_MULTIPLIER_PCT.
281
+ *
79
282
  * Pass `{ mode: 'legacy' }` to source the estimate from the RPC's legacy gas
80
283
  * price. The price is used for both EIP-1559 fee caps so it remains the
81
284
  * effective-price ceiling. On a no-priority local fork where the base fee
@@ -96,47 +299,151 @@ async function estimateGasFees(client, chainId, options) {
96
299
  }
97
300
  if (HISTORICAL_FEE_CHAINS.has(chainId)) {
98
301
  const config = { ...DEFAULT_FEE_CONFIG, ...FEE_CONFIG_OVERRIDES[chainId] };
99
- return estimateFeeHistoryGasFees(client, config);
302
+ return estimateFeeHistoryGasFees(client, chainId, config);
100
303
  }
101
304
  const tipFloorWei = TIP_FLOOR_WEI[chainId];
102
305
  if (tipFloorWei !== undefined) {
103
306
  return estimateViemFeesWithTipFloor(client, chainId, tipFloorWei);
104
307
  }
105
- return client.estimateFeesPerGas();
308
+ const [fees, baseFee] = await Promise.all([
309
+ client.estimateFeesPerGas(),
310
+ nextBlockBaseFee(client, chainId).catch(() => {
311
+ recordDegradedEstimate(chainId, 'feehistory_error');
312
+ return null;
313
+ }),
314
+ ]);
315
+ const oracleTip = fees.maxPriorityFeePerGas;
316
+ // The cap applies here too: the chains whose oracle over-reports (Optimism,
317
+ // Soneium, Sonic, Avalanche) have no tip floor, so this is their only path.
318
+ const tip = (0, tipClamp_1.clampTip)(oracleTip, undefined, TIP_CAP_WEI[chainId]);
319
+ return {
320
+ maxFeePerGas: cappedMaxFeePerGas(client, fees, baseFee, chainId, tip, oracleTip),
321
+ maxPriorityFeePerGas: tip,
322
+ };
106
323
  }
107
- // viem's default fee estimate (base fee + oracle tip), with the priority fee
108
- // lifted to an absolute floor — and, for windowed-tip chains, to the windowed
109
- // feeHistory tip. The maxFeePerGas cap is raised by the same delta so the lifted
110
- // tip is never clipped. The tip is only ever raised, never lowered, so this
111
- // preserves viem's estimate whenever it already exceeds the floor.
324
+ // viem's oracle tip, lifted to an absolute floor — and, for windowed-tip chains,
325
+ // to the windowed feeHistory tip — then clamped down to any TIP_CAP_WEI. The tip
326
+ // is therefore raised OR lowered here; the cap applies first and the floor
327
+ // last, so a floor is never undercut by a cap.
328
+ //
329
+ // maxFeePerGas is rebuilt from the next block's base fee rather than adjusted
330
+ // off viem's, falling back to viem's own cap plus the tip delta when the base
331
+ // read fails.
112
332
  async function estimateViemFeesWithTipFloor(client, chainId, tipFloorWei) {
113
- const fees = await client.estimateFeesPerGas();
333
+ // Windowed chains get their tip AND the next-block base from one 20-block
334
+ // feeHistory response: that response already carries `baseFeePerGas` with the
335
+ // next block's base as its last entry, so a second blockCount:1 call would
336
+ // re-fetch a number we are holding. Everyone else reads the base alone.
337
+ //
338
+ // Best-effort throughout: a feeHistory outage must never turn into a quote
339
+ // outage. On failure the tip falls back to max(oracle, floor) — exactly the
340
+ // pre-overlay pricing — and the cap to viem's own, which is strictly more
341
+ // conservative, never less.
342
+ const [fees, history] = await Promise.all([
343
+ client.estimateFeesPerGas(),
344
+ (WINDOWED_TIP_CHAINS.has(chainId)
345
+ ? windowedFeeHistoryTip(client, chainId)
346
+ : nextBlockBaseFee(client, chainId).then((baseFee) => ({
347
+ tip: 0n,
348
+ baseFee,
349
+ }))).catch(() => {
350
+ recordDegradedEstimate(chainId, 'feehistory_error');
351
+ return { tip: 0n, baseFee: null };
352
+ }),
353
+ ]);
114
354
  const oracleTip = fees.maxPriorityFeePerGas;
115
355
  let tip = oracleTip;
116
- if (WINDOWED_TIP_CHAINS.has(chainId)) {
117
- // Best-effort: the windowed overlay refines the tip but must never turn a
118
- // feeHistory outage into a quote outage — on failure, fall back to
119
- // max(oracle, floor), which is exactly the pre-overlay pricing.
120
- const windowedTip = await windowedFeeHistoryTip(client).catch(() => 0n);
121
- if (windowedTip > tip)
122
- tip = windowedTip;
123
- }
124
- if (tipFloorWei > tip)
125
- tip = tipFloorWei;
356
+ if (history.tip > tip)
357
+ tip = history.tip;
358
+ tip = (0, tipClamp_1.clampTip)(tip, tipFloorWei, TIP_CAP_WEI[chainId]);
126
359
  return {
127
- maxFeePerGas: fees.maxFeePerGas + (tip - oracleTip),
360
+ maxFeePerGas: cappedMaxFeePerGas(client, fees, history.baseFee, chainId, tip, oracleTip),
128
361
  maxPriorityFeePerGas: tip,
129
362
  };
130
363
  }
131
- // Nearest-rank p50 across the window's per-block p50 tips — used to track a busy
132
- // market that the static oracle/floor lags.
133
- async function windowedFeeHistoryTip(client) {
364
+ /**
365
+ * The base-fee multiplier viem ACTUALLY applied, as a percentage, read from the
366
+ * client's own chain config exactly as viem reads it
367
+ * (`chain?.fees?.baseFeeMultiplier ?? 1.2`). Used to invert viem's cap on the
368
+ * degraded path, so the reconstruction cannot drift from the viem the CONSUMER
369
+ * is running (viem is a `^2` peer range, so it need not match the version CI
370
+ * pins) or from a consumer that sets a custom multiplier on its chain.
371
+ *
372
+ * Falls back to {@link VIEM_DEFAULT_BASE_FEE_MULTIPLIER_PCT} only when the value
373
+ * is not a plain number — i.e. absent, or viem's function form, which viem
374
+ * evaluates with a block/request context this path does not have. No supported
375
+ * chain uses the function form, and that fallback is exactly today's behaviour,
376
+ * so it is a no-op regression-wise.
377
+ */
378
+ function viemBaseFeeMultiplierPct(client) {
379
+ const m = client.chain?.fees?.baseFeeMultiplier;
380
+ // Number.isFinite, not just `>= 1`: Infinity passes `>= 1` and would make
381
+ // BigInt(Math.round(m * 100)) throw, crashing the estimate on a degraded path.
382
+ // (NaN is already excluded by `>= 1` being false, but this states it.) An
383
+ // invalid multiplier falls back to the pinned default rather than crashing.
384
+ if (typeof m === 'number' && Number.isFinite(m) && m >= 1) {
385
+ return BigInt(Math.round(m * 100));
386
+ }
387
+ return viemDefaults_1.VIEM_DEFAULT_BASE_FEE_MULTIPLIER_PCT;
388
+ }
389
+ /**
390
+ * Cap = nextBlockBaseFee * multiplier + tip.
391
+ *
392
+ * When the base fee could not be read, falls back to viem's own cap — but only
393
+ * where that is genuinely more conservative. viem builds its cap as
394
+ * `latestBase * 1.2 + tip`, so it is more conservative ONLY for chains whose
395
+ * multiplier is at or below 1.2x. For the ones above it (Gnosis 2.0x, HyperEVM
396
+ * 1.30x) viem's cap is LOWER than intended — measured at 62% and 93% of the
397
+ * healthy cap — which on Gnosis reopens exactly the under-capping its 2.0x
398
+ * override exists to close, precisely when the RPC is unhealthy and base fees
399
+ * move. For those chains we recover viem's implied base and re-apply our own
400
+ * multiplier, taking whichever cap is higher.
401
+ */
402
+ function cappedMaxFeePerGas(client, fees, baseFee, chainId, tip, oracleTip) {
403
+ const pct = baseFeeMultiplierPct(chainId);
404
+ if (baseFee === null) {
405
+ const viemPct = viemBaseFeeMultiplierPct(client);
406
+ const viemCap = fees.maxFeePerGas + (tip - oracleTip);
407
+ let cap = viemCap;
408
+ if (pct > viemPct) {
409
+ const impliedBase = ((fees.maxFeePerGas - oracleTip) * 100n) / viemPct;
410
+ const scaled = (impliedBase * pct) / 100n + tip;
411
+ if (scaled > cap)
412
+ cap = scaled;
413
+ }
414
+ // Never return below the tip itself. viem's default guarantees
415
+ // maxFeePerGas >= oracleTip, so `viemCap` is non-negative and this cannot
416
+ // bind today — but the guarantee is viem's, not ours, and an estimator that
417
+ // broke it would otherwise make BOTH arms negative (a `maxFeePerGas <=
418
+ // oracleTip` precondition would not help: it routes to viemCap, which is
419
+ // exactly the negative value).
420
+ return cap > tip ? cap : tip;
421
+ }
422
+ return (baseFee * pct) / 100n + tip;
423
+ }
424
+ /**
425
+ * Nearest-rank p50 across the window's per-block p50 tips — used to track a busy
426
+ * market that the static oracle/floor lags — plus the next block's base fee,
427
+ * which the same response already carries as the last `baseFeePerGas` entry.
428
+ *
429
+ * `baseFee` is `null`, never `0n`, when that array is missing or empty, for the
430
+ * same reason as {@link nextBlockBaseFee}: a 0 base would price the cap at the
431
+ * tip alone.
432
+ */
433
+ async function windowedFeeHistoryTip(client, chainId) {
134
434
  const feeHistory = await client.getFeeHistory({
135
435
  blockCount: WINDOWED_TIP_HISTORY_BLOCKS,
136
436
  rewardPercentiles: [WINDOWED_TIP_PERCENTILE],
137
437
  });
138
438
  const perBlockTips = (feeHistory.reward ?? []).map((r) => r?.[0] ?? 0n);
139
- return percentileBigInt(perBlockTips, WINDOWED_TIP_PERCENTILE);
439
+ const baseFee = feeHistory.baseFeePerGas?.at(-1);
440
+ if (baseFee === undefined) {
441
+ recordDegradedEstimate(chainId, 'feehistory_missing_base');
442
+ }
443
+ return {
444
+ tip: percentileBigInt(perBlockTips, WINDOWED_TIP_PERCENTILE),
445
+ baseFee: baseFee ?? null,
446
+ };
140
447
  }
141
448
  // Windowed eth_feeHistory estimator:
142
449
  // tip = `windowTipPercentile` across the last N blocks' per-block
@@ -144,8 +451,59 @@ async function windowedFeeHistoryTip(client) {
144
451
  // (or `congestedTipPercentile` when the window's mean
145
452
  // gasUsedRatio exceeds `congestionRatio`), floored at
146
453
  // `tipFloorWei`.
147
- // maxFeePerGas = nextBlockBaseFee * baseFeeMultiplier + tip
148
- async function estimateFeeHistoryGasFees(client, config) {
454
+ // maxFeePerGas = nextBlockBaseFee * baseFeeMultiplierPct / 100 + tip
455
+ /**
456
+ * Records that a quote was produced by a DEGRADED path — eth_feeHistory failed
457
+ * or answered without a base fee, so the cap came from viem rather than from the
458
+ * next block's actual base.
459
+ *
460
+ * This exists because the degradation is otherwise invisible. Every fallback
461
+ * here is deliberately silent at the call site (a feeHistory outage must never
462
+ * become a quote outage), which means a broken upstream would quietly revert
463
+ * these chains to viem's 1.2x-of-latest pricing — the exact over-charge this
464
+ * module exists to remove — and nothing would say so. The estimate would still
465
+ * look healthy; it would just be wrong.
466
+ *
467
+ * Written to the active span rather than a logger: this package cannot reach
468
+ * the orchestrator's logger, and the orchestrator already wraps these calls in
469
+ * `withSpan('getGasFeeComponents')`, so the attributes land in Tempo without any
470
+ * wiring. `@opentelemetry/api` is a no-op when no SDK is registered, so this
471
+ * costs nothing for consumers who do not instrument.
472
+ */
473
+ function recordDegradedEstimate(chainId, reason) {
474
+ const span = api_1.trace.getActiveSpan();
475
+ if (!span)
476
+ return;
477
+ span.setAttribute('gas.estimate.degraded', true);
478
+ span.setAttribute('gas.estimate.degraded_reason', reason);
479
+ span.setAttribute('gas.estimate.chain_id', chainId);
480
+ span.addEvent('gas.estimate.degraded', { reason, 'chain.id': chainId });
481
+ }
482
+ /**
483
+ * Cap for the windowed estimator when eth_feeHistory answered without a base
484
+ * fee. Recovers the base implied by viem's own estimate and re-applies this
485
+ * chain's multiplier, never returning less than viem's cap or the tip itself.
486
+ *
487
+ * Deliberately does NOT adopt viem's tip — see the call site.
488
+ */
489
+ async function viemFallbackCap(client, config, priorityFee) {
490
+ const fees = await client.estimateFeesPerGas();
491
+ const oracleTip = fees.maxPriorityFeePerGas;
492
+ const viemCap = fees.maxFeePerGas + (priorityFee - oracleTip);
493
+ let cap = viemCap;
494
+ if (fees.maxFeePerGas > oracleTip) {
495
+ // Same reconstruction as cappedMaxFeePerGas: invert by the multiplier viem
496
+ // ACTUALLY applied for this consumer, not the pinned default — viem is a
497
+ // peer range and a consumer chain may set its own baseFeeMultiplier.
498
+ const viemPct = viemBaseFeeMultiplierPct(client);
499
+ const impliedBase = ((fees.maxFeePerGas - oracleTip) * 100n) / viemPct;
500
+ const scaled = (impliedBase * config.baseFeeMultiplierPct) / 100n;
501
+ if (scaled + priorityFee > cap)
502
+ cap = scaled + priorityFee;
503
+ }
504
+ return cap > priorityFee ? cap : priorityFee;
505
+ }
506
+ async function estimateFeeHistoryGasFees(client, chainId, config) {
149
507
  const feeHistory = await client.getFeeHistory({
150
508
  blockCount: config.historyBlocks,
151
509
  rewardPercentiles: [config.tipPercentile, config.congestedTipPercentile],
@@ -161,8 +519,30 @@ async function estimateFeeHistoryGasFees(client, config) {
161
519
  const priorityFee = windowedTip > config.tipFloorWei ? windowedTip : config.tipFloorWei;
162
520
  // baseFeePerGas has historyBlocks + 1 entries; the last is the *next* block's
163
521
  // base fee (the one our tx lands in), not the latest mined block's.
164
- const baseFee = feeHistory.baseFeePerGas.at(-1) ?? 0n;
165
- const maxFeePerGas = baseFee * config.baseFeeMultiplier + priorityFee;
522
+ //
523
+ // Optional chaining, not a bare `.at()`: an absent array would otherwise throw
524
+ // a TypeError here rather than reaching the fallback below.
525
+ const baseFee = feeHistory.baseFeePerGas?.at(-1);
526
+ if (baseFee === undefined) {
527
+ // The RPC answered without telling us the base fee. Defaulting to 0n would
528
+ // price the cap at the tip alone — on mainnet an unincludable bid AND a ~20x
529
+ // under-charge, silently — so that is not an option. Throwing is not a good
530
+ // one either: this is a successful HTTP 200 carrying bad data, so viem's
531
+ // transport retries never fire, and the chains on this path include Plasma
532
+ // at ~69k fills/wk. So fall back for the BASE only.
533
+ //
534
+ // Keeping `priorityFee` is the point. These chains are on the windowed path
535
+ // precisely because their tip oracle is untrustworthy (Plasma's reports
536
+ // 0.005 gwei against a measured 0.0015), so falling back to viem wholesale
537
+ // would trade a hard failure for a silent ~3x overcharge on our
538
+ // highest-volume chain. The tip we already computed is the calibrated one.
539
+ recordDegradedEstimate(chainId, 'feehistory_missing_base');
540
+ return {
541
+ maxFeePerGas: await viemFallbackCap(client, config, priorityFee),
542
+ maxPriorityFeePerGas: priorityFee,
543
+ };
544
+ }
545
+ const maxFeePerGas = (baseFee * config.baseFeeMultiplierPct) / 100n + priorityFee;
166
546
  return { maxFeePerGas, maxPriorityFeePerGas: priorityFee };
167
547
  }
168
548
  // Nearest-rank percentile over a bigint sample. Empty sample → 0n.
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Applies a chain's tip cap and floor, in the order that makes the FLOOR win.
3
+ *
4
+ * The cap is measured from what included transactions actually pay, so it is
5
+ * the tighter evidence of what a chain requires. The floor is a safety guard,
6
+ * and on BSC it is the chain's post-Maxwell protocol MINIMUM — a bid below it
7
+ * is rejected outright. So the cap applies first and the floor last, making
8
+ * `result >= floor` hold for any cap value, including a cap below the floor.
9
+ *
10
+ * No chain has a cap below its floor today (BSC is the only chain in both
11
+ * tables: cap 0.055, floor 0.05), which is exactly why this ordering has to be
12
+ * pinned by a test rather than by the current data.
13
+ *
14
+ * This lives in its own module rather than in `chains.ts` because `./chains` is
15
+ * a public package export — anything exported from there becomes SDK API, and
16
+ * consumers must not be able to depend on cap/floor ordering internals. This
17
+ * module has no `exports` subpath, so it is importable by the test and not by
18
+ * consumers.
19
+ */
20
+ export declare function clampTip(tip: bigint, floorWei: bigint | undefined, capWei: bigint | undefined): bigint;
21
+ //# sourceMappingURL=tipClamp.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tipClamp.d.ts","sourceRoot":"","sources":["../src/tipClamp.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,QAAQ,CACtB,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,MAAM,EAAE,MAAM,GAAG,SAAS,GACzB,MAAM,CAKR"}
@@ -0,0 +1,30 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.clampTip = clampTip;
4
+ /**
5
+ * Applies a chain's tip cap and floor, in the order that makes the FLOOR win.
6
+ *
7
+ * The cap is measured from what included transactions actually pay, so it is
8
+ * the tighter evidence of what a chain requires. The floor is a safety guard,
9
+ * and on BSC it is the chain's post-Maxwell protocol MINIMUM — a bid below it
10
+ * is rejected outright. So the cap applies first and the floor last, making
11
+ * `result >= floor` hold for any cap value, including a cap below the floor.
12
+ *
13
+ * No chain has a cap below its floor today (BSC is the only chain in both
14
+ * tables: cap 0.055, floor 0.05), which is exactly why this ordering has to be
15
+ * pinned by a test rather than by the current data.
16
+ *
17
+ * This lives in its own module rather than in `chains.ts` because `./chains` is
18
+ * a public package export — anything exported from there becomes SDK API, and
19
+ * consumers must not be able to depend on cap/floor ordering internals. This
20
+ * module has no `exports` subpath, so it is importable by the test and not by
21
+ * consumers.
22
+ */
23
+ function clampTip(tip, floorWei, capWei) {
24
+ let clamped = tip;
25
+ if (capWei !== undefined && capWei < clamped)
26
+ clamped = capWei;
27
+ if (floorWei !== undefined && floorWei > clamped)
28
+ clamped = floorWei;
29
+ return clamped;
30
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * viem's default base-fee multiplier, as a percentage.
3
+ *
4
+ * viem builds its EIP-1559 estimate as `base * baseFeeMultiplier + tip`, with
5
+ * `baseFeeMultiplier` defaulting to 1.2 (actions/public/estimateFeesPerGas.js:
6
+ * `chain?.fees?.baseFeeMultiplier ?? 1.2`).
7
+ *
8
+ * We depend on that value in exactly one place: the degraded path in
9
+ * `cappedMaxFeePerGas`, which runs viem's formula BACKWARDS to recover the base
10
+ * fee when eth_feeHistory could not supply one. If viem ever changes this
11
+ * default, that division silently mis-scales the two chains whose own
12
+ * multiplier exceeds it (Gnosis, HyperEVM) — no error, just a wrong number.
13
+ *
14
+ * Lives in its own module, rather than in `chains.ts`, so the test that pins it
15
+ * against viem's real behaviour can import the same constant the code uses
16
+ * instead of a copy that could drift. `./chains` is a public package export;
17
+ * this module has no `exports` subpath, so it stays internal.
18
+ */
19
+ export declare const VIEM_DEFAULT_BASE_FEE_MULTIPLIER_PCT = 120n;
20
+ //# sourceMappingURL=viemDefaults.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"viemDefaults.d.ts","sourceRoot":"","sources":["../src/viemDefaults.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,oCAAoC,OAAO,CAAA"}
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.VIEM_DEFAULT_BASE_FEE_MULTIPLIER_PCT = void 0;
4
+ /**
5
+ * viem's default base-fee multiplier, as a percentage.
6
+ *
7
+ * viem builds its EIP-1559 estimate as `base * baseFeeMultiplier + tip`, with
8
+ * `baseFeeMultiplier` defaulting to 1.2 (actions/public/estimateFeesPerGas.js:
9
+ * `chain?.fees?.baseFeeMultiplier ?? 1.2`).
10
+ *
11
+ * We depend on that value in exactly one place: the degraded path in
12
+ * `cappedMaxFeePerGas`, which runs viem's formula BACKWARDS to recover the base
13
+ * fee when eth_feeHistory could not supply one. If viem ever changes this
14
+ * default, that division silently mis-scales the two chains whose own
15
+ * multiplier exceeds it (Gnosis, HyperEVM) — no error, just a wrong number.
16
+ *
17
+ * Lives in its own module, rather than in `chains.ts`, so the test that pins it
18
+ * against viem's real behaviour can import the same constant the code uses
19
+ * instead of a copy that could drift. `./chains` is a public package export;
20
+ * this module has no `exports` subpath, so it stays internal.
21
+ */
22
+ exports.VIEM_DEFAULT_BASE_FEE_MULTIPLIER_PCT = 120n;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rhinestone/service-sdk",
3
- "version": "0.2.11",
3
+ "version": "0.2.12",
4
4
  "description": "Shared SDK for Rhinestone services",
5
5
  "author": {
6
6
  "name": "Rhinestone",