@rhinestone/service-sdk 0.2.11 → 0.2.13

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;AAyUD;;;;;;;;;;;;;;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,90 @@ 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 near 0.0014 (third-party solvers are included on-chain at that price
81
+ // ~10.7k times a day, so it demonstrably clears). Sampling anywhere above p10
82
+ // therefore prices at 0.005 and the floor never binds — the tip must be read
83
+ // at p10, where the window median is genuinely 0, so the floor below sets the
84
+ // price.
50
85
  [chains_1.plasma.id]: {
51
- tipFloorWei: 5000000n, // 0.005 gwei chain-min priority fee
86
+ // Explicit, not inherited. Plasma's base fee is ~0 almost always, so this
87
+ // multiplier is free — but "almost" matters: a 7d block replay puts 0.4% of
88
+ // quotes under the base fee one block later at 1.05x, and 1.5% ten blocks
89
+ // later, against 0% at 2.0x. On ~15k bundles/day priced through this chain
90
+ // that is real. Inheriting the shared default would silently re-introduce it.
91
+ baseFeeMultiplierPct: 200n,
92
+ tipPercentile: 10,
93
+ congestedTipPercentile: 25,
94
+ // 0.0014 gwei — where fills on this chain ACTUALLY clear, re-measured from
95
+ // prod on 2026-07-27 once the previous 0.0017 had deployed and could be
96
+ // checked against reality (it billed 1.25x the clearing price).
97
+ //
98
+ // Measured from the solver that fills ~10.7k plasma bundles/day: it is
99
+ // included at 0.00103 (7d median 0.001364). Inclusion at ~0.001 is therefore
100
+ // demonstrated tens of thousands of times a day. NOTE the on-chain reward
101
+ // percentiles disagree and must not be used here — being gas-weighted they
102
+ // read p25..p90 = 0.005, which is what a minority of gas-heavy transactions
103
+ // pay, not the bar for inclusion.
104
+ //
105
+ // Lowering it cannot hurt includability: plasma's base is ~0, so the cap is
106
+ // `base * 2.0 + floor` and the floor never gates it — the 7d replay reports
107
+ // a 0.00% unincludable rate for every floor from 0.0017 down to 0.0008, at
108
+ // every lag from 1 to 50 blocks. The floor only sets what we CHARGE.
109
+ tipFloorWei: 1400000n,
52
110
  },
53
111
  };
54
112
  // Chains where viem's default base-fee estimate is fine but its tip oracle
@@ -61,21 +119,182 @@ const TIP_FLOOR_WEI = {
61
119
  [chains_1.base.id]: 3000000n, // 0.003 gwei — oracle ~0.001 (network ~p25); real fills ~0.003-0.0036 (probed 2026-07-08)
62
120
  [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
121
  [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
122
+ // 100 wei. Gnosis operates at a scale where gwei is the wrong unit to think
123
+ // in: its median base fee is 118 WEI (p10 24, p90 278k), and included-tx tips
124
+ // are 0 at p50 and p75. The old 1.0 gwei floor was ~8,500,000x the median base
125
+ // fee; an earlier revision of this PR cut it to 100_000 wei, which was still
126
+ // 850x the base and left the floor as the dominant term in the whole quote —
127
+ // it alone pinned Gnosis at A/C 0.00 no matter what the base multiplier did.
128
+ //
129
+ // 21 wei — viem's own oracle reading on this chain, so the floor effectively
130
+ // never binds and simply guards a zero-reporting upstream. Note the BSC
131
+ // failure mode (a 0 oracle zeroing the ENTIRE bid) cannot occur here: Gnosis'
132
+ // base fee is nonzero, so `base * mult` alone keeps the bid includable.
133
+ //
134
+ // Deliberately NOT sized for congestion. Gnosis does have busy episodes
135
+ // needing ~263,000 wei, but no static floor is the right tool — it would
136
+ // over-charge the calm majority without covering the tail. If that tail ever
137
+ // matters (it does not at 3 fills/wk), the fix is moving Gnosis to
138
+ // HISTORICAL_FEE_CHAINS for the windowed path's congestion escalation, not a
139
+ // bigger floor.
140
+ [chains_1.gnosis.id]: 21n,
141
+ // Left at its existing value deliberately. HyperEVM over-charges the tip by
142
+ // ~16x (0.158 charged vs 0.00953 realized) but under-charges the base (0.189
143
+ // charged vs 0.354 realized), and the two errors cancel to an overall A/C of
144
+ // 1.05. Correcting only the tip would turn that balance into a ~1.65
145
+ // under-charge, so the tip stays and the base carries the correction instead
146
+ // — see BASE_FEE_MULTIPLIER_PCT_OVERRIDES. Revisit with a fill-side base probe.
147
+ [chains_1.hyperEvm.id]: 150000000n, // 0.15 gwei
148
+ // 3 gwei — feeHistory p50 2.0 / p75 3.0 of included txs, over 2,100 blocks
149
+ // spanning 5.1d (2026-07-24). This contradicts the previous comment's
150
+ // "competitive tip p50/mode ~22 gwei" (2026-06-25); both claim a p50 and they
151
+ // differ 7x. The old figure is not reproducible against current chain data.
152
+ // Monad rides the shared 1.05x base multiplier with no cushion, so an error
153
+ // here surfaces directly; the lag replay puts it at 0% under-capped at every
154
+ // lag tested.
155
+ [chains_1.monad.id]: 3000000000n,
67
156
  };
68
157
  // Chains whose real tip drifts above the static floor in busy markets — also
69
158
  // window the feeHistory tip and take the max with the floor. Polygon: real tip
70
159
  // runs ~6.7x its oracle. Base: realized tip drifts above the floor while the
71
160
  // 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.
161
+ // stable base fees, but the base still gets the shared next-block treatment.
73
162
  const WINDOWED_TIP_CHAINS = new Set([chains_1.polygon.id, chains_1.base.id]);
163
+ // Upper bound on the priority fee, per chain.
164
+ //
165
+ // The counterpart to TIP_FLOOR_WEI: a floor can only ever RAISE a tip, so there
166
+ // was previously no way to correct a chain whose eth_maxPriorityFeePerGas
167
+ // OVER-reports. Several do, by a wide margin — the oracle quotes a price far
168
+ // above what transactions on that chain actually pay to get included.
169
+ //
170
+ // Each value is set from the strongest evidence that chain has. Where we fill
171
+ // often enough for our own realized tip to mean something (Optimism, Soneium,
172
+ // BSC) the cap sits at that realized price. Where we do not (Sonic, Avalanche:
173
+ // 3-5 fills/wk, too few to be anything but noise — Gnosis' realized price over
174
+ // 3 fills came out BELOW its own base fee, which is impossible) it is set from
175
+ // eth_feeHistory rewards, the tips included transactions actually paid, at
176
+ // roughly the market p75/p90 — demonstrably enough to be included, never the
177
+ // median.
178
+ const TIP_CAP_WEI = {
179
+ // Arbitrum, Unichain and Katana are deliberately absent: their charged tip
180
+ // already matches realized. Unichain and Katana sit at A/C 0.94/0.91, so a cap
181
+ // there would under-charge; Arbitrum's oracle returns 0 outright (38,387
182
+ // samples over 7d, all zero — it has no priority-fee auction), so a cap there
183
+ // would simply be a no-op.
184
+ [chains_1.optimism.id]: 400000n, // 0.0004 gwei — realized tip 0.00038 vs a flat 0.001 oracle quote
185
+ [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
186
+ [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
187
+ [chains_1.avalanche.id]: 1000000n, // 0.001 gwei — market p75/p90; charged tip was 0.0117
188
+ [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
189
+ };
74
190
  const WINDOWED_TIP_HISTORY_BLOCKS = 20;
191
+ // Stays at p50 for both windowed chains. Polygon looked like a candidate for a
192
+ // lower percentile, but its market p25 (167 gwei, probed 2026-07-24) is ABOVE
193
+ // what we already charge (~154) while our own fills clear at ~85 — our fills
194
+ // price below the entire included-tx distribution, which no percentile of that
195
+ // distribution can express.
75
196
  const WINDOWED_TIP_PERCENTILE = 50; // p50 per block, nearest-rank across the window
197
+ // Base-fee headroom applied on the viem estimator path, as a percentage.
198
+ //
199
+ // This is pure CAP headroom against a base fee that rises between quote and
200
+ // submission: on-chain you only ever pay `base + tip`, so unused headroom is
201
+ // never spent — but the orchestrator BILLS this number (and sizes gas-refund
202
+ // ceilings and bridged native reserves from it), so over-buffering is charged
203
+ // to the user.
204
+ //
205
+ // It is applied to the NEXT block's base fee, which eth_feeHistory reports
206
+ // directly (the historical path has always used it). That matters: viem's own
207
+ // estimate scales the LATEST MINED block's base by 1.2x, and since EIP-1559
208
+ // allows the base to grow at most 12.5% per block, viem's 1.2x is really
209
+ // "one worst-case block (1.125x) plus ~6% margin" — not 20% of slack. Pricing
210
+ // off the next block's actual base instead of that worst case is where the
211
+ // over-charge disappears: 7d of prod fills show the stable-base chains on this
212
+ // path billing exactly 1.20x what they pay (Base 0.00602 charged vs 0.00501
213
+ // realized; Arbitrum 0.02412 vs 0.02008), because their blocks are not full
214
+ // and the worst case never materialises.
215
+ //
216
+ // 1.05x of the real next-block base keeps genuine headroom while removing that
217
+ // systematic over-charge. If the base outruns it, the relayer's tx queue
218
+ // re-prices and bumps unbounded (relayer/src/tx_queue/nonce-slot.ts, reprice /
219
+ // bumpedFees) — a latency cost, not an inclusion failure.
220
+ //
221
+ // That unbounded bump is a phase-1 posture, not a permanent property: the
222
+ // relayer omits `feeCeiling` and `gasPriceCap` "isn't wired from the intent
223
+ // yet" (relayer/src/tx_queue/config.ts). Once it is, `exceedsCap` flips the
224
+ // slot to `flipToCancelTx` and the fill is ABANDONED rather than delayed — and
225
+ // since that cap derives from the quoted maxFeePerGas, shrinking headroom
226
+ // shrinks the abandon threshold with it. Revisit this multiplier then.
227
+ const BASE_FEE_MULTIPLIER_PCT = 105n;
228
+ // Per-chain base-fee multiplier for the viem paths. Only HyperEVM needs one: its
229
+ // realized base runs ~2.25x the base quoted to us (0.354 vs 0.157, 7d prod),
230
+ // which no next-block reading can see. It is also the one chain whose two errors
231
+ // currently cancel — the tip over-charges ~16x and the base under-charges, netting
232
+ // A/C 1.05 — so dropping it to the shared 1.05 would unbalance it to 1.12. The
233
+ // tip is deliberately left alone for the same reason; 1.30x restores A/C ~1.00.
234
+ // Sized from a 7d block replay measuring, per chain, the share of quotes whose
235
+ // cap falls below the base fee of the block they would land in at a 1-5 block
236
+ // lag. The shared 1.05x holds every chain at 0% except these two.
237
+ const BASE_FEE_MULTIPLIER_PCT_OVERRIDES = {
238
+ [chains_1.hyperEvm.id]: 130n,
239
+ // Gnosis' tip is ~0 in calm markets (market p50 AND p75 are both zero), so
240
+ // its A/C is bounded almost entirely by this multiplier — we bill the cap, so
241
+ // A/C ~= 1/mult. That makes the multiplier the only real lever, and the
242
+ // question is how little headroom its base movement actually needs.
243
+ //
244
+ // Measured over 7d: 1.15x leaves 0% of quotes under-capped at one block, 1.7%
245
+ // at five and 4.3% at ten, at A/C 0.98. The shared 1.05x would be 14%/28%,
246
+ // which is why Gnosis needs an override at all.
247
+ //
248
+ // Be clear that this IS a trade, not a free win. main is 0% under-capped at
249
+ // every lag — but only because its 1.0 gwei tip floor (8,500,000x this chain's
250
+ // median base fee) accidentally supplied enormous cap headroom while pinning
251
+ // A/C at 0.00. Removing that floor is what surfaces the exposure. What we are
252
+ // buying: A/C 0.00 -> 0.98. What we are paying: a relayer re-price on roughly
253
+ // 1.7% of quotes, on a chain filling ~3x/week — about one bump every seven
254
+ // months, and a re-price is latency, not a lost fill.
255
+ //
256
+ // 1.30x is the conservative alternative: A/C 0.88 at 0.1%/0.2%. Prefer it if
257
+ // Gnosis volume ever becomes non-trivial.
258
+ [chains_1.gnosis.id]: 115n,
259
+ // 0.9% under-capped at one block and 8.1% at five, against 0% today. 1.15x
260
+ // returns it to 0% through a ten-block lag while still improving A/C from
261
+ // 0.70 to 0.86 — better than today on both axes, which 1.05x is not.
262
+ [chains_1.avalanche.id]: 115n,
263
+ };
264
+ const baseFeeMultiplierPct = (chainId) => BASE_FEE_MULTIPLIER_PCT_OVERRIDES[chainId] ?? BASE_FEE_MULTIPLIER_PCT;
265
+ /**
266
+ * Next-block base fee, as reported by eth_feeHistory (last entry).
267
+ *
268
+ * Returns `null` — never 0n — when the RPC omits it or returns an empty array:
269
+ * a 0 base would silently price the cap at the tip alone, which under-prices
270
+ * on every chain with a nonzero base. `null` routes the caller to viem's own
271
+ * (strictly more conservative) cap instead.
272
+ */
273
+ async function nextBlockBaseFee(client, chainId) {
274
+ const feeHistory = await client.getFeeHistory({
275
+ blockCount: 1,
276
+ rewardPercentiles: [],
277
+ });
278
+ const baseFee = feeHistory.baseFeePerGas?.at(-1);
279
+ if (baseFee === undefined) {
280
+ // Recorded HERE rather than at the caller's `.catch`, because this is a
281
+ // SUCCESSFUL response carrying no base fee — it never throws, so the catch
282
+ // does not see it. That is the same failure class the windowed path records
283
+ // as `feehistory_missing_base`, and leaving it out would make the one case
284
+ // viem's transport retries cannot absorb the one case with no signal.
285
+ recordDegradedEstimate(chainId, 'feehistory_missing_base');
286
+ return null;
287
+ }
288
+ return baseFee;
289
+ }
76
290
  /**
77
291
  * Estimates gas fees for a chain and returns EIP-1559-shaped fee values.
78
292
  *
293
+ * `maxFeePerGas` is built from the NEXT block's base fee (not the last mined
294
+ * one) times a per-chain multiplier, plus the priority fee. The orchestrator
295
+ * bills this number, so unused headroom is charged to the user — see
296
+ * BASE_FEE_MULTIPLIER_PCT.
297
+ *
79
298
  * Pass `{ mode: 'legacy' }` to source the estimate from the RPC's legacy gas
80
299
  * price. The price is used for both EIP-1559 fee caps so it remains the
81
300
  * effective-price ceiling. On a no-priority local fork where the base fee
@@ -96,47 +315,151 @@ async function estimateGasFees(client, chainId, options) {
96
315
  }
97
316
  if (HISTORICAL_FEE_CHAINS.has(chainId)) {
98
317
  const config = { ...DEFAULT_FEE_CONFIG, ...FEE_CONFIG_OVERRIDES[chainId] };
99
- return estimateFeeHistoryGasFees(client, config);
318
+ return estimateFeeHistoryGasFees(client, chainId, config);
100
319
  }
101
320
  const tipFloorWei = TIP_FLOOR_WEI[chainId];
102
321
  if (tipFloorWei !== undefined) {
103
322
  return estimateViemFeesWithTipFloor(client, chainId, tipFloorWei);
104
323
  }
105
- return client.estimateFeesPerGas();
324
+ const [fees, baseFee] = await Promise.all([
325
+ client.estimateFeesPerGas(),
326
+ nextBlockBaseFee(client, chainId).catch(() => {
327
+ recordDegradedEstimate(chainId, 'feehistory_error');
328
+ return null;
329
+ }),
330
+ ]);
331
+ const oracleTip = fees.maxPriorityFeePerGas;
332
+ // The cap applies here too: the chains whose oracle over-reports (Optimism,
333
+ // Soneium, Sonic, Avalanche) have no tip floor, so this is their only path.
334
+ const tip = (0, tipClamp_1.clampTip)(oracleTip, undefined, TIP_CAP_WEI[chainId]);
335
+ return {
336
+ maxFeePerGas: cappedMaxFeePerGas(client, fees, baseFee, chainId, tip, oracleTip),
337
+ maxPriorityFeePerGas: tip,
338
+ };
106
339
  }
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.
340
+ // viem's oracle tip, lifted to an absolute floor — and, for windowed-tip chains,
341
+ // to the windowed feeHistory tip — then clamped down to any TIP_CAP_WEI. The tip
342
+ // is therefore raised OR lowered here; the cap applies first and the floor
343
+ // last, so a floor is never undercut by a cap.
344
+ //
345
+ // maxFeePerGas is rebuilt from the next block's base fee rather than adjusted
346
+ // off viem's, falling back to viem's own cap plus the tip delta when the base
347
+ // read fails.
112
348
  async function estimateViemFeesWithTipFloor(client, chainId, tipFloorWei) {
113
- const fees = await client.estimateFeesPerGas();
349
+ // Windowed chains get their tip AND the next-block base from one 20-block
350
+ // feeHistory response: that response already carries `baseFeePerGas` with the
351
+ // next block's base as its last entry, so a second blockCount:1 call would
352
+ // re-fetch a number we are holding. Everyone else reads the base alone.
353
+ //
354
+ // Best-effort throughout: a feeHistory outage must never turn into a quote
355
+ // outage. On failure the tip falls back to max(oracle, floor) — exactly the
356
+ // pre-overlay pricing — and the cap to viem's own, which is strictly more
357
+ // conservative, never less.
358
+ const [fees, history] = await Promise.all([
359
+ client.estimateFeesPerGas(),
360
+ (WINDOWED_TIP_CHAINS.has(chainId)
361
+ ? windowedFeeHistoryTip(client, chainId)
362
+ : nextBlockBaseFee(client, chainId).then((baseFee) => ({
363
+ tip: 0n,
364
+ baseFee,
365
+ }))).catch(() => {
366
+ recordDegradedEstimate(chainId, 'feehistory_error');
367
+ return { tip: 0n, baseFee: null };
368
+ }),
369
+ ]);
114
370
  const oracleTip = fees.maxPriorityFeePerGas;
115
371
  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;
372
+ if (history.tip > tip)
373
+ tip = history.tip;
374
+ tip = (0, tipClamp_1.clampTip)(tip, tipFloorWei, TIP_CAP_WEI[chainId]);
126
375
  return {
127
- maxFeePerGas: fees.maxFeePerGas + (tip - oracleTip),
376
+ maxFeePerGas: cappedMaxFeePerGas(client, fees, history.baseFee, chainId, tip, oracleTip),
128
377
  maxPriorityFeePerGas: tip,
129
378
  };
130
379
  }
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) {
380
+ /**
381
+ * The base-fee multiplier viem ACTUALLY applied, as a percentage, read from the
382
+ * client's own chain config exactly as viem reads it
383
+ * (`chain?.fees?.baseFeeMultiplier ?? 1.2`). Used to invert viem's cap on the
384
+ * degraded path, so the reconstruction cannot drift from the viem the CONSUMER
385
+ * is running (viem is a `^2` peer range, so it need not match the version CI
386
+ * pins) or from a consumer that sets a custom multiplier on its chain.
387
+ *
388
+ * Falls back to {@link VIEM_DEFAULT_BASE_FEE_MULTIPLIER_PCT} only when the value
389
+ * is not a plain number — i.e. absent, or viem's function form, which viem
390
+ * evaluates with a block/request context this path does not have. No supported
391
+ * chain uses the function form, and that fallback is exactly today's behaviour,
392
+ * so it is a no-op regression-wise.
393
+ */
394
+ function viemBaseFeeMultiplierPct(client) {
395
+ const m = client.chain?.fees?.baseFeeMultiplier;
396
+ // Number.isFinite, not just `>= 1`: Infinity passes `>= 1` and would make
397
+ // BigInt(Math.round(m * 100)) throw, crashing the estimate on a degraded path.
398
+ // (NaN is already excluded by `>= 1` being false, but this states it.) An
399
+ // invalid multiplier falls back to the pinned default rather than crashing.
400
+ if (typeof m === 'number' && Number.isFinite(m) && m >= 1) {
401
+ return BigInt(Math.round(m * 100));
402
+ }
403
+ return viemDefaults_1.VIEM_DEFAULT_BASE_FEE_MULTIPLIER_PCT;
404
+ }
405
+ /**
406
+ * Cap = nextBlockBaseFee * multiplier + tip.
407
+ *
408
+ * When the base fee could not be read, falls back to viem's own cap — but only
409
+ * where that is genuinely more conservative. viem builds its cap as
410
+ * `latestBase * 1.2 + tip`, so it is more conservative ONLY for chains whose
411
+ * multiplier is at or below 1.2x. For the ones above it (Gnosis 2.0x, HyperEVM
412
+ * 1.30x) viem's cap is LOWER than intended — measured at 62% and 93% of the
413
+ * healthy cap — which on Gnosis reopens exactly the under-capping its 2.0x
414
+ * override exists to close, precisely when the RPC is unhealthy and base fees
415
+ * move. For those chains we recover viem's implied base and re-apply our own
416
+ * multiplier, taking whichever cap is higher.
417
+ */
418
+ function cappedMaxFeePerGas(client, fees, baseFee, chainId, tip, oracleTip) {
419
+ const pct = baseFeeMultiplierPct(chainId);
420
+ if (baseFee === null) {
421
+ const viemPct = viemBaseFeeMultiplierPct(client);
422
+ const viemCap = fees.maxFeePerGas + (tip - oracleTip);
423
+ let cap = viemCap;
424
+ if (pct > viemPct) {
425
+ const impliedBase = ((fees.maxFeePerGas - oracleTip) * 100n) / viemPct;
426
+ const scaled = (impliedBase * pct) / 100n + tip;
427
+ if (scaled > cap)
428
+ cap = scaled;
429
+ }
430
+ // Never return below the tip itself. viem's default guarantees
431
+ // maxFeePerGas >= oracleTip, so `viemCap` is non-negative and this cannot
432
+ // bind today — but the guarantee is viem's, not ours, and an estimator that
433
+ // broke it would otherwise make BOTH arms negative (a `maxFeePerGas <=
434
+ // oracleTip` precondition would not help: it routes to viemCap, which is
435
+ // exactly the negative value).
436
+ return cap > tip ? cap : tip;
437
+ }
438
+ return (baseFee * pct) / 100n + tip;
439
+ }
440
+ /**
441
+ * Nearest-rank p50 across the window's per-block p50 tips — used to track a busy
442
+ * market that the static oracle/floor lags — plus the next block's base fee,
443
+ * which the same response already carries as the last `baseFeePerGas` entry.
444
+ *
445
+ * `baseFee` is `null`, never `0n`, when that array is missing or empty, for the
446
+ * same reason as {@link nextBlockBaseFee}: a 0 base would price the cap at the
447
+ * tip alone.
448
+ */
449
+ async function windowedFeeHistoryTip(client, chainId) {
134
450
  const feeHistory = await client.getFeeHistory({
135
451
  blockCount: WINDOWED_TIP_HISTORY_BLOCKS,
136
452
  rewardPercentiles: [WINDOWED_TIP_PERCENTILE],
137
453
  });
138
454
  const perBlockTips = (feeHistory.reward ?? []).map((r) => r?.[0] ?? 0n);
139
- return percentileBigInt(perBlockTips, WINDOWED_TIP_PERCENTILE);
455
+ const baseFee = feeHistory.baseFeePerGas?.at(-1);
456
+ if (baseFee === undefined) {
457
+ recordDegradedEstimate(chainId, 'feehistory_missing_base');
458
+ }
459
+ return {
460
+ tip: percentileBigInt(perBlockTips, WINDOWED_TIP_PERCENTILE),
461
+ baseFee: baseFee ?? null,
462
+ };
140
463
  }
141
464
  // Windowed eth_feeHistory estimator:
142
465
  // tip = `windowTipPercentile` across the last N blocks' per-block
@@ -144,8 +467,59 @@ async function windowedFeeHistoryTip(client) {
144
467
  // (or `congestedTipPercentile` when the window's mean
145
468
  // gasUsedRatio exceeds `congestionRatio`), floored at
146
469
  // `tipFloorWei`.
147
- // maxFeePerGas = nextBlockBaseFee * baseFeeMultiplier + tip
148
- async function estimateFeeHistoryGasFees(client, config) {
470
+ // maxFeePerGas = nextBlockBaseFee * baseFeeMultiplierPct / 100 + tip
471
+ /**
472
+ * Records that a quote was produced by a DEGRADED path — eth_feeHistory failed
473
+ * or answered without a base fee, so the cap came from viem rather than from the
474
+ * next block's actual base.
475
+ *
476
+ * This exists because the degradation is otherwise invisible. Every fallback
477
+ * here is deliberately silent at the call site (a feeHistory outage must never
478
+ * become a quote outage), which means a broken upstream would quietly revert
479
+ * these chains to viem's 1.2x-of-latest pricing — the exact over-charge this
480
+ * module exists to remove — and nothing would say so. The estimate would still
481
+ * look healthy; it would just be wrong.
482
+ *
483
+ * Written to the active span rather than a logger: this package cannot reach
484
+ * the orchestrator's logger, and the orchestrator already wraps these calls in
485
+ * `withSpan('getGasFeeComponents')`, so the attributes land in Tempo without any
486
+ * wiring. `@opentelemetry/api` is a no-op when no SDK is registered, so this
487
+ * costs nothing for consumers who do not instrument.
488
+ */
489
+ function recordDegradedEstimate(chainId, reason) {
490
+ const span = api_1.trace.getActiveSpan();
491
+ if (!span)
492
+ return;
493
+ span.setAttribute('gas.estimate.degraded', true);
494
+ span.setAttribute('gas.estimate.degraded_reason', reason);
495
+ span.setAttribute('gas.estimate.chain_id', chainId);
496
+ span.addEvent('gas.estimate.degraded', { reason, 'chain.id': chainId });
497
+ }
498
+ /**
499
+ * Cap for the windowed estimator when eth_feeHistory answered without a base
500
+ * fee. Recovers the base implied by viem's own estimate and re-applies this
501
+ * chain's multiplier, never returning less than viem's cap or the tip itself.
502
+ *
503
+ * Deliberately does NOT adopt viem's tip — see the call site.
504
+ */
505
+ async function viemFallbackCap(client, config, priorityFee) {
506
+ const fees = await client.estimateFeesPerGas();
507
+ const oracleTip = fees.maxPriorityFeePerGas;
508
+ const viemCap = fees.maxFeePerGas + (priorityFee - oracleTip);
509
+ let cap = viemCap;
510
+ if (fees.maxFeePerGas > oracleTip) {
511
+ // Same reconstruction as cappedMaxFeePerGas: invert by the multiplier viem
512
+ // ACTUALLY applied for this consumer, not the pinned default — viem is a
513
+ // peer range and a consumer chain may set its own baseFeeMultiplier.
514
+ const viemPct = viemBaseFeeMultiplierPct(client);
515
+ const impliedBase = ((fees.maxFeePerGas - oracleTip) * 100n) / viemPct;
516
+ const scaled = (impliedBase * config.baseFeeMultiplierPct) / 100n;
517
+ if (scaled + priorityFee > cap)
518
+ cap = scaled + priorityFee;
519
+ }
520
+ return cap > priorityFee ? cap : priorityFee;
521
+ }
522
+ async function estimateFeeHistoryGasFees(client, chainId, config) {
149
523
  const feeHistory = await client.getFeeHistory({
150
524
  blockCount: config.historyBlocks,
151
525
  rewardPercentiles: [config.tipPercentile, config.congestedTipPercentile],
@@ -161,8 +535,31 @@ async function estimateFeeHistoryGasFees(client, config) {
161
535
  const priorityFee = windowedTip > config.tipFloorWei ? windowedTip : config.tipFloorWei;
162
536
  // baseFeePerGas has historyBlocks + 1 entries; the last is the *next* block's
163
537
  // 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;
538
+ //
539
+ // Optional chaining, not a bare `.at()`: an absent array would otherwise throw
540
+ // a TypeError here rather than reaching the fallback below.
541
+ const baseFee = feeHistory.baseFeePerGas?.at(-1);
542
+ if (baseFee === undefined) {
543
+ // The RPC answered without telling us the base fee. Defaulting to 0n would
544
+ // price the cap at the tip alone — on mainnet an unincludable bid AND a ~20x
545
+ // under-charge, silently — so that is not an option. Throwing is not a good
546
+ // one either: this is a successful HTTP 200 carrying bad data, so viem's
547
+ // transport retries never fire, and the chains on this path include Plasma
548
+ // at ~69k fills/wk. So fall back for the BASE only.
549
+ //
550
+ // Keeping `priorityFee` is the point. These chains are on the windowed path
551
+ // precisely because their tip oracle is untrustworthy (Plasma's reports
552
+ // 0.005 gwei against a measured 0.0014), so falling back to viem wholesale
553
+ // would trade a hard failure for a silent ~3.5x overcharge on the chain
554
+ // carrying the most fills through our pricing. The tip we already computed
555
+ // is the calibrated one.
556
+ recordDegradedEstimate(chainId, 'feehistory_missing_base');
557
+ return {
558
+ maxFeePerGas: await viemFallbackCap(client, config, priorityFee),
559
+ maxPriorityFeePerGas: priorityFee,
560
+ };
561
+ }
562
+ const maxFeePerGas = (baseFee * config.baseFeeMultiplierPct) / 100n + priorityFee;
166
563
  return { maxFeePerGas, maxPriorityFeePerGas: priorityFee };
167
564
  }
168
565
  // 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.13",
4
4
  "description": "Shared SDK for Rhinestone services",
5
5
  "author": {
6
6
  "name": "Rhinestone",