@rhinestone/service-sdk 0.2.5 → 0.2.7

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.
@@ -1 +1 @@
1
- {"version":3,"file":"chains.d.ts","sourceRoot":"","sources":["../src/chains.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,YAAY,EAAE,MAAM,MAAM,CAAA;AAG1D,KAAK,mBAAmB,GAAG,IAAI,CAC7B,YAAY,EACZ,oBAAoB,GAAG,eAAe,CACvC,CAAA;AA+CD,wBAAsB,eAAe,CACnC,MAAM,EAAE,mBAAmB,EAC3B,OAAO,EAAE,MAAM,GACd,OAAO,CAAC,gBAAgB,CAAC,CAM3B"}
1
+ {"version":3,"file":"chains.d.ts","sourceRoot":"","sources":["../src/chains.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,YAAY,EAAE,MAAM,MAAM,CAAA;AAW1D,KAAK,mBAAmB,GAAG,IAAI,CAC7B,YAAY,EACZ,oBAAoB,GAAG,eAAe,CACvC,CAAA;AAuGD,wBAAsB,eAAe,CACnC,MAAM,EAAE,mBAAmB,EAC3B,OAAO,EAAE,MAAM,GACd,OAAO,CAAC,gBAAgB,CAAC,CAU3B"}
package/dist/chains.js CHANGED
@@ -2,54 +2,149 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.estimateGasFees = estimateGasFees;
4
4
  const chains_1 = require("viem/chains");
5
- // Chains that price fees from eth_feeHistory instead of viem's default
6
- // estimator (which sources the tip from the unspecified eth_maxPriorityFeePerGas
7
- // oracle). L2s are intentionally absent — their sequencers include ~zero-tip
8
- // txs, so the default is fine and a floor would just overpay.
9
- const HISTORICAL_FEE_CHAINS = new Set([chains_1.sepolia.id, chains_1.mainnet.id]);
5
+ // Chains that price fees from a windowed eth_feeHistory instead of viem's
6
+ // default estimator. Two membership rationales:
7
+ // - volatile-base L1s (mainnet, sepolia): windowing the next-block base fee
8
+ // and pooling tips across the window smooths the single-block noise that
9
+ // made the latest-block-only read jumpy;
10
+ // - broken-tip-oracle chains (Plasma): eth_maxPriorityFeePerGas over-prices
11
+ // the tip by orders of magnitude vs the chain's real rewards, and this path
12
+ // never consults the oracle — see FEE_CONFIG_OVERRIDES for the specifics.
13
+ // Other chains stay on viem's default (some with a tip-floor overlay, see
14
+ // TIP_FLOOR_WEI): their base fee is stable or sequencer-pinned, so windowing
15
+ // adds nothing.
16
+ const HISTORICAL_FEE_CHAINS = new Set([
17
+ chains_1.sepolia.id,
18
+ chains_1.mainnet.id,
19
+ chains_1.plasma.id,
20
+ ]);
10
21
  const DEFAULT_FEE_CONFIG = {
11
- historyBlocks: 1,
12
- baseFeeMultiplier: 2n,
13
- // p50, not p75. The mainnet stalls we saw were caused by a *zero* tip, not by
14
- // tipping below p75 — once the tip is non-zero a fill is reliably included, so
15
- // a higher percentile buys no extra speed, only cost.
16
- //
17
- // Example — bundle 4225…447075 (2026-06-04): the fill broadcast with tip 0 and
18
- // sat ~3 min. It was mineable (maxFee >= baseFee) for ~12 of its 16 stuck
19
- // blocks yet still skipped, because a zero-tip tx earns the builder nothing
20
- // (base fee is burned). The market was tipping p50 ~0.07-0.98 gwei the whole
21
- // time — any non-zero tip would have gotten it in.
22
- //
23
- // At ~$1,800/ETH and ~383k gas/fill the tip runs ~$0.14 at p50 vs ~$0.68 at
24
- // p75 (~5x), on *every* mainnet fill — not just the rare stuck one.
25
- rewardPercentile: 50,
26
- tipFloorWei: 100000000n, // 0.1 gwei (~$0.07/fill); see rewardPercentile
22
+ 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)
25
+ congestedTipPercentile: 75, // escalate to p75 under load (not p90 — ~45x cost)
26
+ congestionRatio: 0.5, // "gasUsedRatio trends > 0.5"
27
+ windowTipPercentile: 50, // nearest-rank p50 across the window's per-block tips
28
+ // 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.
31
+ tipFloorWei: 100000000n,
27
32
  };
28
- // Per-chain overrides; any omitted field falls back to DEFAULT_FEE_CONFIG.
33
+ // Per-chain overrides for the windowed feeHistory path; any omitted field falls
34
+ // back to DEFAULT_FEE_CONFIG.
29
35
  const FEE_CONFIG_OVERRIDES = {
30
- // Testnet — leave as-is: aggressive percentile, no floor (not real money).
31
- [chains_1.sepolia.id]: { rewardPercentile: 75, tipFloorWei: 0n },
36
+ // Testnet — not real money: aggressive percentile, no floor.
37
+ [chains_1.sepolia.id]: {
38
+ tipPercentile: 75,
39
+ congestedTipPercentile: 90,
40
+ tipFloorWei: 0n,
41
+ },
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.
50
+ [chains_1.plasma.id]: {
51
+ tipFloorWei: 5000000n, // 0.005 gwei chain-min priority fee
52
+ },
32
53
  };
54
+ // Chains where viem's default base-fee estimate is fine but its tip oracle
55
+ // (eth_maxPriorityFeePerGas) under-prices the priority fee versus the real
56
+ // on-chain inclusion floor — the oracle reports the chain-min or zero while txs
57
+ // actually clear at a much higher tip. We keep viem's base estimate and lift
58
+ // only the tip to this absolute floor. Values measured live per chain in the
59
+ // RHI-4468 audit.
60
+ const TIP_FLOOR_WEI = {
61
+ [chains_1.polygon.id]: 45000000000n, // 45 gwei — Bor min 30 gwei x1.5; real p50 tip ~200 gwei
62
+ [chains_1.gnosis.id]: 1000000000n, // 1.0 gwei — viem oracle ~21 wei; real p90 inclusion ~1.0 gwei (re-probed 2026-06-25)
63
+ [chains_1.hyperEvm.id]: 150000000n, // 0.15 gwei — oracle returns 0; realized p50 tip ~0.15 gwei
64
+ [chains_1.monad.id]: 22000000000n, // 22 gwei — oracle 2 gwei; competitive tip p50/mode ~22 gwei
65
+ };
66
+ // Polygon's real tip runs ~6.7x its oracle (Bor min 30 gwei vs real p50 ~200
67
+ // gwei), so a static floor alone lags a busy market — also window the feeHistory
68
+ // tip and take the max with the floor. Its base fee is stable (~5% vol), so the
69
+ // base stays on viem's default.
70
+ const WINDOWED_TIP_CHAINS = new Set([chains_1.polygon.id]);
71
+ const WINDOWED_TIP_HISTORY_BLOCKS = 20;
72
+ const WINDOWED_TIP_PERCENTILE = 50; // p50 per block, nearest-rank across the window
33
73
  async function estimateGasFees(client, chainId) {
34
- if (!HISTORICAL_FEE_CHAINS.has(chainId)) {
35
- return client.estimateFeesPerGas();
74
+ if (HISTORICAL_FEE_CHAINS.has(chainId)) {
75
+ const config = { ...DEFAULT_FEE_CONFIG, ...FEE_CONFIG_OVERRIDES[chainId] };
76
+ return estimateFeeHistoryGasFees(client, config);
77
+ }
78
+ const tipFloorWei = TIP_FLOOR_WEI[chainId];
79
+ if (tipFloorWei !== undefined) {
80
+ return estimateViemFeesWithTipFloor(client, chainId, tipFloorWei);
81
+ }
82
+ return client.estimateFeesPerGas();
83
+ }
84
+ // viem's default fee estimate (base fee + oracle tip), with the priority fee
85
+ // lifted to an absolute floor — and, for windowed-tip chains, to the windowed
86
+ // feeHistory tip. The maxFeePerGas cap is raised by the same delta so the lifted
87
+ // tip is never clipped. The tip is only ever raised, never lowered, so this
88
+ // preserves viem's estimate whenever it already exceeds the floor.
89
+ async function estimateViemFeesWithTipFloor(client, chainId, tipFloorWei) {
90
+ const fees = await client.estimateFeesPerGas();
91
+ const oracleTip = fees.maxPriorityFeePerGas;
92
+ let tip = oracleTip;
93
+ if (WINDOWED_TIP_CHAINS.has(chainId)) {
94
+ const windowedTip = await windowedFeeHistoryTip(client);
95
+ if (windowedTip > tip)
96
+ tip = windowedTip;
36
97
  }
37
- const config = { ...DEFAULT_FEE_CONFIG, ...FEE_CONFIG_OVERRIDES[chainId] };
38
- return estimateFeeHistoryGasFees(client, config);
98
+ if (tipFloorWei > tip)
99
+ tip = tipFloorWei;
100
+ return {
101
+ maxFeePerGas: fees.maxFeePerGas + (tip - oracleTip),
102
+ maxPriorityFeePerGas: tip,
103
+ };
39
104
  }
40
- // Priority fee = the latest block's reward at `rewardPercentile`, floored at
41
- // `tipFloorWei`; maxFeePerGas = baseFee * baseFeeMultiplier + priorityFee.
105
+ // Nearest-rank p50 across the window's per-block p50 tips — used to track a busy
106
+ // market that the static oracle/floor lags.
107
+ async function windowedFeeHistoryTip(client) {
108
+ const feeHistory = await client.getFeeHistory({
109
+ blockCount: WINDOWED_TIP_HISTORY_BLOCKS,
110
+ rewardPercentiles: [WINDOWED_TIP_PERCENTILE],
111
+ });
112
+ const perBlockTips = (feeHistory.reward ?? []).map((r) => r?.[0] ?? 0n);
113
+ return percentileBigInt(perBlockTips, WINDOWED_TIP_PERCENTILE);
114
+ }
115
+ // Windowed eth_feeHistory estimator:
116
+ // tip = `windowTipPercentile` across the last N blocks' per-block
117
+ // tips, where the per-block tip is read at `tipPercentile`
118
+ // (or `congestedTipPercentile` when the window's mean
119
+ // gasUsedRatio exceeds `congestionRatio`), floored at
120
+ // `tipFloorWei`.
121
+ // maxFeePerGas = nextBlockBaseFee * baseFeeMultiplier + tip
42
122
  async function estimateFeeHistoryGasFees(client, config) {
43
123
  const feeHistory = await client.getFeeHistory({
44
124
  blockCount: config.historyBlocks,
45
- rewardPercentiles: [config.rewardPercentile],
125
+ rewardPercentiles: [config.tipPercentile, config.congestedTipPercentile],
46
126
  });
47
- const reward = feeHistory.reward?.at(-1)?.[0] ?? 0n;
48
- const priorityFee = reward > config.tipFloorWei ? reward : config.tipFloorWei;
127
+ const ratios = feeHistory.gasUsedRatio ?? [];
128
+ const meanRatio = ratios.length > 0
129
+ ? ratios.reduce((sum, r) => sum + r, 0) / ratios.length
130
+ : 0;
131
+ // Under load, read the per-block tip from the higher percentile column.
132
+ const column = meanRatio > config.congestionRatio ? 1 : 0;
133
+ const perBlockTips = (feeHistory.reward ?? []).map((r) => r?.[column] ?? 0n);
134
+ const windowedTip = percentileBigInt(perBlockTips, config.windowTipPercentile);
135
+ const priorityFee = windowedTip > config.tipFloorWei ? windowedTip : config.tipFloorWei;
136
+ // baseFeePerGas has historyBlocks + 1 entries; the last is the *next* block's
137
+ // base fee (the one our tx lands in), not the latest mined block's.
49
138
  const baseFee = feeHistory.baseFeePerGas.at(-1) ?? 0n;
50
139
  const maxFeePerGas = baseFee * config.baseFeeMultiplier + priorityFee;
51
- return {
52
- maxFeePerGas,
53
- maxPriorityFeePerGas: priorityFee,
54
- };
140
+ return { maxFeePerGas, maxPriorityFeePerGas: priorityFee };
141
+ }
142
+ // Nearest-rank percentile over a bigint sample. Empty sample → 0n.
143
+ function percentileBigInt(values, percentile) {
144
+ if (values.length === 0)
145
+ return 0n;
146
+ const sorted = [...values].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
147
+ const rank = Math.ceil((percentile / 100) * sorted.length);
148
+ const idx = Math.min(Math.max(rank - 1, 0), sorted.length - 1);
149
+ return sorted[idx];
55
150
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rhinestone/service-sdk",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "Shared SDK for Rhinestone services",
5
5
  "author": {
6
6
  "name": "Rhinestone",