npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-sdk

viem 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 mean gasUsedRatio shows congestion. Chains land here either for a volatile base fee (the L1s) or a broken tip oracle (plasma, whose eth_maxPriorityFeePerGas reports ~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 / unhandledRejection listener 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, and dispose() 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, preserving JSON.stringify's toJSON semantics (so a Date still becomes an ISO string).
  • bigintReplacer — the bare replacer, with no guards, for use with JSON.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:fix

Dependency 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.