@stablechain/enterprise
v1.0.0
Published
Stable Enterprise SDK
Readme
Server-side SDK for Stable's enterprise transaction rails: gas waiver (relay gas-waived transactions), guaranteed blockspace (premium Enterprise-lane inclusion), and the composition of the two.
This is a stateless, server-side library that signs with private keys. Never run it in a browser — keep waiver and signing keys on your backend.
For end-user transfer / bridge / swap / earn, use
@stablechain/sdk.
Installation
bun add @stablechain/enterprise viem
# or
npm install @stablechain/enterprise viemviem is a peer dependency (>= 2.0.0). @aws-sdk/client-kms is an optional
peer dependency, needed only for the AWS KMS signer (@stablechain/enterprise/aws-kms).
The three modules
| Module | Client | What it does | Gateway? | Signer pays gas? |
|---------------------|-------------------------|------------------------------------------------------------------------------|----------|------------------|
| gasWaiver | enterprise.gasWaiver | Relay a user's tx with gas waived by a whitelisted account. | No | No (waived) |
| guaranteedBlock | enterprise.guaranteedBlock | Send a 0x3F CustomTx through the Enterprise RPC gateway for premium blockspace. | Yes | Yes (funded EOA) |
| guaranteedWaiver | enterprise.guaranteedWaiver | Gas-waived tx routed through guaranteed blockspace (composition of both). | Yes | No (waived) |
Enable any combination on one createStableEnterprise call — each module is
independent and carries its own signing key (or shares one; see
Signing).
Networks
Both rails are live on Stable Mainnet. The SDK is chain-agnostic — pass the chain you want and nothing else changes:
| Network | Export | Chain ID | RPC | Explorer |
|-----------------|-----------------|----------|---------------------------------|---------------------------------|
| Stable Mainnet | stable | 988 | https://rpc.stable.xyz | https://stablescan.xyz |
| Stable Testnet | stableTestnet | 2201 | https://rpc.testnet.stable.xyz| https://testnet.stablescan.xyz|
Both are re-exported from @stablechain/enterprise (they originate in
viem/chains), so you need no separate viem/chains import.
Two things are network-scoped and must match the chain you pass:
- The waiver key. A waiver account is whitelisted in governance per network
— a testnet-registered address is not registered on mainnet. Register the
mainnet address before your first mainnet relay, or the validator rejects the
WaiverTx. - The Enterprise API key. The gateway host
(
https://enterprise-rpc.stable.xyz/<api-key>) is the same on both networks; the key you were issued selects the network. Use the mainnet key withchain: stable.
Everything else — the waiver precompile, the NonceKey precompile backing 2D nonces, lane ids, transaction encoding — is identical across networks.
Quick start
import { createStableEnterprise, stable } from "@stablechain/enterprise";
import { privateKeyToAccount } from "viem/accounts";
const enterprise = createStableEnterprise({
chain: stable, // `stable` (mainnet) or `stableTestnet`, re-exported from viem/chains
gasWaiver: {
account: privateKeyToAccount(process.env.WAIVER_PRIVATE_KEY),
},
});
const gw = enterprise.gasWaiver!;
const { txHash } = await gw.send(sender, { to: token, data, gas: 150_000n });
// txHash is H_inner — the user-facing inner-tx hashStableEnterpriseConfig
| Param | Type | Description |
|--------------------------|-----------------|-----------------------------------------------------------------------------|
| chain | StableChain | Target chain — stable (mainnet) or stableTestnet, re-exported from the package. See Networks. |
| rpcEndpoints | string[] | (optional) Stable RPC endpoints, tried in order on failure. Defaults to the chain's built-in RPC URLs. |
| enterpriseRpcEndpoints | string[] | (optional) Enterprise RPC gateway endpoints for guaranteed blockspace (holds your API key). |
| batchSizeLimit | number | (optional) Max transactions per batched RPC call. Default 100. |
| signer | Signer | (optional) Default signer every module signs through when it has no key of its own. See Signing. |
| gasWaiver | GasWaiverConfig | (optional) Enable the Gas Waiver module. |
| guaranteedBlock | GuaranteedBlockConfig | (optional) Enable the Guaranteed Blockspace module. |
| guaranteedWaiver | GuaranteedWaiverConfig | (optional) Enable the composed Guaranteed Blockspace + Gas Waiver module. |
Every module client exposes the same four methods: send / sendBatch (build +
sign + relay) and relay / relayBatch (relay pre-signed hex). sendBatch /
relayBatch return one BatchResultItem per input, in input order — a
failed item is recorded, not thrown.
Gas Waiver
Relay a user's transaction with its gas waived by a whitelisted, governance-registered account.
user EOA ──signs──▶ InnerTx (gasPrice = 0)
│
▼
waiver account ──wraps & signs──▶ WaiverTx ──broadcast──▶ Stable node
(whitelisted) │
▼
validator accepts (waiver is
registered in governance); the
InnerTx executes, gas waivedTwo keys are involved:
| Key | Role |
|----------------------|---------------------------------------------------------------------------------------|
| Waiver key | The whitelisted, governance-registered account that signs the outer WaiverTx. |
| User (sender) key | Any EOA that signs the InnerTx. Its gas is waived. |
The call returns H_inner (the user-facing inner-tx hash), not the wrapper hash.
GasWaiverConfig — the waiver key source (SignerSource) plus
per-partner InnerTx policy limits: maxGasLimit, maxDataLength, allowedTargets.
Simplest path — send / sendBatch
Pass the signer account and the fields that vary; gasPrice: 0, legacy type,
chainId, and the nonce are handled for you.
// single — build + sign + wrap + relay in one call; throws on rejection
const { txHash } = await gw.send(sender, { to: token, data, gas: 150_000n });
// batch — nonces auto-sequenced from the account's pending nonce
const results = await gw.sendBatch(sender, [
{ to: token, data: dataA, gas: 150_000n },
{ to: token, data: dataB, gas: 150_000n },
]);Low-level — relay / relayBatch (pre-signed)
For the non-custodial flow where the sender signs the InnerTx in their own
environment and hands you only the signed hex (the waiver operator never sees the
sender key). Build one with buildWaiverInnerTx, signing through a Signer —
toSigner(account) for a local key, or a custody signer (KMS/HSM):
import { buildWaiverInnerTx, toSigner } from "@stablechain/enterprise";
const signed = await buildWaiverInnerTx(toSigner(sender), stable.id, { to, data, gas: 150_000n, nonce });
const { txHash } = await gw.relay(signed);
const results = await gw.relayBatch([signed0, signed1]); // one result per input, in orderGuaranteed Blockspace
Relay a GuaranteedTx (type 0x3F CustomTx) through the Enterprise RPC gateway
for premium, Enterprise-lane blockspace. Independent of gas waiver.
Unlike gas waiver, the signer pays its own gas, so it must be funded. Each GuaranteedTx carries a 2D nonce keyed to an Enterprise lane, and broadcasting goes only through the gateway (which holds your Enterprise API key).
GuaranteedBlockConfig
| Field | Type | Description |
|-----------|----------------|------------------------------------------------------|
| account | LocalAccount | Funded account that signs GuaranteedTxs. |
| laneId | bigint | Enterprise lane id — must be in [0, ENTERPRISE_MASK - 1]. |
const enterprise = createStableEnterprise({
chain: stable,
enterpriseRpcEndpoints: [process.env.ENTERPRISE_RPC_URL!], // gateway (holds the API key)
guaranteedBlock: {
account: privateKeyToAccount(process.env.USER_PRIVATE_KEY), // any funded EOA
laneId: 0n,
},
});
const gb = enterprise.guaranteedBlock!;Simplest path — send / sendBatch
Pass the signer and the varying fields. The 1559 fee fields are required (the signer
pays gas); chainId, the enterprise nonceKey, and the 2D nonce are handled for you.
The lane is strict-sequential (one uncommitted tx at a time, no mempool queuing) and
the 2D nonce is read from the NonceKey precompile at call time, so fire rapid
same-lane send()s only after the previous one is committed — or use sendBatch,
which reads the base nonce once and sequences the whole batch locally.
const gasPrice = await publicClient.getGasPrice();
const { txHash } = await gb.send(signer, {
to: recipient,
gas: 21_000n,
gasFeeCap: gasPrice * 2n,
gasTipCap: gasPrice,
});
const results = await gb.sendBatch(signer, [
{ to: a, gas: 21_000n, gasFeeCap: gasPrice * 2n, gasTipCap: gasPrice },
{ to: b, gas: 21_000n, gasFeeCap: gasPrice * 2n, gasTipCap: gasPrice },
]);Low-level — relay / relayBatch (pre-signed)
Build and sign the GuaranteedTx elsewhere with buildGuaranteedTx, then hand the
operator only the signed hex. The enterprise nonce key comes from nonceKeyForLane(laneId):
import { buildGuaranteedTx, nonceKeyForLane, toSigner } from "@stablechain/enterprise";
const signed = await buildGuaranteedTx(toSigner(account), stable.id, {
to,
gas: 21_000n,
gasFeeCap,
gasTipCap,
nonce, // the account's current 2D-lane nonce
nonceKey: nonceKeyForLane(0n), // Enterprise lane 0
});
const { txHash } = await gb.relay(signed);Guaranteed Blockspace + Gas Waiver
The composition of the two flows: a gas-waived transaction routed through
guaranteed blockspace. Both the inner and outer transaction are 0x3F CustomTxs
sharing one Enterprise nonceKey, so it broadcasts through the gateway like
guaranteedBlock — but the waiver sponsors gas, so the user needs no balance and no
fee fields, like gasWaiver.
user EOA ──signs──▶ inner 0x3F CustomTx (Enterprise nonceKey, fees = 0)
│
▼
waiver account ──wraps & signs──▶ outer 0x3F CustomTx ──broadcast──▶ Enterprise
(whitelisted) (SAME nonceKey) gatewayThe call returns H_inner (the inner-tx hash), like gas waiver.
GuaranteedWaiverConfig — the waiver (wrapper) key source
(SignerSource) plus laneId and the same maxGasLimit /
maxDataLength / allowedTargets inner-tx limits as gasWaiver.
const enterprise = createStableEnterprise({
chain: stable,
enterpriseRpcEndpoints: [process.env.ENTERPRISE_RPC_URL!],
guaranteedWaiver: {
account: privateKeyToAccount(process.env.WAIVER_PRIVATE_KEY), // whitelisted wrapper signer
laneId: 0n,
},
});
const gw = enterprise.guaranteedWaiver!;
// single — build + sign inner (from any EOA) + wrap + relay; no fee fields (gas waived)
const { txHash } = await gw.send(user, { to: recipient });
// batch — inner nonces auto-sequenced from the precompile's base
const results = await gw.sendBatch(user, [{ to: a }, { to: b }]);Low-level — relay / relayBatch (pre-signed)
The user signs the inner 0x3F CustomTx in their own environment with
buildGuaranteedTx (using nonceKeyForLane(laneId)) and hands the operator only the
signed hex; the operator wraps it with the waiver key and relays:
import { buildGuaranteedTx, nonceKeyForLane, toSigner } from "@stablechain/enterprise";
const signedInner = await buildGuaranteedTx(toSigner(user), stable.id, {
to,
gas: 100_000n,
gasFeeCap: 0n, // waived — fees are 0 on the inner
gasTipCap: 0n,
nonce,
nonceKey: nonceKeyForLane(0n),
});
const { txHash } = await gw.relay(signedInner); // waiver wraps + broadcasts → H_innerSigning
Every module resolves its signing key from a SignerSource, in order:
signer— a custody-gradeSigner(KMS / HSM / vendor). Set per-module, or set the top-levelconfig.signeronce to drive all modules from a single custody backend.account— an in-process viemLocalAccount(e.g.privateKeyToAccount(...)).- The env signer —
STABLE_ENTERPRISE_PRIVATE_KEY— used when neither is set.
Resolution is lazy: a client that only uses guaranteedBlock, or gives each
module its own key, never needs an env key set.
Signer helpers exported from the package:
import { toSigner, privateKeySigner, envSigner } from "@stablechain/enterprise";| Export | Description |
|--------------------|--------------------------------------------------------------------|
| toSigner(account)| Adapt a viem LocalAccount to a Signer. |
| privateKeySigner | Build a Signer from a raw private key. |
| envSigner() | Build a Signer from STABLE_ENTERPRISE_PRIVATE_KEY. |
AWS KMS signer
A custody-grade Signer whose private key never leaves AWS KMS, exported from the
optional subpath @stablechain/enterprise/aws-kms (requires the
@aws-sdk/client-kms peer dependency):
import { createStableEnterprise, stable } from "@stablechain/enterprise";
import { awsKmsSigner } from "@stablechain/enterprise/aws-kms";
const signer = await awsKmsSigner({ keyId: process.env.AWS_KMS_KEY_ID! });
const enterprise = createStableEnterprise({
chain: stable,
gasWaiver: { signer }, // waiver key lives in KMS
});Errors
| Class | Thrown when |
|--------------------------------|-------------------------------------------------------------------------|
| StableEnterpriseError | Base class for all enterprise errors. |
| StableEnterpriseRelayError | A relay was rejected (broadcast failure, gateway rejection, etc.). Carries an ErrorCode. |
| WaiverValidationError | An InnerTx failed per-partner validation (bad target, gas/data limits, non-zero gas price). |
Batch methods never throw for individual failures — each BatchResultItem carries
either a txHash or an error with a stable ErrorCode. Codes are exported as
ErrorCode (e.g. BROADCAST_FAILED, TARGET_NOT_ALLOWED, GATEWAY_UNAUTHORIZED,
QUOTA_EXCEEDED).
Example
A runnable backend example covering all three flows lives in
examples/enterprise — single and batch sends,
a real ERC-20 relay, and the AWS KMS custody path. See its README for setup and
.env configuration.
The example targets Stable Testnet, so it can be run end to end without
spending real funds. Point it at mainnet by swapping stableTestnet for stable
in its shared.ts — and note its ERC-20 relay uses the testnet USD₮0 address,
which differs on mainnet.
