@rhinestone/service-sdk 0.2.13 → 0.2.15

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/README.md CHANGED
@@ -1,22 +1,205 @@
1
- # service-sdk
1
+ # @rhinestone/service-sdk
2
+
3
+ Shared TypeScript utilities for Rhinestone backend services — the pieces every service
4
+ needs and none of them should reimplement: OpenTelemetry setup and helpers, chain-aware gas
5
+ fee estimation, and BigInt-safe JSON serialization.
6
+
7
+ Published to npm and consumed by `orchestrator`, `relayer`, `deposit-service-processor`,
8
+ `route-stats`, `back-office` (api), and `1auth` (passkey, recovery-signer).
9
+
10
+ Deliberately not a home for domain logic. The one module that encodes real judgement —
11
+ `chains` — earns it by being pure pricing arithmetic that several services must agree on;
12
+ everything else is glue.
13
+
14
+ ## Install
15
+
16
+ ```sh
17
+ bun add @rhinestone/service-sdk
18
+ ```
19
+
20
+ `viem` is a peer dependency (`^2.40.1`), needed only for the `chains` entry point.
21
+
22
+ ## Entry points
23
+
24
+ | Import | What |
25
+ |---|---|
26
+ | `@rhinestone/service-sdk` | Re-exports `./serialization` only |
27
+ | `@rhinestone/service-sdk/chains` | Chain-aware gas fee estimation |
28
+ | `@rhinestone/service-sdk/opentelemetry` | SDK setup, tracing/metrics helpers, propagation, integrations |
29
+ | `@rhinestone/service-sdk/serialization` | BigInt-safe JSON serialization |
30
+
31
+ ---
2
32
 
3
33
  ## Gas fee estimation
4
34
 
5
35
  ```ts
6
36
  import { estimateGasFees } from '@rhinestone/service-sdk/chains'
7
37
 
8
- const fees = await estimateGasFees(client, chainId)
9
- const legacyFees = await estimateGasFees(client, chainId, { mode: 'legacy' })
38
+ const { maxFeePerGas, maxPriorityFeePerGas, baseFeePerGas } =
39
+ await estimateGasFees(client, chainId)
10
40
  ```
11
41
 
12
- With no options, `estimateGasFees` retains its chain-aware fee estimation
13
- behavior. Legacy mode takes precedence over all chain-specific logic and calls
14
- `client.estimateFeesPerGas({ type: 'legacy' })` without requesting fee history.
15
-
16
- The return type remains EIP-1559-shaped in both modes. Legacy `gasPrice` is
17
- normalized to
18
- `{ maxFeePerGas: gasPrice, maxPriorityFeePerGas: gasPrice }`. Equal caps
19
- preserve the legacy effective-price ceiling when the result is used as EIP-1559
20
- fees. On a no-priority local fork where `baseFee == gasPrice`, the max-fee cap
21
- means the actual paid tip is still zero. The mode selects the estimation source
22
- only; it does not select or modify a transaction type.
42
+ `estimateGasFees` returns EIP-1559-shaped fees for every chain and every mode. The whole
43
+ module exists because viem's default estimator systematically mis-prices our fills: the
44
+ orchestrator **bills** `maxFeePerGas`, but on-chain a transaction only ever pays
45
+ `base + tip`, so unused headroom is charged to the user rather than spent.
46
+
47
+ **Three estimation strategies**, selected per chain:
48
+
49
+ - **Windowed `eth_feeHistory`** (mainnet, sepolia, plasma) — pools tips across a 20-block
50
+ window and prices the base from the *next* block, escalating to a higher reward
51
+ percentile when the window's mean `gasUsedRatio` shows congestion. Chains land here
52
+ either for a volatile base fee (the L1s) or a broken tip oracle (plasma, whose
53
+ `eth_maxPriorityFeePerGas` reports ~3.5x what fills actually clear at).
54
+ - **viem's estimate with a tip floor** (base, bsc, polygon, gnosis, hyperEVM, monad) — for
55
+ chains whose base fee is stable but whose tip oracle *under*-reports. Polygon and base
56
+ additionally window the feeHistory tip, since their real tip drifts above a static floor.
57
+ - **viem's estimate plain** — everything else.
58
+
59
+ On both viem paths a per-chain tip **cap** also applies where the oracle *over*-reports
60
+ (optimism, soneium, sonic, avalanche, bsc). Cap and floor can both be set for one chain —
61
+ bsc is — in which case the cap applies first and the floor last, so a floor is never
62
+ undercut.
63
+
64
+ `maxFeePerGas` is rebuilt as `nextBlockBaseFee * multiplier + tip` — the next block's base,
65
+ not the latest mined one, which is where most of the over-charge disappears. The multiplier
66
+ is per-chain and each path has its own table: 1.05x by default on the viem paths (overridden
67
+ for hyperEVM, gnosis, avalanche), and part of the per-chain fee config on the windowed path
68
+ (1.30x sepolia, 1.60x mainnet, 2.00x plasma).
69
+
70
+ **`baseFeePerGas` in the result** lets a caller derive the expected price without a second
71
+ `eth_feeHistory` round trip. It matters because `maxFeePerGas` is a submission *cap* — right
72
+ to bill, wrong to report to a consumer comparing relayers. It is `null` — never `0n` — when
73
+ the base was unavailable (non-1559 chain, legacy mode, or a degraded read). Treat `null` as
74
+ unknown and fall back; treating it as zero would price a cap at the tip alone.
75
+
76
+ ### Legacy mode
77
+
78
+ ```ts
79
+ const fees = await estimateGasFees(client, chainId, { mode: 'legacy' })
80
+ ```
81
+
82
+ Takes precedence over all chain-specific logic and calls
83
+ `client.estimateFeesPerGas({ type: 'legacy' })` without requesting fee history. The legacy
84
+ `gasPrice` is normalized to `{ maxFeePerGas: gasPrice, maxPriorityFeePerGas: gasPrice }` —
85
+ equal caps preserve the legacy effective-price ceiling for EIP-1559 consumers. On a
86
+ no-priority local fork where `baseFee == gasPrice`, the max-fee cap keeps the paid tip at
87
+ zero. The mode selects the estimation *source* only; it does not select a transaction type.
88
+
89
+ ### When the RPC degrades
90
+
91
+ Every fallback here is silent by design — an `eth_feeHistory` outage must never become a
92
+ quote outage. Because that would otherwise revert a chain to exactly the pricing this module
93
+ removes, with nothing to show for it, the degraded paths annotate the **active span**
94
+ (`gas.estimate.degraded`, `gas.estimate.degraded_reason`, `gas.estimate.chain_id`) rather
95
+ than a logger. `@opentelemetry/api` is a no-op without a registered SDK, so this costs
96
+ nothing for uninstrumented consumers.
97
+
98
+ ### Changing the per-chain numbers
99
+
100
+ Every floor, cap and multiplier in `src/chains.ts` is annotated with the evidence it came
101
+ from — measured clearing prices, block replays, dated probes — and several encode a
102
+ deliberate trade rather than an optimum. **Read the comment before changing the number**;
103
+ these values price real fills on seven services, and the reasoning is not reconstructible
104
+ from the value alone.
105
+
106
+ ---
107
+
108
+ ## OpenTelemetry
109
+
110
+ ### Setup
111
+
112
+ ```ts
113
+ import { setupSDK, configFromEnv, shutdownOpentelemetry } from '@rhinestone/service-sdk/opentelemetry'
114
+
115
+ setupSDK({ serviceName: 'my-service', ...configFromEnv() })
116
+ ```
117
+
118
+ `setupSDK` is idempotent and wires the whole stack in one call: an `AsyncLocalStorage`
119
+ context manager, gzip'd OTLP/gRPC trace and metric exporters, a tuned `BatchSpanProcessor`,
120
+ host metrics, and a `startup_time` gauge used as a deploy marker. It also registers a
121
+ `beforeExit` flush, so a clean shutdown exports what is still queued.
122
+
123
+ `configFromEnv()` reads `VERSION` and `DEPLOYMENT_ENV`. The OTLP endpoint comes from the
124
+ standard `OTEL_EXPORTER_OTLP_ENDPOINT`.
125
+
126
+ **Bundled instrumentations**, all enabled by `setupSDK`: `http` (WebSocket upgrades ignored,
127
+ so a long-lived connection can't produce an enormous span), `express` (captures referer and
128
+ request body on handler spans), `amqplib`, `prisma`, `pino` (log *correlation* only —
129
+ sending is disabled), and `viem` (parent-span-gated, capturing operation results).
130
+
131
+ ### Tracing
132
+
133
+ ```ts
134
+ import { withSpan, currentSpan, currentTraceId, addSpanAttributes, recordSpanError, CreateSpan } from '@rhinestone/service-sdk/opentelemetry'
135
+ ```
136
+
137
+ `withSpan(name, fn, options?)` runs `fn` in an active span, handling sync and async
138
+ identically: it records the exception, sets `ERROR` status and ends the span on a throw or
139
+ rejection, then rethrows. `CreateSpan(name?)` is the decorator form for class methods.
140
+ `withRootContext(fn)` detaches from the ambient context to start a fresh trace.
141
+ `currentSpan`, `currentContext`, `currentTraceId`, `addSpanAttributes` and `recordSpanError`
142
+ operate on whatever span is active.
143
+
144
+ ### Metrics
145
+
146
+ `createCounter`, `createHistogram`, `createGauge` and `createObservableGauge`, each taking
147
+ an optional `MetricOptions`. `SpanStatusCode`, `ValueType` and the OTel API types are
148
+ re-exported so consumers need no direct `@opentelemetry/api` dependency.
149
+
150
+ ### Propagation
151
+
152
+ `getTraceHeaders()` returns W3C `traceparent`/`tracestate` as a plain object for axios or
153
+ generic HTTP. `injectTraceHeaders` is the viem `onFetchRequest` hook form, propagating trace
154
+ context into outgoing RPC calls.
155
+
156
+ ---
157
+
158
+ ## Serialization
159
+
160
+ ```ts
161
+ import { convertBigIntFields, stringifyBigIntFields, bigintReplacer } from '@rhinestone/service-sdk/serialization'
162
+ ```
163
+
164
+ `BigInt` has no JSON representation, so an un-converted payload makes `JSON.stringify`
165
+ throw. These convert it to decimal strings.
166
+
167
+ - `convertBigIntFields(value, options?)` — returns a converted copy.
168
+ - `stringifyBigIntFields(value, options?)` — stringifies directly, preserving
169
+ `JSON.stringify`'s `toJSON` semantics (so a `Date` still becomes an ISO string).
170
+ - `bigintReplacer` — the bare replacer, with no guards, for use with `JSON.stringify`.
171
+
172
+ Both guarded functions **throw** on circular references and past `maxDepth`
173
+ (`DEFAULT_BIGINT_CONVERSION_MAX_DEPTH`, 100) rather than emitting lossy placeholders.
174
+ `convertFiniteNumbers: true` additionally encodes finite numbers as strings, for
175
+ canonical-signing payloads.
176
+
177
+ ---
178
+
179
+ ## Development
180
+
181
+ ```sh
182
+ bun install
183
+ bun run build # clean + tsc
184
+ bun run test
185
+ bun run check # biome (src only — see below)
186
+ bun run check:fix
187
+ ```
188
+
189
+ Dependency installs are gated on a 3-day release age (`bunfig.toml`,
190
+ `minimumReleaseAge`) as a supply-chain mitigation, so a freshly published — or freshly
191
+ compromised — version is not picked up immediately.
192
+
193
+ Note that biome is scoped to `src/**`, so `test/**` is neither linted nor format-checked.
194
+
195
+ ## Releasing
196
+
197
+ Publishing is automated. Cut a **GitHub Release** with tag `vX.Y.Z`; `release.yml` builds and
198
+ publishes to npm via OIDC trusted publishing.
199
+
200
+ The **tag is the version**. `package.json` says `0.0.1` and is never bumped — the workflow
201
+ overwrites it at publish time from the tag name. Don't try to keep it in sync.
202
+
203
+ Consumers pin a mix of exact versions and caret ranges, so **a `0.2.x` release reaches
204
+ every caret consumer on its next install, with no PR in that service**. Treat any change to
205
+ gas pricing as shipping to production on that basis.
package/dist/chains.d.ts CHANGED
@@ -13,6 +13,27 @@ export type EstimateGasFeesOptions = {
13
13
  */
14
14
  mode?: 'legacy';
15
15
  };
16
+ /**
17
+ * {@link estimateGasFees}' result: viem's EIP-1559 fee values plus the next
18
+ * block's base fee, when it could be read.
19
+ *
20
+ * `baseFeePerGas` is surfaced because `maxFeePerGas` is a submission CAP
21
+ * (`base * baseFeeMultiplierPct + tip`) — the price we are willing to bid up to,
22
+ * not the price a transaction pays (`base + tip`). Billing the cap is correct
23
+ * where a relayer must stay covered if the base fee climbs before inclusion; it
24
+ * is wrong to REPORT to a consumer that compares it against other relayers.
25
+ * Exposing the base fee lets a caller derive the expected price WITHOUT a second
26
+ * eth_feeHistory round trip — every path here already reads it and used to
27
+ * discard it (RHI-4992).
28
+ *
29
+ * `null` when the base fee was unavailable: a non-1559 chain, `mode: 'legacy'`,
30
+ * or a degraded read (already recorded via `recordDegradedEstimate`). Callers
31
+ * must treat null as "unknown" and fall back — never as zero, which would price
32
+ * a cap at the tip alone.
33
+ */
34
+ export type EstimatedGasFees = FeeValuesEIP1559 & {
35
+ baseFeePerGas: bigint | null;
36
+ };
16
37
  /**
17
38
  * Estimates gas fees for a chain and returns EIP-1559-shaped fee values.
18
39
  *
@@ -28,5 +49,5 @@ export type EstimateGasFeesOptions = {
28
49
  * zero. This changes fee estimation only; it does not select a transaction
29
50
  * type.
30
51
  */
31
- export declare function estimateGasFees(client: FeeEstimationClient, chainId: number, options?: EstimateGasFeesOptions): Promise<FeeValuesEIP1559>;
52
+ export declare function estimateGasFees(client: FeeEstimationClient, chainId: number, options?: EstimateGasFeesOptions): Promise<EstimatedGasFees>;
32
53
  //# sourceMappingURL=chains.d.ts.map
@@ -1 +1 @@
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"}
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;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,MAAM,gBAAgB,GAAG,gBAAgB,GAAG;IAChD,aAAa,EAAE,MAAM,GAAG,IAAI,CAAA;CAC7B,CAAA;AAyWD;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,eAAe,CACnC,MAAM,EAAE,mBAAmB,EAC3B,OAAO,EAAE,MAAM,EACf,OAAO,CAAC,EAAE,sBAAsB,GAC/B,OAAO,CAAC,gBAAgB,CAAC,CAiD3B"}
package/dist/chains.js CHANGED
@@ -41,19 +41,51 @@ const DEFAULT_FEE_CONFIG = {
41
41
  // Per-chain overrides for the windowed feeHistory path; any omitted field falls
42
42
  // back to DEFAULT_FEE_CONFIG.
43
43
  const FEE_CONFIG_OVERRIDES = {
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.
44
+ // Sepolia's reward distribution is a STEP FUNCTION, not a gradient. Replayed 300
45
+ // blocks / 38,746 txs on 2026-08-10:
46
+ //
47
+ // realised priority fee p1-p10 0.001 p25 0.0012 p50 0.354 p75 1.000
48
+ // per-block MINIMUM included tip: 0.001 gwei in essentially every block
49
+ //
50
+ // So a mass sits at 0.001 and another at 1.0 with almost nothing between, and the
51
+ // percentile choice is the whole price. p50 lands ON the cliff (0.22 gwei live) and
52
+ // p75 lands past it (1.16) — against a price that demonstrably clears at 0.001.
53
+ //
54
+ // This chain was billing ~1000x that clearing price. Paired against live feeHistory
55
+ // 12 times, our tip tracked p75 at ratio 0.98 while the median block was only 36%
56
+ // full: sepolia has 4-6 completely full blocks per 20-block window, and the
57
+ // congestion predicate takes the MEAN gasUsedRatio, so those outliers alone hold it
58
+ // on the 0.5 threshold (sampled means 0.499-0.541, escalating in 5 of 8 samples).
59
+ // The quote therefore oscillated between a 0.22 and a 1.16 gwei tip minute to minute.
60
+ //
61
+ // MEASURED effect, simulated against live feeHistory 10x with both configs side by
62
+ // side rather than derived on paper (the paper number was wrong):
63
+ //
64
+ // tip 1.1124 -> 0.1300 gwei (9x lower)
65
+ // cap 2.49 -> 1.51 gwei (1.68x cheaper, at a ~1.0 gwei base fee)
66
+ //
67
+ // Note WHICH knob does the work. Congestion fired in 10 of 10 samples, so sepolia
68
+ // effectively never reads `tipPercentile` at all — `congestedTipPercentile` is the
69
+ // live lever, and 75 -> 50 is what buys the 9x. The 50 -> 25 only starts to bite once
70
+ // the mean-based predicate below is fixed, at which point this gets cheaper again
71
+ // (p25 reads 0.0011 gwei). Reported by an external consumer as 3.3x over spot
72
+ // (RHI-5618).
73
+ //
74
+ // NOTE the mean-based congestion predicate is a SEPARATE defect and is deliberately
75
+ // NOT fixed here: mainnet sits on the same 0.5 knife-edge (measured mean 0.475-0.502,
76
+ // flipping), so switching it to a median would silently re-tune mainnet's tip too.
77
+ // That needs its own A/C measurement — see RHI-5618.
52
78
  [chains_1.sepolia.id]: {
79
+ // Unchanged. The base multiplier was already lowered 2.0x -> 1.3x and was never
80
+ // the dominant term here; the tip was.
53
81
  baseFeeMultiplierPct: 130n,
54
- tipPercentile: 50,
55
- congestedTipPercentile: 75,
56
- tipFloorWei: 0n,
82
+ tipPercentile: 25,
83
+ congestedTipPercentile: 50,
84
+ // 0.001 gwei — the price sepolia inclusion demonstrably clears at, from the replay
85
+ // above. Was 0n: a zero floor lets a percentile read of 0 price the tip at nothing,
86
+ // and testnet inclusion is already flaky, so the floor is doing real work now that
87
+ // we sample low enough to hit it.
88
+ tipFloorWei: 1000000n,
57
89
  },
58
90
  // Mainnet's reward distribution is inflated by MEV-boost blocks — searchers
59
91
  // pay enormous priority fees, so the feeHistory p50 prices our plain fills
@@ -311,7 +343,12 @@ async function estimateGasFees(client, chainId, options) {
311
343
  // Equal caps preserve gasPrice as the effective-price ceiling. If a
312
344
  // no-priority local fork reports baseFee == gasPrice, the max-fee cap means
313
345
  // the actual paid tip remains zero despite this priority-fee allowance.
314
- return { maxFeePerGas: gasPrice, maxPriorityFeePerGas: gasPrice };
346
+ // Legacy has no base-fee component to report.
347
+ return {
348
+ maxFeePerGas: gasPrice,
349
+ maxPriorityFeePerGas: gasPrice,
350
+ baseFeePerGas: null,
351
+ };
315
352
  }
316
353
  if (HISTORICAL_FEE_CHAINS.has(chainId)) {
317
354
  const config = { ...DEFAULT_FEE_CONFIG, ...FEE_CONFIG_OVERRIDES[chainId] };
@@ -335,6 +372,7 @@ async function estimateGasFees(client, chainId, options) {
335
372
  return {
336
373
  maxFeePerGas: cappedMaxFeePerGas(client, fees, baseFee, chainId, tip, oracleTip),
337
374
  maxPriorityFeePerGas: tip,
375
+ baseFeePerGas: baseFee,
338
376
  };
339
377
  }
340
378
  // viem's oracle tip, lifted to an absolute floor — and, for windowed-tip chains,
@@ -375,6 +413,7 @@ async function estimateViemFeesWithTipFloor(client, chainId, tipFloorWei) {
375
413
  return {
376
414
  maxFeePerGas: cappedMaxFeePerGas(client, fees, history.baseFee, chainId, tip, oracleTip),
377
415
  maxPriorityFeePerGas: tip,
416
+ baseFeePerGas: history.baseFee,
378
417
  };
379
418
  }
380
419
  /**
@@ -557,10 +596,15 @@ async function estimateFeeHistoryGasFees(client, chainId, config) {
557
596
  return {
558
597
  maxFeePerGas: await viemFallbackCap(client, config, priorityFee),
559
598
  maxPriorityFeePerGas: priorityFee,
599
+ baseFeePerGas: null,
560
600
  };
561
601
  }
562
602
  const maxFeePerGas = (baseFee * config.baseFeeMultiplierPct) / 100n + priorityFee;
563
- return { maxFeePerGas, maxPriorityFeePerGas: priorityFee };
603
+ return {
604
+ maxFeePerGas,
605
+ maxPriorityFeePerGas: priorityFee,
606
+ baseFeePerGas: baseFee,
607
+ };
564
608
  }
565
609
  // Nearest-rank percentile over a bigint sample. Empty sample → 0n.
566
610
  function percentileBigInt(values, percentile) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rhinestone/service-sdk",
3
- "version": "0.2.13",
3
+ "version": "0.2.15",
4
4
  "description": "Shared SDK for Rhinestone services",
5
5
  "author": {
6
6
  "name": "Rhinestone",