@rhinestone/service-sdk 0.2.4 → 0.2.6

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;AAKD,wBAAsB,eAAe,CACnC,MAAM,EAAE,mBAAmB,EAC3B,OAAO,EAAE,MAAM,GACd,OAAO,CAAC,gBAAgB,CAAC,CAK3B"}
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;AAmFD,wBAAsB,eAAe,CACnC,MAAM,EAAE,mBAAmB,EAC3B,OAAO,EAAE,MAAM,GACd,OAAO,CAAC,gBAAgB,CAAC,CAU3B"}
package/dist/chains.js CHANGED
@@ -2,24 +2,129 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.estimateGasFees = estimateGasFees;
4
4
  const chains_1 = require("viem/chains");
5
- const SEPOLIA_FEE_HISTORY_BLOCKS = 3;
6
- const SEPOLIA_REWARD_PERCENTILE = 75;
5
+ // Chains that price fees from a windowed eth_feeHistory instead of viem's
6
+ // default estimator — the volatile-base L1s, where windowing the next-block base
7
+ // fee and pooling tips across the window smooths the single-block noise that
8
+ // made the latest-block-only read jumpy. Other chains stay on viem's default
9
+ // (some with a tip-floor overlay, see TIP_FLOOR_WEI): their base fee is stable
10
+ // or sequencer-pinned, so windowing adds nothing.
11
+ const HISTORICAL_FEE_CHAINS = new Set([chains_1.sepolia.id, chains_1.mainnet.id]);
12
+ const DEFAULT_FEE_CONFIG = {
13
+ historyBlocks: 20, // Geth FullNodeGPO window
14
+ baseFeeMultiplier: 2n, // ~6 blocks / ~70s headroom (cap only — free)
15
+ tipPercentile: 50, // calm-market per-block tip (real mainnet p50 ≈ 0.03 gwei)
16
+ congestedTipPercentile: 75, // escalate to p75 under load (not p90 — ~45x cost)
17
+ congestionRatio: 0.5, // "gasUsedRatio trends > 0.5"
18
+ windowTipPercentile: 50, // nearest-rank p50 across the window's per-block tips
19
+ // 0.1 gwei. Enough to clear the zero-tip stall (the tip is the only builder
20
+ // incentive — base fee is burned), without over-buffering: real mainnet p50 is
21
+ // ~0.03 gwei, so this is ~3x the median, while 1 gwei would be ~30x.
22
+ tipFloorWei: 100000000n,
23
+ };
24
+ // Per-chain overrides for the windowed feeHistory path; any omitted field falls
25
+ // back to DEFAULT_FEE_CONFIG.
26
+ const FEE_CONFIG_OVERRIDES = {
27
+ // Testnet — not real money: aggressive percentile, no floor.
28
+ [chains_1.sepolia.id]: {
29
+ tipPercentile: 75,
30
+ congestedTipPercentile: 90,
31
+ tipFloorWei: 0n,
32
+ },
33
+ };
34
+ // Chains where viem's default base-fee estimate is fine but its tip oracle
35
+ // (eth_maxPriorityFeePerGas) under-prices the priority fee versus the real
36
+ // on-chain inclusion floor — the oracle reports the chain-min or zero while txs
37
+ // actually clear at a much higher tip. We keep viem's base estimate and lift
38
+ // only the tip to this absolute floor. Values measured live per chain in the
39
+ // RHI-4468 audit.
40
+ const TIP_FLOOR_WEI = {
41
+ [chains_1.polygon.id]: 45000000000n, // 45 gwei — Bor min 30 gwei x1.5; real p50 tip ~200 gwei
42
+ [chains_1.gnosis.id]: 1000000000n, // 1.0 gwei — viem oracle ~21 wei; real p90 inclusion ~1.0 gwei (re-probed 2026-06-25)
43
+ [chains_1.hyperEvm.id]: 150000000n, // 0.15 gwei — oracle returns 0; realized p50 tip ~0.15 gwei
44
+ [chains_1.monad.id]: 22000000000n, // 22 gwei — oracle 2 gwei; competitive tip p50/mode ~22 gwei
45
+ };
46
+ // Polygon's real tip runs ~6.7x its oracle (Bor min 30 gwei vs real p50 ~200
47
+ // gwei), so a static floor alone lags a busy market — also window the feeHistory
48
+ // tip and take the max with the floor. Its base fee is stable (~5% vol), so the
49
+ // base stays on viem's default.
50
+ const WINDOWED_TIP_CHAINS = new Set([chains_1.polygon.id]);
51
+ const WINDOWED_TIP_HISTORY_BLOCKS = 20;
52
+ const WINDOWED_TIP_PERCENTILE = 50; // p50 per block, nearest-rank across the window
7
53
  async function estimateGasFees(client, chainId) {
8
- if (chainId === chains_1.sepolia.id) {
9
- return estimateSepoliaGasFees(client);
54
+ if (HISTORICAL_FEE_CHAINS.has(chainId)) {
55
+ const config = { ...DEFAULT_FEE_CONFIG, ...FEE_CONFIG_OVERRIDES[chainId] };
56
+ return estimateFeeHistoryGasFees(client, config);
57
+ }
58
+ const tipFloorWei = TIP_FLOOR_WEI[chainId];
59
+ if (tipFloorWei !== undefined) {
60
+ return estimateViemFeesWithTipFloor(client, chainId, tipFloorWei);
10
61
  }
11
62
  return client.estimateFeesPerGas();
12
63
  }
13
- async function estimateSepoliaGasFees(client) {
64
+ // viem's default fee estimate (base fee + oracle tip), with the priority fee
65
+ // lifted to an absolute floor — and, for windowed-tip chains, to the windowed
66
+ // feeHistory tip. The maxFeePerGas cap is raised by the same delta so the lifted
67
+ // tip is never clipped. The tip is only ever raised, never lowered, so this
68
+ // preserves viem's estimate whenever it already exceeds the floor.
69
+ async function estimateViemFeesWithTipFloor(client, chainId, tipFloorWei) {
70
+ const fees = await client.estimateFeesPerGas();
71
+ const oracleTip = fees.maxPriorityFeePerGas;
72
+ let tip = oracleTip;
73
+ if (WINDOWED_TIP_CHAINS.has(chainId)) {
74
+ const windowedTip = await windowedFeeHistoryTip(client);
75
+ if (windowedTip > tip)
76
+ tip = windowedTip;
77
+ }
78
+ if (tipFloorWei > tip)
79
+ tip = tipFloorWei;
80
+ return {
81
+ maxFeePerGas: fees.maxFeePerGas + (tip - oracleTip),
82
+ maxPriorityFeePerGas: tip,
83
+ };
84
+ }
85
+ // Nearest-rank p50 across the window's per-block p50 tips — used to track a busy
86
+ // market that the static oracle/floor lags.
87
+ async function windowedFeeHistoryTip(client) {
14
88
  const feeHistory = await client.getFeeHistory({
15
- blockCount: SEPOLIA_FEE_HISTORY_BLOCKS,
16
- rewardPercentiles: [SEPOLIA_REWARD_PERCENTILE],
89
+ blockCount: WINDOWED_TIP_HISTORY_BLOCKS,
90
+ rewardPercentiles: [WINDOWED_TIP_PERCENTILE],
17
91
  });
18
- const priorityFee = feeHistory.reward?.at(-1)?.[0] ?? 0n;
92
+ const perBlockTips = (feeHistory.reward ?? []).map((r) => r?.[0] ?? 0n);
93
+ return percentileBigInt(perBlockTips, WINDOWED_TIP_PERCENTILE);
94
+ }
95
+ // Windowed eth_feeHistory estimator:
96
+ // tip = `windowTipPercentile` across the last N blocks' per-block
97
+ // tips, where the per-block tip is read at `tipPercentile`
98
+ // (or `congestedTipPercentile` when the window's mean
99
+ // gasUsedRatio exceeds `congestionRatio`), floored at
100
+ // `tipFloorWei`.
101
+ // maxFeePerGas = nextBlockBaseFee * baseFeeMultiplier + tip
102
+ async function estimateFeeHistoryGasFees(client, config) {
103
+ const feeHistory = await client.getFeeHistory({
104
+ blockCount: config.historyBlocks,
105
+ rewardPercentiles: [config.tipPercentile, config.congestedTipPercentile],
106
+ });
107
+ const ratios = feeHistory.gasUsedRatio ?? [];
108
+ const meanRatio = ratios.length > 0
109
+ ? ratios.reduce((sum, r) => sum + r, 0) / ratios.length
110
+ : 0;
111
+ // Under load, read the per-block tip from the higher percentile column.
112
+ const column = meanRatio > config.congestionRatio ? 1 : 0;
113
+ const perBlockTips = (feeHistory.reward ?? []).map((r) => r?.[column] ?? 0n);
114
+ const windowedTip = percentileBigInt(perBlockTips, config.windowTipPercentile);
115
+ const priorityFee = windowedTip > config.tipFloorWei ? windowedTip : config.tipFloorWei;
116
+ // baseFeePerGas has historyBlocks + 1 entries; the last is the *next* block's
117
+ // base fee (the one our tx lands in), not the latest mined block's.
19
118
  const baseFee = feeHistory.baseFeePerGas.at(-1) ?? 0n;
20
- const maxFeePerGas = baseFee * 2n + priorityFee;
21
- return {
22
- maxFeePerGas,
23
- maxPriorityFeePerGas: priorityFee,
24
- };
119
+ const maxFeePerGas = baseFee * config.baseFeeMultiplier + priorityFee;
120
+ return { maxFeePerGas, maxPriorityFeePerGas: priorityFee };
121
+ }
122
+ // Nearest-rank percentile over a bigint sample. Empty sample → 0n.
123
+ function percentileBigInt(values, percentile) {
124
+ if (values.length === 0)
125
+ return 0n;
126
+ const sorted = [...values].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
127
+ const rank = Math.ceil((percentile / 100) * sorted.length);
128
+ const idx = Math.min(Math.max(rank - 1, 0), sorted.length - 1);
129
+ return sorted[idx];
25
130
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rhinestone/service-sdk",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "Shared SDK for Rhinestone services",
5
5
  "author": {
6
6
  "name": "Rhinestone",