@rhinestone/service-sdk 0.2.14 → 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 +197 -14
- package/dist/chains.d.ts.map +1 -1
- package/dist/chains.js +43 -11
- package/package.json +1 -1
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
|
|
9
|
-
|
|
38
|
+
const { maxFeePerGas, maxPriorityFeePerGas, baseFeePerGas } =
|
|
39
|
+
await estimateGasFees(client, chainId)
|
|
10
40
|
```
|
|
11
41
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
`
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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.map
CHANGED
|
@@ -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;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,MAAM,gBAAgB,GAAG,gBAAgB,GAAG;IAChD,aAAa,EAAE,MAAM,GAAG,IAAI,CAAA;CAC7B,CAAA;
|
|
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
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
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:
|
|
55
|
-
congestedTipPercentile:
|
|
56
|
-
|
|
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
|