@stablechain/sdk
v1.0.8
Published
Stable SDK
Readme
Transfer, bridge, swap, and earn yield on Stable from a browser wallet or a
server-side key. For gasless relaying and premium blockspace, see
@stablechain/enterprise.
Installation
bun add @stablechain/sdk viem
# or
npm install @stablechain/sdk viemviem is a peer dependency (>= 2.0.0).
Quick start
Server-side (private key):
import { createStable, Network } from "@stablechain/sdk";
import { privateKeyToAccount } from "viem/accounts";
const stable = createStable({
network: Network.Mainnet,
account: privateKeyToAccount("0x..."),
});Browser wallet (MetaMask, etc.):
import { createStable, Network } from "@stablechain/sdk";
import { custom } from "viem";
const stable = createStable({
network: Network.Mainnet,
transport: custom(window.ethereum),
});Pre-built viem WalletClient:
const stable = createStable({
network: Network.Mainnet,
walletClient: myWalletClient,
});StableConfig
| Param | Type | Description |
|-----------------|----------------|----------------------------------------------------------------------|
| network | Network | Network.Mainnet (default) or Network.Testnet |
| rpc | string | (optional) RPC override for the Stable chain |
| rpcOverrides | Partial<Record<Chain, string>> | (optional) Per-chain RPC endpoints for the bridge routes, e.g. { [Chain.Ethereum]: "https://..." }. rpc covers the Stable chain only; a bridge reads and broadcasts on whichever chain it starts from, and defaults to this SDK's built-in public endpoint there |
| account | Account | (optional) viem Account for server-side signing |
| transport | Transport | (optional) viem Transport (e.g. custom(window.ethereum)) |
| walletClient | WalletClient | (optional) Pre-built viem WalletClient — takes precedence |
| earn | EarnConfig | (optional) { vault: "0x..." } — required for stable.earn.* |
| merklApiBase | string | (optional) Base URL for the Merkl rewards API — override to proxy through your own server and avoid CORS restrictions in browser environments (default: https://api.merkl.xyz) |
API
interface StableClient {
transfer(params: TransferParams): Promise<OperationResult>;
quoteBridge(params: BridgeParams): Promise<BridgeQuote>;
bridge(params: BridgeParams): Promise<OperationResult>;
prepareBridgeCalldata(params: PrepareBridgeParams): Promise<BridgeCalldata>;
estimateBridgeTime(params: Pick<BridgeParams, "fromChain" | "toChain"> & Partial<Pick<BridgeParams, "fromToken" | "toToken">>): Promise<BridgeTimeEstimate>;
trackBridge(srcTxHash: string): Promise<BridgeStatus>;
quoteSwap(params: SwapParams): Promise<SwapQuote>;
swap(params: SwapParams): Promise<OperationResult>;
authorization: { /* ERC-3009 — see below */ };
earn: {
deposit(params: EarnDepositParams): Promise<EarnResult>;
bulkDeposit(items: EarnDepositParams[], options?: BulkDepositOptions): Promise<BulkEarnResult>;
withdraw(params: EarnWithdrawParams): Promise<EarnResult>;
bulkWithdraw(items: EarnWithdrawParams[], options?: BulkWithdrawOptions): Promise<BulkEarnResult>;
redeem(params: EarnRedeemParams): Promise<EarnResult>;
incentiveRewards: {
fetch(): Promise<MerklRewardsResult>;
claim(): Promise<MerklClaimResult>;
};
};
}All write methods resolve to OperationResult or EarnResult:
interface OperationResult {
txHash: string;
toAmount?: number; // destination-side amount, when a conversion occurred
}
interface EarnResult {
txHash: string;
idempotencyKey?: string;
}transfer(params)
Send tokens on Stable. Native USDT0 transfers use 18 decimals; ERC-20 decimals are fetched on-chain when not provided.
Native USDT0
const { txHash } = await stable.transfer({
to: "0xRecipient",
amount: 10, // 10 USDT0
});ERC-20 (USDC, EURC, …)
const { txHash } = await stable.transfer({
to: "0xRecipient",
amount: 100,
token: "0xUSDCAddress", // ERC-20 contract address on Stable
tokenDecimals: 6, // optional — fetched on-chain if omitted
});| Param | Type | Description |
|-----------------|----------|-----------------------------------------------------------------------------------|
| to | string | Recipient address |
| amount | number | Amount in human-readable units |
| token | string | (optional) ERC-20 contract address. Omit for native USDT0. |
| tokenDecimals | number | (optional) Token decimals — fetched on-chain if not provided |
| from | string | (optional, advisory) Sender label. The signer is taken from walletClient/account; this field does not override it. |
transfer switches the wallet to the configured Stable network, sends the tx, waits for the receipt, and throws a StableTransactionError if it reverts.
quoteBridge(params) → BridgeQuote
Preview a bridge before sending. Quotes are fetched from LI.FI.
import { Chain } from "@stablechain/sdk";
const quote = await stable.quoteBridge({
fromChain: Chain.Ethereum,
toChain: Chain.Stable,
fromToken: "0xdAC17F958D2ee523a2206206994597C13D831ec7", // USDT on Ethereum
toToken: "0x779Ded0c9e1022225f8E0630b35a9b54bE713736", // USDT0 on Stable
amount: 100,
});
console.log("You receive:", quote.toAmount); // human units, on toChainBridgeQuote.toAmount is the expected destination-side amount in human units.
On native OFT routes the quote is fully costed — toAmount comes from the adapter's
own quoteOFT, so it is the amount that actually lands, net of any OFT fee and of
dust dropped by decimal conversion:
| Field | Type | What it is |
|----------------|---------------------|-------------------------------------------------------------------------------|
| toAmount | number | What the recipient receives, human units |
| nativeFee | bigint | LayerZero messaging fee (wei) as quoted — the number to show a user |
| nativeFeeMax | bigint | What a send built from this quote carries as its value: nativeFee padded by feeBufferBps. Check native balances against this |
| lzTokenFee | bigint | Same fee if paid in LZ token instead — 0n on every route the SDK sends |
| oft | BridgeOftQuote | The adapter's own accounting: amountSentLD, amountReceivedLD, fees[], and the pathway's minAmountLD/maxAmountLD |
| srcGas | BridgeGasEstimate | Source-chain execution cost: gas, gasPriceWei, costWei, source |
| totalCostWei | bigint | srcGas.costWei + nativeFee — everything leaving the sender's native balance |
| estimate | BridgeTimeEstimate| Delivery time — see estimateBridgeTime |
import { formatEther } from "viem";
console.log("You receive:", quote.toAmount);
for (const fee of quote.oft?.fees ?? []) {
// Empty on every pathway the SDK configures today; a deployment that charges an
// OFT-level fee reports it here. Negative means a credit to the sender.
console.log(`${fee.description}: ${fee.amount}`);
}
if (quote.totalCostWei) {
console.log(`Costs ${formatEther(quote.totalCostWei)} of native token to send`);
}
if (quote.estimate) {
console.log(`Arrives in ~${Math.round(quote.estimate.seconds / 60)} min`);
}srcGas.source says how much to trust the cost: "simulated" means the real send
was simulated, "fallback" means the simulation reverted — which is what happens when
you quote before approving the adapter — and gas is a typical-send constant instead.
Every field above is absent on LI.FI routes, and oft/srcGas are also absent when
the adapter or the RPC would not answer, so treat them as optional rather than assuming
a native route populates them.
Both quoteBridge() and bridge() accept dstNativeDrop — wei of the destination
chain's native token to airdrop to the recipient, what bridge UIs call gas on
destination. The executor prices it into nativeFee, so quote with the same value you
send with:
const quote = await stable.quoteBridge({ ...params, dstNativeDrop: 10n ** 14n });
await stable.bridge({ ...params, dstNativeDrop: 10n ** 14n, quote });Each pathway's executor caps the drop it will accept; asking for more makes the quote
throw StableQuoteError rather than silently clamping.
The messaging fee is padded before sending
A send has to carry the fee the pathway costs at the moment it lands, and send
demands an exact match: msg.value must equal _fee.nativeFee, the endpoint reverts
with LZ_InsufficientFee when that falls short of what it needs, and refunds the
excess to the sender when it does not. Gas moves between a quote and a signature, and
only one direction of that costs anything — a fee that rose reverts the send with the
gas already spent, while a fee that was padded comes back.
So bridge() and prepareBridgeCalldata() send nativeFee padded by feeBufferBps,
5% by default:
await stable.bridge({ ...params, feeBufferBps: 1_000 }); // 10%
await stable.bridge({ ...params, feeBufferBps: 0 }); // exactly the quoted feequoteBridge() reports both numbers and pads neither: nativeFee is the honest quote
to display, nativeFeeMax is what will actually leave the wallet.
bridge(params)
Bridge tokens cross-chain, over the native LayerZero OFT mesh where the pair supports it and LI.FI otherwise — see Bridge routes. Handles ERC-20 approval automatically. Switches the wallet to the source chain (MetaMask will prompt) before sending.
Only a lockbox pathway needs an allowance at all — Ethereum for USDT0 and XAUt0, and
Sepolia on testnet. Every other deployment is an OFT that burns from the caller, so no
approve and no allowance read is issued there. Where one is needed and the current
allowance is short but not zero, it takes two approvals: legacy USDT refuses to
overwrite a non-zero allowance with another non-zero value, so the SDK sends
approve(0) first.
import { Chain } from "@stablechain/sdk";
const { txHash, toAmount } = await stable.bridge({
fromChain: Chain.Ethereum,
toChain: Chain.Stable,
fromToken: "0xdAC17F958D2ee523a2206206994597C13D831ec7",
toToken: "0x779Ded0c9e1022225f8E0630b35a9b54bE713736",
amount: 100,
});| Param | Type | Description |
|----------------|---------------|--------------------------------------------------------------------------|
| fromChain | Chain | Source chain |
| toChain | Chain | Destination chain |
| fromToken | string | Source token contract address |
| toToken | string | Destination token contract address |
| amount | number | Amount to bridge (human-readable) |
| fromDecimals | number | (optional) Source token decimals — defaults to 6. LI.FI routes only: native OFT routes parse the amount with the decimals the token has on the source chain, and a value disagreeing with those throws |
| recipient | string | (optional) Destination address — defaults to the signer |
| dstNativeDrop| bigint | (optional) Destination native token (wei) to airdrop to the recipient — native OFT routes only |
| feeBufferBps | number | (optional) Padding on the quoted messaging fee, in basis points — defaults to 500 (5%). Native OFT routes only |
| refundAddress| string | (optional) Where LayerZero returns messaging fee it did not need — defaults to the sender. Native OFT routes only |
| quote | BridgeQuote | (optional) On native OFT routes, a quote from quoteBridge() is reused as-is: it supplies the messaging fee and the slippage floor, skipping two reads. LI.FI routes re-fetch regardless |
| route | BridgeRoute | (optional) "auto" (default), "oft", or "lifi" — see Bridge routes |
txHash is the source-chain transaction hash. Destination delivery is asynchronous —
poll trackBridge(txHash) to follow it.
prepareBridgeCalldata(params) → BridgeCalldata
Build a bridge's transactions without signing or sending any of them — for the split where a backend quotes, builds and checks while the holder's wallet only signs. Native OFT routes only: a pair that resolves to LI.FI throws, because LI.FI hands back its own transaction request from its API.
import { Chain } from "@stablechain/sdk";
const built = await stable.prepareBridgeCalldata({
from: "0xUserWallet", // whose allowance decides the approvals
fromChain: Chain.Ethereum,
toChain: Chain.Stable,
fromToken: "0xdAC17F958D2ee523a2206206994597C13D831ec7",
toToken: "0x779Ded0c9e1022225f8E0630b35a9b54bE713736",
amount: 100,
});
for (const step of built.steps) {
// Send in order, each confirmed before the next.
await walletClient.sendTransaction(step);
}Takes everything bridge() does, plus:
| Param | Type | Description |
|-----------------|----------|-----------------------------------------------------------------------------------|
| from | string | The address that will sign these transactions. Its allowance decides whether an approval step is needed, and it is the default recipient and refund address |
steps is ordered and must be sent in order, each confirmed before the next:
| Step | When |
|-------------|---------------------------------------------------------------------------------------|
| approve(0)| The pathway needs an allowance, and the current one is short but not zero — legacy USDT refuses to overwrite a non-zero allowance with another non-zero value |
| approve(n)| The pathway needs an allowance and the current one is short |
| send | Always. Carries the buffered messaging fee as its value |
Each step is { chainId, to, data, value }, and chainId is the source chain on every
one of them. Alongside them:
| Field | Type | What it is |
|-----------|-----------------|---------------------------------------------------------------------|
| chainId | number | The source chain every step must be sent on |
| asset | "usdt0" \| "xaut0" | Which OFT mesh this send moves through |
| quote | BridgeQuote | The quote the steps were built from — supplied or freshly read |
| send | object | The send step's arguments decoded: dstEid, recipient, to, amountLD, minAmountLD, extraOptions, nativeFee, lzTokenFee, refundAddress |
send is there so a backend can check what it built without re-deriving the ABI
encoding — and OFT_ADAPTER_ABI and buildLzOptions are exported for the checks that
do want the encoding:
import { OFT_ADAPTER_ABI } from "@stablechain/sdk";
import { decodeFunctionData } from "viem";
const { args } = decodeFunctionData({
abi: OFT_ADAPTER_ABI,
data: built.steps.at(-1)!.data as `0x${string}`,
});This refuses to build when the adapter will not answer quoteOFT: its receipt is what
floors the send at the amount it would actually deliver, and there is no safe stand-in
for that floor — the full input amount reverts with SlippageExceeded on any pathway
that charges a fee. quoteBridge() still degrades to a fee-only quote in that case;
only the paths that build a send refuse.
Bridge routes
bridge() and quoteBridge() pick between two paths:
| Route | What it is | Cost | Speed |
|----------|-------------------------------------|---------------------------------|--------|
| "oft" | The token's native LayerZero OFT | No protocol fee — gas + LZ messaging fee | Slower |
| "lifi" | The LI.FI aggregator | Charges a fee | Fast |
route defaults to "auto": the OFT path when both chains deploy the asset the token
addresses name, LI.FI otherwise. Forcing "oft" on an unsupported pair throws
StableValidationError rather than silently charging a LI.FI fee.
route: "usdt0"is a deprecated alias for"oft". It does not restrict the route to USDT0 — the token addresses decide the asset.
Native OFT assets
Two assets have their own OFT mesh, each with its own adapter and token address per
chain. A bridge stays within one mesh: the OFT mints its own peer on the far side, so it
cannot hand back a different token. A cross-asset request (USDT0 in, XAUt0 out) is a
trade, not a bridge, and "auto" sends it to LI.FI.
USDT0 — Stable, Ethereum, Arbitrum, Bera, Flare, HyperEVM, Ink, MegaETH, Optimism, Plasma, Polygon, Rootstock, Sei, Unichain, XLayer.
XAUt0 (Tether Gold, 6 decimals) — Stable, Ethereum, Arbitrum, BSC, HyperEVM, Ink, Plasma, Polygon. Every pair among those eight is wired in both directions.
// XAUt0: Ethereum → Stable, over the native OFT mesh
const { txHash } = await stable.bridge({
fromChain: Chain.Ethereum,
toChain: Chain.Stable,
fromToken: "0x68749665FF8D2d112Fa859AA293F07A622782F38", // XAUt on Ethereum
toToken: "0xB8CE59FC3717ada4C02eaDF9682A9e934F625ebb", // XAUt0 on Stable
amount: 2, // 2 XAUt0
});Ethereum is XAUt0's lockbox — canonical XAUt is escrowed there and minted elsewhere, so it is the only chain where a send needs an ERC-20 approve. Bridging XAUt0 from any other chain burns from the caller and skips the approval transaction entirely.
BSC is XAUt0-only: it has no USDT0 deployment, so a BSC USDT bridge routes to LI.FI.
Addresses per chain are in BRIDGE_CHAIN_CONFIGS[chain].tokens, keyed by asset:
import { BRIDGE_CHAIN_CONFIGS, bridgeAssetFor, Chain } from "@stablechain/sdk";
const stableChain = BRIDGE_CHAIN_CONFIGS[Chain.Stable];
stableChain.tokens.xaut0?.address; // XAUt0 ERC-20 on Stable
stableChain.tokens.xaut0?.oftAdapter; // its OFT
// Which asset an address refers to on a chain, if any
bridgeAssetFor(stableChain, someToken); // "usdt0" | "xaut0" | undefinedestimateBridgeTime(params) → BridgeTimeEstimate
How long a native OFT (LayerZero) bridge will take, before you send it. Throws for chain pairs with no OFT adapter — LI.FI routes carry their own ETA in the quote.
const est = await stable.estimateBridgeTime({
fromChain: Chain.Stable,
toChain: Chain.Arbitrum,
});
console.log(`~${Math.round(est.seconds / 60)} min`); // ~42 minDelivery time is per pathway, and USDT0 and XAUt0 are different pathways on the same
chain pair. Omitting the tokens estimates the USDT0 pathway; pass fromToken/toToken
to estimate a specific asset:
const est = await stable.estimateBridgeTime({
fromChain: Chain.Ethereum,
toChain: Chain.Stable,
fromToken: "0x68749665FF8D2d112Fa859AA293F07A622782F38", // XAUt on Ethereum
toToken: "0xB8CE59FC3717ada4C02eaDF9682A9e934F625ebb", // XAUt0 on Stable
});Nearly all of the wait is source-chain finality: the DVNs securing the pathway will
not attest until the send transaction is buried under confirmations source blocks.
Destination execution is a few blocks on top.
estimateBridgeTime reports that breakdown:
| Field | Meaning |
|-------------------|--------------------------------------------------------------------------------------------------|
| seconds | The estimate — display this |
| source | "formula", "floor", or "observed" — which input produced seconds |
| observedSeconds | Median delivery time of recent messages on this exact pathway, from LayerZero Scan |
| formulaSeconds | LayerZero's documented estimate: srcBlockTime × confirmations + dstBlockTime × (2 + dvnCount) |
| confirmations | Source blocks the DVNs wait for, read live from the pathway's ULN config |
| dvnCount | DVNs verifying the pathway |
seconds comes from the formula — the pathway's live ULN config is what the DVNs are
actually bound by, so a config change is reflected the moment it lands. On every pathway
that is purely confirmation-bound it agrees with both usdt0.to's own estimate and the
delivery times LayerZero Scan reports to well under a percent.
Some source chains carry a floor. DVN attestation and destination execution sit outside the
formula, and on Ethereum and Arbitrum the DVNs additionally hold until the send finalizes on
the beacon chain. Where confirmations are high that overhead vanishes into the rounding;
where they are low it dominates — Arbitrum's 60 confirmations compute to ~19 s against a real
~18 min. Those chains are floored at a measured minimum, and source reports "floor".
observedSeconds is fetched alongside and reported for comparison, but does not drive
seconds: a 50-message window can reach back months, so on a pathway that has been
reconfigured or has gone quiet it answers from a config that no longer exists.
Pathway configs and observed medians are cached for 5 minutes; call
clearBridgeEstimateCache() to force a refetch.
Roughly what to expect on mainnet, from configs read 2026-08-27:
| Pathway | Confirmations | Typical delivery | |---------------------|---------------|------------------| | Polygon → Stable | 32 | ~1 min ᶠ | | Bera → Stable | 60 | ~2 min ᶠ | | Flare → Stable | 500 | ~10 min ᶠ | | Sei → Stable | 2000 | ~17 min | | Ethereum → Stable | 65 | ~19.5 min ᶠ | | Arbitrum → Stable | 60 | ~22.5 min ᶠ | | Unichain → Stable | 1600 | ~27 min | | Plasma → Stable | 1800 | ~30 min | | Optimism → Stable | 1000 | ~33 min | | Stable → any | 3600 | ~42 min | | Rootstock → Stable | 120 | ~62 min | | MegaETH → Stable | 5400 | ~90 min | | X Layer → Stable | 9000 | ~2.5 h | | HyperEVM → Stable | 43200 | ~12 h |
ᶠ Floored at the source chain's measured minimum, because its confirmation count is not the whole wait — Ethereum and Arbitrum would otherwise compute to ~13 min and ~19 s.
trackBridge(srcTxHash) → BridgeStatus
Follow an in-flight native OFT bridge, via LayerZero Scan. A message Scan has not indexed yet
reports "pending" rather than throwing, so it is safe to call immediately after bridge().
const { txHash } = await stable.bridge({ /* … */ });
const status = await stable.trackBridge(txHash);
// { state: "inflight", elapsedSeconds: 1027, scanUrl: "https://layerzeroscan.com/tx/0x…" }
if (status.state === "delivered") console.log("Landed:", status.dstTxHash);state is one of "pending" | "inflight" | "delivered" | "failed". raw carries the
underlying LayerZero Scan status when you need the exact value.
quoteSwap(params) → SwapQuote
Fetch a LI.FI swap quote on Stable without sending a transaction.
const quote = await stable.quoteSwap({
fromToken: "0x8a2B28364102Bea189D99A475C494330Ef2bDD0B", // e.g. USDCe
toToken: "0x779Ded0c9e1022225f8E0630b35a9b54bE713736", // USDT0
amount: 100,
fromDecimals: 6,
});
console.log("You'll receive:", quote.toAmount, "USDT0");interface SwapQuote {
toAmount: number; // human-readable, formatted with toToken decimals
fromAmount: bigint; // raw source-side amount
fromToken: string; // ERC-20 to approve (or native sentinel)
approvalAddress?: string; // spender for ERC-20 approval (omitted for native)
transactionRequest: {
to: string;
data: string;
value: string;
gasLimit: string;
chainId: number;
};
}swap(params)
Swap tokens on Stable via LI.FI. Handles ERC-20 approval automatically; MetaMask is asked to switch to Stable if needed.
Pass a pre-fetched quote from quoteSwap() to skip the LI.FI quote call.
const quote = await stable.quoteSwap({ fromToken, toToken, amount: 100, fromDecimals: 6 });
const { txHash, toAmount } = await stable.swap({
fromToken,
toToken,
amount: 100,
fromDecimals: 6,
quote,
});| Param | Type | Default | Description |
|----------------|-------------|---------|---------------------------------------------------|
| fromToken | string | | Token address on Stable |
| toToken | string | | Token address on Stable |
| amount | number | | Human-readable amount |
| fromDecimals | number | 6 | Source token decimals |
| toAddress | string | | (optional) Recipient — defaults to signer |
| quote | SwapQuote | | (optional) Pre-fetched quote — skips LI.FI call |
authorization.* — ERC-3009 gasless transfers
ERC-3009 ("Transfer With Authorization") lets a payer sign a transfer offline; anyone else submits it on-chain later. The payer pays no gas and needn't be the submitter — the primitive behind x402 payments and net-30 invoicing. USDT0 on Stable implements it natively.
The actions are viem-style and fully standalone — 3009 works with nothing but viem, no createStable and no payment module required. Import the primitives directly, attach them to any viem client via .extend(erc3009Actions), or use the stable.authorization.* convenience below.
// Isolated subpath — pulls in zero bridge/earn/swap code.
import { signTransferAuthorization, erc3009Actions } from "@stablechain/sdk/erc3009";
const signed = await signTransferAuthorization(walletClient, { to, value, validBefore });
// or: const client = walletClient.extend(erc3009Actions);This module is a leaf: it depends only on viem. Any higher-level payments module is a consumer of it, never the reverse — so you can adopt 3009 à la carte.
// Payer side — sign offline, hand the result off over HTTP / QR / x402
const signed = await stable.authorization.signTransferAuthorization({
to: "0xVendor",
value: 1_250_000n, // raw units — 1.25 USDT0 (6 dp)
validBefore: BigInt(Math.floor(Date.now() / 1000) + 3600),
});
// Submitter side — screen it gas-free, then settle (or hand off to a relayer)
const check = await stable.authorization.verifyAuthorization({ signed });
if (check.ok) await stable.authorization.transferWithAuthorization({ signed });| Method | Returns | Description |
|------------------------------------------------|-----------------------------|-----------------------------------------------------------------------------|
| signTransferAuthorization(p) | SignedAuthorization | Sign a portable TransferWithAuthorization (offline, gas-free) |
| signReceiveAuthorization(p) | SignedAuthorization | Same, but only the recipient (to) can submit it |
| transferWithAuthorization(p) | Hash | Submit a signed (or freshly-signed) authorization — anyone may submit |
| receiveWithAuthorization(p) | Hash | Submit as the recipient (enforced on-chain via msg.sender) |
| cancelAuthorization(p) | Hash | Revoke an unused nonce (best-effort — can race an in-flight settle) |
| prepareTransferWithAuthorization(p) | PreparedAuthorizationCall | Sign + return { to, data, value, chainId, signed } for a relayer to submit |
| verifyAuthorization(p) | VerifyAuthorizationResult | Gas-free preflight: signer, nonce state, time window, solvency, eth_call |
| getAuthorizationState(p) | boolean | true once the nonce is used or cancelled |
| getAuthorizationUsage(p) | AuthorizationUsage | Resolve the consuming tx from the AuthorizationUsed event (no indexer) |
value is a raw bigint in the token's smallest unit (USDT0 is 6 decimals) — this is the 6-dp ERC-20 path, not the 18-dp native one. token defaults to USDT0; nonce defaults to a cryptographically-random 32 bytes; the EIP-712 domain is read from the token (EIP-5267 eip712Domain(), falling back to name() + version "1") unless overridden.
To relay gaslessly, pass prepareTransferWithAuthorization's output to the @stablechain/enterprise gas-waiver rail.
Contract signers: canonical ERC-3009 uses
ecrecover, so Safe / EIP-1271 accounts cannot sign an authorization. Use an EOA (optionally a 7702 session key) for the payer.
earn.deposit(params) → EarnResult
Deposit USDT0 into the Stable vault to earn yield, powered by Morpho. Handles ERC-20 approval and permit signatures automatically.
import { createStable, Network, STABLE_VAULT_ADDRESS } from "@stablechain/sdk";
const stable = createStable({
network: Network.Mainnet,
earn: { vault: STABLE_VAULT_ADDRESS },
walletClient: myWalletClient,
});
const { txHash } = await stable.earn.deposit({ amount: 100 }); // 100 USDT0| Param | Type | Description |
|---------------------|----------|-------------------------------------------------------------------|
| amount | number | Amount in human-readable units |
| vault | string | (optional) Vault address — overrides the one set in config |
| tokenDecimals | number | (optional) Token decimals — fetched on-chain if omitted |
| slippageTolerance | number | (optional) Max slippage as a decimal (e.g. 0.01 = 1%) |
| idempotencyKey | string | (optional) Dedup key — concurrent calls with the same key return the same Promise |
| depositBuffer | number | (optional) Max fraction of vault idle liquidity to consume (e.g. 0.8 = 80%). Throws StableValidationError before sending if exceeded. Must be > 0 and ≤ 1. |
earn.withdraw(params) / earn.redeem(params) → EarnResult
Exit the vault by asset amount (withdraw) or by share count (redeem).
// Withdraw by asset amount — most common
const { txHash } = await stable.earn.withdraw({ amount: 100 });
// Redeem by share count — use reader.earn.position() to get sharesFormatted
const { sharesFormatted } = await reader.earn.position();
const { txHash } = await stable.earn.redeem({ shares: sharesFormatted });Shares ≠ assets. The exchange rate increases over time as yield accrues. Use
withdrawwhen you want a specific USDT0 amount back; useredeemwhen you want to exit by share count (e.g. close 100% of a position). Usereader.earn.position()to get the human-readable share balance (sharesFormatted).
When idle vault liquidity is insufficient, withdraw and redeem automatically pull liquidity from the underlying market adapters. No extra configuration needed.
earn.bulkDeposit(items, options?) → BulkEarnResult
Execute multiple deposits sequentially from one signer. Each item can target a different vault — useful for distributing across vaults in a single call. Failures are isolated: a failed item is recorded in results without halting the rest (unless stopOnError is set).
import { type BulkEarnResult } from "@stablechain/sdk";
const result: BulkEarnResult = await stable.earn.bulkDeposit([
{ amount: 50, vault: "0xVaultA" },
{ amount: 30, vault: "0xVaultB" },
{ amount: 20 }, // falls back to the vault set in config
]);
console.log(`${result.succeeded} succeeded, ${result.failed} failed`);
result.results.forEach((r, i) => {
if (r.status === "fulfilled") console.log(`#${i + 1} [${r.vault}] txHash:`, r.txHash);
else console.error(`#${i + 1} [${r.vault}] error:`, r.error);
});BulkDepositOptions
| Option | Type | Default | Description |
|------------------|-----------|---------|---------------------------------------------------------------|
| stopOnError | boolean | false | Halt on first failure and throw StableBulkError |
| idempotencyKey | string | | Batch-level dedup key |
BulkEarnResultItem
| Field | Type | Description |
|-------------------|----------|------------------------------------------|
| status | string | "fulfilled" or "rejected" |
| txHash | string | (fulfilled only) Transaction hash |
| error | string | (rejected only) Error message |
| vault | string | (optional) Vault address for this item |
| idempotencyKey | string | (optional) Item-level dedup key |
earn.bulkWithdraw(items, options?) → BulkEarnResult
Execute multiple withdrawals sequentially from one signer. Same failure-isolation semantics as bulkDeposit.
const result = await stable.earn.bulkWithdraw([
{ amount: 50 },
{ amount: 30 },
]);
console.log(`${result.succeeded} succeeded, ${result.failed} failed`);BulkWithdrawOptions
| Option | Type | Default | Description |
|------------------|-----------|---------|------------------------------------------------|
| stopOnError | boolean | false | Halt on first failure and throw StableBulkError |
| idempotencyKey | string | | Batch-level dedup key |
earn.incentiveRewards.fetch() → MerklRewardsResult
Fetch pending Merkl incentive rewards for the signer on the configured chain.
const { rewards } = await stable.earn.incentiveRewards.fetch();
rewards.forEach((r) => {
console.log(`${r.token.symbol}: ${r.amount} (${r.rawAmount} raw)`);
});MerklRewardsResult
| Field | Type | Description |
|-----------|----------------|------------------------------------------|
| chainId | number | Chain ID the rewards are from |
| rewards | MerklReward[]| Pending rewards, one entry per token |
MerklReward
| Field | Type | Description |
|-------------|--------------------|------------------------------------------------------|
| token | MerklRewardToken | Reward token info (address, symbol, decimals) |
| amount | number | Cumulative claimable amount (human-readable) |
| rawAmount | string | Cumulative claimable amount as a raw string (pass to BigInt for contract args) |
| proofs | string[] | Merkle proofs required for on-chain claiming |
Merkl updates reward distributions roughly every 2 hours.
earn.incentiveRewards.claim() → MerklClaimResult
Claim all pending Merkl rewards for the signer in a single transaction.
const { txHash, tokenCount } = await stable.earn.incentiveRewards.claim();
console.log(`Claimed ${tokenCount} reward token(s). Tx: ${txHash}`);Throws StableValidationError if there are no pending rewards to claim.
MerklClaimResult
| Field | Type | Description |
|--------------|----------|------------------------------------------|
| txHash | string | Transaction hash |
| tokenCount | number | Number of distinct reward tokens claimed |
CORS in browser environments
Merkl's API does not send Access-Control-Allow-Origin headers for all chains. When calling from a browser, proxy the Merkl API through your own server and pass the proxy URL via merklApiBase:
// In your backend — forward /merkl-proxy/* to https://api.merkl.xyz/*
const stable = createStable({
merklApiBase: "/merkl-proxy",
// ...
});With Vite, add a dev-server proxy to vite.config.ts:
server: {
proxy: {
"/merkl-proxy": {
target: "https://api.merkl.xyz",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/merkl-proxy/, ""),
},
},
},createStableReader(config) — read vault state
Read-only client for fetching position, yield, and rewards data — no wallet required.
import { createStableReader, Network, STABLE_VAULT_ADDRESS } from "@stablechain/sdk";
const reader = createStableReader({
network: Network.Mainnet,
earn: { vault: STABLE_VAULT_ADDRESS },
address: "0xYourAddress",
});
// Current vault position
const { assets, sharesFormatted, shareDecimals } = await reader.earn.position();
console.log("Position:", assets, "USDT0");
console.log("Shares:", sharesFormatted); // human-readable share balance
// APY breakdown — native yield + per-token reward boosts
const { apy, native, rewards, performanceFee } = await reader.earn.getYield();
console.log("Net APY:", (apy * 100).toFixed(2) + "%");
console.log("Native:", (native * 100).toFixed(2) + "%");
rewards.forEach((r) => console.log(`${r.symbol} boost:`, (r.apr * 100).toFixed(2) + "%"));
// Projected yield over a horizon
const { projectedYield } = await reader.earn.preview({ amount: 1000, horizonDays: 365 });
console.log("Projected yield over 1 year:", projectedYield, "USDT0");
// Check instant withdrawability
const { isInstant, availableLiquidity } = await reader.earn.withdrawability({ amount: 500 });
// Pending Merkl rewards (read-only, no signer needed)
const { rewards: incentives } = await reader.earn.incentiveRewards.fetch();StableReaderConfig
| Param | Type | Description |
|----------------|----------|----------------------------------------------------------------------|
| network | Network| Network.Mainnet (default) or Network.Testnet |
| rpc | string | (optional) RPC override |
| earn | object | (optional) { vault: "0x..." } — required for reader.earn.* |
| address | string | Address of the user whose position to read |
| merklApiBase | string | (optional) Base URL for the Merkl rewards API — see StableConfig.merklApiBase |
reader.earn methods
| Method | Returns | Description |
|------------------------------------|------------------|---------------------------------------------------------|
| getYield() | VaultYield | Current APY breakdown — native yield + per-token rewards |
| position() | VaultPosition | User's current shares and asset value |
| preview({ amount, horizonDays }) | YieldPreview | Projected yield over a given horizon using live net APY |
| withdrawability({ amount }) | WithdrawStatus | Whether the amount can be withdrawn from idle liquidity |
| incentiveRewards.fetch() | MerklRewardsResult | Pending Merkl rewards for the configured address |
VaultPosition
| Field | Type | Description |
|-------------------|----------|----------------------------------------------------|
| assets | number | Position value in USDT0 (human-readable) |
| shares | bigint | Raw vault share balance |
| sharesFormatted | number | Human-readable share balance (use this for redeem) |
| shareDecimals | number | Vault share token decimals |
| tokenDecimals | number | Underlying asset decimals |
| assetAddress | string | Underlying asset contract address |
VaultYield
| Field | Type | Description |
|------------------|---------------------|----------------------------------------------------------------------|
| apy | number | Total net APY after fees, including all rewards (e.g. 0.072 = 7.2%) |
| native | number | Base lending yield after fees, excluding reward boosts |
| rewards | VaultYieldReward[]| Per-token reward boosts (empty when no active incentive programs) |
| performanceFee | number | Performance fee as a decimal (e.g. 0.05 = 5%) |
| managementFee | number | Management fee as a decimal |
VaultYieldReward
| Field | Type | Description |
|-----------|----------|------------------------------------------------------|
| symbol | string | Reward token symbol (e.g. "MORPHO") |
| address | string | Reward token contract address |
| apr | number | APR contribution from this token (e.g. 0.02 = 2%) |
YieldPreview
| Field | Type | Description |
|------------------|----------|-----------------------------------------|
| apy | number | Current net APY (e.g. 0.065 = 6.5%) |
| projectedYield | number | Expected yield over horizonDays |
| horizonDays | number | Horizon used for the projection |
Earn is only available on Mainnet (
Network.Mainnet).
Supported chains
bridge and quoteBridge accept any of these via the Chain enum:
| Enum | Network | Chain ID | USDT decimals |
|-----------------------|------------------|-----------|---------------|
| Chain.Sepolia | Ethereum Sepolia | 11155111 | 6 |
| Chain.StableTestnet | Stable Testnet | 2201 | 6 |
| Chain.Stable | Stable Mainnet | 988 | 6 |
| Chain.Ethereum | Ethereum | 1 | 6 |
| Chain.Arbitrum | Arbitrum One | 42161 | 6 |
| Chain.Ink | Ink | 57073 | 6 |
| Chain.Bera | Berachain | 80094 | 6 |
| Chain.MegaETH | MegaETH | 4326 | 6 |
| Chain.Base | Base | 8453 | 6 |
| Chain.BSC | BSC | 56 | 18 |
| Chain.HyperEVM | HyperEVM | 999 | 6 |
| Chain.Polygon | Polygon | 137 | 6 |
| Chain.Plasma | Plasma | 9745 | 6 |
| Chain.Optimism | Optimism | 10 | 6 |
| Chain.Unichain | Unichain | 130 | 6 |
| Chain.Sei | Sei | 1329 | 6 |
| Chain.Flare | Flare | 14 | 6 |
| Chain.XLayer | X Layer | 196 | 6 |
| Chain.Rootstock | Rootstock | 30 | 6 |
Per-chain RPC and USDT contract addresses are exported as CHAIN_CONFIGS (ChainConfig type) from the package.
Errors
All thrown errors extend StableError (which extends viem's BaseError). Catch by class to branch on the failure mode.
| Class | Thrown when |
|---------------------------|------------------------------------------------------------------------------|
| StableValidationError | Invalid input (bad address, non-positive amount, unsupported chain, missing signer). Carries field and value. |
| StableQuoteError | LI.FI quote rejected (4xx) or returned an unexpected shape / chain mismatch. Carries provider, httpStatus, providerCode, body. |
| StableTransactionError | Wallet or on-chain failure. Carries phase: "switch_chain" \| "approve" \| "send" \| "receipt" \| "sign", optional txHash, chainId. |
| StableNetworkError | Transport-level failure reaching an external API (LI.FI, Merkl) after retries. |
LI.FI quote fetches retry automatically on 429 and 5xx responses with exponential backoff before surfacing.
Testnet
import { createStable, Network } from "@stablechain/sdk";
import { custom } from "viem";
const stable = createStable({
network: Network.Testnet,
transport: custom(window.ethereum),
});Examples
Everything lives in examples/core. From the repo root:
cd examples/core
bun install
# React + Vite app — transfer, bridge (with quote), swap, and earn
# (deposit / withdraw / redeem / bulk deposit / incentive rewards)
bun dev
# Zero-setup Node script — the ERC-3009 sign → verify → settle flow
bun run erc3009