@rhinestone/service-sdk
v0.2.17
Published
Shared SDK for Rhinestone services
Readme
@rhinestone/service-sdk
Shared TypeScript utilities for Rhinestone backend services — the pieces every service needs and none of them should reimplement: OpenTelemetry setup and helpers, chain-aware gas fee estimation, graceful-shutdown plumbing, and BigInt-safe JSON serialization.
Published to npm and consumed by orchestrator, relayer, deposit-service-processor,
route-stats, back-office (api), and 1auth (passkey, recovery-signer).
Deliberately not a home for domain logic. The one module that encodes real judgement —
chains — earns it by being pure pricing arithmetic that several services must agree on;
everything else is glue.
Install
bun add @rhinestone/service-sdkviem is a peer dependency (^2.40.1), needed only for the chains entry point.
Entry points
| Import | What |
|---|---|
| @rhinestone/service-sdk | Re-exports ./serialization only |
| @rhinestone/service-sdk/chains | Chain-aware gas fee estimation |
| @rhinestone/service-sdk/lifecycle | Termination signals, crashes and explicit aborts as one channel |
| @rhinestone/service-sdk/opentelemetry | SDK setup, tracing/metrics helpers, propagation, integrations |
| @rhinestone/service-sdk/serialization | BigInt-safe JSON serialization |
Gas fee estimation
import { estimateGasFees } from '@rhinestone/service-sdk/chains'
const { maxFeePerGas, maxPriorityFeePerGas, baseFeePerGas } =
await estimateGasFees(client, chainId)estimateGasFees returns EIP-1559-shaped fees for every chain and every mode. The whole
module exists because viem's default estimator systematically mis-prices our fills: the
orchestrator bills maxFeePerGas, but on-chain a transaction only ever pays
base + tip, so unused headroom is charged to the user rather than spent.
Three estimation strategies, selected per chain:
- Windowed
eth_feeHistory(mainnet, sepolia, plasma) — pools tips across a 20-block window and prices the base from the next block, escalating to a higher reward percentile when the window's meangasUsedRatioshows congestion. Chains land here either for a volatile base fee (the L1s) or a broken tip oracle (plasma, whoseeth_maxPriorityFeePerGasreports ~3.5x what fills actually clear at). - viem's estimate with a tip floor (base, bsc, polygon, gnosis, hyperEVM, monad) — for chains whose base fee is stable but whose tip oracle under-reports. Polygon and base additionally window the feeHistory tip, since their real tip drifts above a static floor.
- viem's estimate plain — everything else.
On both viem paths a per-chain tip cap also applies where the oracle over-reports (optimism, soneium, sonic, avalanche, bsc). Cap and floor can both be set for one chain — bsc is — in which case the cap applies first and the floor last, so a floor is never undercut.
maxFeePerGas is rebuilt as nextBlockBaseFee * multiplier + tip — the next block's base,
not the latest mined one, which is where most of the over-charge disappears. The multiplier
is per-chain and each path has its own table: 1.05x by default on the viem paths (overridden
for hyperEVM, gnosis, avalanche), and part of the per-chain fee config on the windowed path
(1.30x sepolia, 1.60x mainnet, 2.00x plasma).
baseFeePerGas in the result lets a caller derive the expected price without a second
eth_feeHistory round trip. It matters because maxFeePerGas is a submission cap — right
to bill, wrong to report to a consumer comparing relayers. It is null — never 0n — when
the base was unavailable (non-1559 chain, legacy mode, or a degraded read). Treat null as
unknown and fall back; treating it as zero would price a cap at the tip alone.
Legacy mode
const fees = await estimateGasFees(client, chainId, { mode: 'legacy' })Takes precedence over all chain-specific logic and calls
client.estimateFeesPerGas({ type: 'legacy' }) without requesting fee history. The legacy
gasPrice is normalized to { maxFeePerGas: gasPrice, maxPriorityFeePerGas: gasPrice } —
equal caps preserve the legacy effective-price ceiling for EIP-1559 consumers. On a
no-priority local fork where baseFee == gasPrice, the max-fee cap keeps the paid tip at
zero. The mode selects the estimation source only; it does not select a transaction type.
When the RPC degrades
Every fallback here is silent by design — an eth_feeHistory outage must never become a
quote outage. Because that would otherwise revert a chain to exactly the pricing this module
removes, with nothing to show for it, the degraded paths annotate the active span
(gas.estimate.degraded, gas.estimate.degraded_reason, gas.estimate.chain_id) rather
than a logger. @opentelemetry/api is a no-op without a registered SDK, so this costs
nothing for uninstrumented consumers.
Changing the per-chain numbers
Every floor, cap and multiplier in src/chains.ts is annotated with the evidence it came
from — measured clearing prices, block replays, dated probes — and several encode a
deliberate trade rather than an optimum. Read the comment before changing the number;
these values price real fills on seven services, and the reasoning is not reconstructible
from the value alone.
Lifecycle
import {
installTerminationHandlers,
terminationExitCode,
describeReason,
} from '@rhinestone/service-sdk/lifecycle'
const termination = installTerminationHandlers()
const reason = await Promise.race([serve(termination), termination.waitForTermination()])
logger.info(`shutting down: ${describeReason(reason)}`)
await teardownInOrder()
process.exit(terminationExitCode(reason))Folds every way a process can be asked to stop into one channel: SIGTERM / SIGINT, an
uncaught exception, an unhandled rejection, and an explicit terminate(reason) from code
that found a broken invariant. waitForTermination() resolves once with a discriminated
TerminationReason, so the caller can log it and pick an exit code instead of every call
site inventing its own.
The first reason wins; later ones are dropped. That is the point rather than a detail: sockets closing during a shutdown routinely orphan promises, and those rejections must not restart the shutdown or flip a clean stop to a failure.
isTerminating() is the same state as a synchronous boolean, for the callers that can't
await — readiness probes returning 503 the instant a signal lands, admission gates refusing
new work while in-flight work drains.
Two things to know:
- Registering an
uncaughtException/unhandledRejectionlistener stops Node exiting on either. The caller must exit once teardown is done, or a crashed process lingers. - Nothing is registered at import time, and each install is independent. The install call is
the only thing that touches
process, anddispose()removes exactly what it added, so tests get a fresh one and own its disposal.
This module logs nothing itself; reporting is the owner's job, once the promise resolves.
OpenTelemetry
Setup
import { setupSDK, configFromEnv, shutdownOpentelemetry } from '@rhinestone/service-sdk/opentelemetry'
setupSDK({ serviceName: 'my-service', ...configFromEnv() })setupSDK is idempotent and wires the whole stack in one call: an AsyncLocalStorage
context manager, gzip'd OTLP/gRPC trace and metric exporters, a tuned BatchSpanProcessor,
host metrics, and a startup_time gauge used as a deploy marker. It also registers a
beforeExit flush, so a clean shutdown exports what is still queued.
configFromEnv() reads VERSION and DEPLOYMENT_ENV. The OTLP endpoint comes from the
standard OTEL_EXPORTER_OTLP_ENDPOINT.
Bundled instrumentations, all enabled by setupSDK: http (WebSocket upgrades ignored,
so a long-lived connection can't produce an enormous span), express (captures referer and
request body on handler spans), amqplib, prisma, pino (log correlation only —
sending is disabled), and viem (parent-span-gated, capturing operation results).
Tracing
import { withSpan, currentSpan, currentTraceId, addSpanAttributes, recordSpanError, CreateSpan } from '@rhinestone/service-sdk/opentelemetry'withSpan(name, fn, options?) runs fn in an active span, handling sync and async
identically: it records the exception, sets ERROR status and ends the span on a throw or
rejection, then rethrows. CreateSpan(name?) is the decorator form for class methods.
withRootContext(fn) detaches from the ambient context to start a fresh trace.
currentSpan, currentContext, currentTraceId, addSpanAttributes and recordSpanError
operate on whatever span is active.
Metrics
createCounter, createHistogram, createGauge and createObservableGauge, each taking
an optional MetricOptions. SpanStatusCode, ValueType and the OTel API types are
re-exported so consumers need no direct @opentelemetry/api dependency.
Propagation
getTraceHeaders() returns W3C traceparent/tracestate as a plain object for axios or
generic HTTP. injectTraceHeaders is the viem onFetchRequest hook form, propagating trace
context into outgoing RPC calls.
Serialization
import { convertBigIntFields, stringifyBigIntFields, bigintReplacer } from '@rhinestone/service-sdk/serialization'BigInt has no JSON representation, so an un-converted payload makes JSON.stringify
throw. These convert it to decimal strings.
convertBigIntFields(value, options?)— returns a converted copy.stringifyBigIntFields(value, options?)— stringifies directly, preservingJSON.stringify'stoJSONsemantics (so aDatestill becomes an ISO string).bigintReplacer— the bare replacer, with no guards, for use withJSON.stringify.
Both guarded functions throw on circular references and past maxDepth
(DEFAULT_BIGINT_CONVERSION_MAX_DEPTH, 100) rather than emitting lossy placeholders.
convertFiniteNumbers: true additionally encodes finite numbers as strings, for
canonical-signing payloads.
Development
bun install
bun run build # clean + tsc
bun run test
bun run check # biome (src only — see below)
bun run check:fixDependency installs are gated on a 3-day release age (bunfig.toml,
minimumReleaseAge) as a supply-chain mitigation, so a freshly published — or freshly
compromised — version is not picked up immediately.
Note that biome is scoped to src/**, so test/** is neither linted nor format-checked.
Releasing
Publishing is automated. Cut a GitHub Release with tag vX.Y.Z; release.yml builds and
publishes to npm via OIDC trusted publishing.
The tag is the version. package.json says 0.0.1 and is never bumped — the workflow
overwrites it at publish time from the tag name. Don't try to keep it in sync.
Consumers pin a mix of exact versions and caret ranges, so a 0.2.x release reaches
every caret consumer on its next install, with no PR in that service. Treat any change to
gas pricing as shipping to production on that basis.
