@stablechain/sdk 1.0.4 → 1.0.6

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 ADDED
@@ -0,0 +1,878 @@
1
+ <div align="center">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="../../.github/assets/stable-logotype-light.svg">
4
+ <img alt="Stable" src="../../.github/assets/stable-logotype-dark.svg" width="96" height="96">
5
+ </picture>
6
+
7
+ <h1>@stablechain/sdk</h1>
8
+
9
+ <p><strong>TypeScript SDK for <a href="https://stable.xyz">Stable</a> — a USDT-native EVM blockchain.</strong></p>
10
+ </div>
11
+
12
+ Transfer, bridge, swap, and earn yield on Stable from a browser wallet or a
13
+ server-side key. For gasless relaying and premium blockspace, see
14
+ [`@stablechain/enterprise`](../enterprise).
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ bun add @stablechain/sdk viem
20
+ # or
21
+ npm install @stablechain/sdk viem
22
+ ```
23
+
24
+ `viem` is a peer dependency (>= 2.0.0).
25
+
26
+ ## Quick start
27
+
28
+ **Server-side (private key):**
29
+
30
+ ```ts
31
+ import { createStable, Network } from "@stablechain/sdk";
32
+ import { privateKeyToAccount } from "viem/accounts";
33
+
34
+ const stable = createStable({
35
+ network: Network.Mainnet,
36
+ account: privateKeyToAccount("0x..."),
37
+ });
38
+ ```
39
+
40
+ **Browser wallet (MetaMask, etc.):**
41
+
42
+ ```ts
43
+ import { createStable, Network } from "@stablechain/sdk";
44
+ import { custom } from "viem";
45
+
46
+ const stable = createStable({
47
+ network: Network.Mainnet,
48
+ transport: custom(window.ethereum),
49
+ });
50
+ ```
51
+
52
+ **Pre-built viem `WalletClient`:**
53
+
54
+ ```ts
55
+ const stable = createStable({
56
+ network: Network.Mainnet,
57
+ walletClient: myWalletClient,
58
+ });
59
+ ```
60
+
61
+ ### `StableConfig`
62
+
63
+ | Param | Type | Description |
64
+ |-----------------|----------------|----------------------------------------------------------------------|
65
+ | `network` | `Network` | `Network.Mainnet` (default) or `Network.Testnet` |
66
+ | `rpc` | `string` | *(optional)* RPC override for the Stable chain |
67
+ | `account` | `Account` | *(optional)* viem `Account` for server-side signing |
68
+ | `transport` | `Transport` | *(optional)* viem `Transport` (e.g. `custom(window.ethereum)`) |
69
+ | `walletClient` | `WalletClient` | *(optional)* Pre-built viem `WalletClient` — takes precedence |
70
+ | `earn` | `EarnConfig` | *(optional)* `{ vault: "0x..." }` — required for `stable.earn.*` |
71
+ | `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`) |
72
+
73
+ ---
74
+
75
+ ## API
76
+
77
+ ```ts
78
+ interface StableClient {
79
+ transfer(params: TransferParams): Promise<OperationResult>;
80
+ quoteBridge(params: BridgeParams): Promise<BridgeQuote>;
81
+ bridge(params: BridgeParams): Promise<OperationResult>;
82
+ estimateBridgeTime(params: Pick<BridgeParams, "fromChain" | "toChain"> & Partial<Pick<BridgeParams, "fromToken" | "toToken">>): Promise<BridgeTimeEstimate>;
83
+ trackBridge(srcTxHash: string): Promise<BridgeStatus>;
84
+ quoteSwap(params: SwapParams): Promise<SwapQuote>;
85
+ swap(params: SwapParams): Promise<OperationResult>;
86
+ authorization: { /* ERC-3009 — see below */ };
87
+ earn: {
88
+ deposit(params: EarnDepositParams): Promise<EarnResult>;
89
+ bulkDeposit(items: EarnDepositParams[], options?: BulkDepositOptions): Promise<BulkEarnResult>;
90
+ withdraw(params: EarnWithdrawParams): Promise<EarnResult>;
91
+ bulkWithdraw(items: EarnWithdrawParams[], options?: BulkWithdrawOptions): Promise<BulkEarnResult>;
92
+ redeem(params: EarnRedeemParams): Promise<EarnResult>;
93
+ incentiveRewards: {
94
+ fetch(): Promise<MerklRewardsResult>;
95
+ claim(): Promise<MerklClaimResult>;
96
+ };
97
+ };
98
+ }
99
+ ```
100
+
101
+ All write methods resolve to `OperationResult` or `EarnResult`:
102
+
103
+ ```ts
104
+ interface OperationResult {
105
+ txHash: string;
106
+ toAmount?: number; // destination-side amount, when a conversion occurred
107
+ }
108
+
109
+ interface EarnResult {
110
+ txHash: string;
111
+ idempotencyKey?: string;
112
+ }
113
+ ```
114
+
115
+ ---
116
+
117
+ ### `transfer(params)`
118
+
119
+ Send tokens on Stable. Native USDT0 transfers use 18 decimals; ERC-20 decimals are fetched on-chain when not provided.
120
+
121
+ **Native USDT0**
122
+
123
+ ```ts
124
+ const { txHash } = await stable.transfer({
125
+ to: "0xRecipient",
126
+ amount: 10, // 10 USDT0
127
+ });
128
+ ```
129
+
130
+ **ERC-20 (USDC, EURC, …)**
131
+
132
+ ```ts
133
+ const { txHash } = await stable.transfer({
134
+ to: "0xRecipient",
135
+ amount: 100,
136
+ token: "0xUSDCAddress", // ERC-20 contract address on Stable
137
+ tokenDecimals: 6, // optional — fetched on-chain if omitted
138
+ });
139
+ ```
140
+
141
+ | Param | Type | Description |
142
+ |-----------------|----------|-----------------------------------------------------------------------------------|
143
+ | `to` | `string` | Recipient address |
144
+ | `amount` | `number` | Amount in human-readable units |
145
+ | `token` | `string` | *(optional)* ERC-20 contract address. Omit for native USDT0. |
146
+ | `tokenDecimals` | `number` | *(optional)* Token decimals — fetched on-chain if not provided |
147
+ | `from` | `string` | *(optional, advisory)* Sender label. The signer is taken from `walletClient`/`account`; this field does **not** override it. |
148
+
149
+ `transfer` switches the wallet to the configured Stable network, sends the tx, waits for the receipt, and throws a `StableTransactionError` if it reverts.
150
+
151
+ ---
152
+
153
+ ### `quoteBridge(params)` → `BridgeQuote`
154
+
155
+ Preview a bridge before sending. Quotes are fetched from [LI.FI](https://li.fi).
156
+
157
+ ```ts
158
+ import { Chain } from "@stablechain/sdk";
159
+
160
+ const quote = await stable.quoteBridge({
161
+ fromChain: Chain.Ethereum,
162
+ toChain: Chain.Stable,
163
+ fromToken: "0xdAC17F958D2ee523a2206206994597C13D831ec7", // USDT on Ethereum
164
+ toToken: "0x779Ded0c9e1022225f8E0630b35a9b54bE713736", // USDT0 on Stable
165
+ amount: 100,
166
+ });
167
+
168
+ console.log("You receive:", quote.toAmount); // human units, on toChain
169
+ ```
170
+
171
+ `BridgeQuote.toAmount` is the expected destination-side amount in human units.
172
+
173
+ On native OFT routes the quote is fully costed — `toAmount` comes from the adapter's
174
+ own `quoteOFT`, so it is the amount that actually lands, net of any OFT fee and of
175
+ dust dropped by decimal conversion:
176
+
177
+ | Field | Type | What it is |
178
+ |----------------|---------------------|-------------------------------------------------------------------------------|
179
+ | `toAmount` | `number` | What the recipient receives, human units |
180
+ | `nativeFee` | `bigint` | LayerZero messaging fee (wei), paid as the `send` call's `value` |
181
+ | `lzTokenFee` | `bigint` | Same fee if paid in LZ token instead — `0n` on every route the SDK sends |
182
+ | `oft` | `BridgeOftQuote` | The adapter's own accounting: `amountSentLD`, `amountReceivedLD`, `fees[]`, and the pathway's `minAmountLD`/`maxAmountLD` |
183
+ | `srcGas` | `BridgeGasEstimate` | Source-chain execution cost: `gas`, `gasPriceWei`, `costWei`, `source` |
184
+ | `totalCostWei` | `bigint` | `srcGas.costWei + nativeFee` — everything leaving the sender's native balance |
185
+ | `estimate` | `BridgeTimeEstimate`| Delivery time — see [`estimateBridgeTime`](#estimatebridgetimeparams--bridgetimeestimate) |
186
+
187
+ ```ts
188
+ import { formatEther } from "viem";
189
+
190
+ console.log("You receive:", quote.toAmount);
191
+ for (const fee of quote.oft?.fees ?? []) {
192
+ // Empty on every pathway the SDK configures today; a deployment that charges an
193
+ // OFT-level fee reports it here. Negative means a credit to the sender.
194
+ console.log(`${fee.description}: ${fee.amount}`);
195
+ }
196
+ if (quote.totalCostWei) {
197
+ console.log(`Costs ${formatEther(quote.totalCostWei)} of native token to send`);
198
+ }
199
+ if (quote.estimate) {
200
+ console.log(`Arrives in ~${Math.round(quote.estimate.seconds / 60)} min`);
201
+ }
202
+ ```
203
+
204
+ `srcGas.source` says how much to trust the cost: `"simulated"` means the real `send`
205
+ was simulated, `"fallback"` means the simulation reverted — which is what happens when
206
+ you quote before approving the adapter — and `gas` is a typical-send constant instead.
207
+ Every field above is absent on LI.FI routes, and `oft`/`srcGas` are also absent when
208
+ the adapter or the RPC would not answer, so treat them as optional rather than assuming
209
+ a native route populates them.
210
+
211
+ Both `quoteBridge()` and `bridge()` accept `dstNativeDrop` — wei of the **destination**
212
+ chain's native token to airdrop to the recipient, what bridge UIs call *gas on
213
+ destination*. The executor prices it into `nativeFee`, so quote with the same value you
214
+ send with:
215
+
216
+ ```ts
217
+ const quote = await stable.quoteBridge({ ...params, dstNativeDrop: 10n ** 14n });
218
+ await stable.bridge({ ...params, dstNativeDrop: 10n ** 14n, quote });
219
+ ```
220
+
221
+ Each pathway's executor caps the drop it will accept; asking for more makes the quote
222
+ throw `StableQuoteError` rather than silently clamping.
223
+
224
+ ---
225
+
226
+ ### `bridge(params)`
227
+
228
+ Bridge tokens cross-chain, over the native LayerZero OFT mesh where the pair supports
229
+ it and LI.FI otherwise — see [Bridge routes](#bridge-routes). Handles ERC-20 approval
230
+ automatically (and skips it where the OFT does not need one). Switches the wallet to
231
+ the source chain (MetaMask will prompt) before sending.
232
+
233
+ ```ts
234
+ import { Chain } from "@stablechain/sdk";
235
+
236
+ const { txHash, toAmount } = await stable.bridge({
237
+ fromChain: Chain.Ethereum,
238
+ toChain: Chain.Stable,
239
+ fromToken: "0xdAC17F958D2ee523a2206206994597C13D831ec7",
240
+ toToken: "0x779Ded0c9e1022225f8E0630b35a9b54bE713736",
241
+ amount: 100,
242
+ });
243
+ ```
244
+
245
+ | Param | Type | Description |
246
+ |----------------|---------------|--------------------------------------------------------------------------|
247
+ | `fromChain` | `Chain` | Source chain |
248
+ | `toChain` | `Chain` | Destination chain |
249
+ | `fromToken` | `string` | Source token contract address |
250
+ | `toToken` | `string` | Destination token contract address |
251
+ | `amount` | `number` | Amount to bridge (human-readable) |
252
+ | `fromDecimals` | `number` | *(optional)* Source token decimals — defaults to `6` |
253
+ | `recipient` | `string` | *(optional)* Destination address — defaults to the signer |
254
+ | `dstNativeDrop`| `bigint` | *(optional)* Destination native token (wei) to airdrop to the recipient — native OFT routes only |
255
+ | `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 |
256
+ | `route` | `BridgeRoute` | *(optional)* `"auto"` (default), `"oft"`, or `"lifi"` — see [Bridge routes](#bridge-routes) |
257
+
258
+ `txHash` is the **source-chain** transaction hash. Destination delivery is asynchronous —
259
+ poll [`trackBridge(txHash)`](#trackbridgesrctxhash--bridgestatus) to follow it.
260
+
261
+ ---
262
+
263
+ ### Bridge routes
264
+
265
+ `bridge()` and `quoteBridge()` pick between two paths:
266
+
267
+ | Route | What it is | Cost | Speed |
268
+ |----------|-------------------------------------|---------------------------------|--------|
269
+ | `"oft"` | The token's native LayerZero OFT | No protocol fee — gas + LZ messaging fee | Slower |
270
+ | `"lifi"` | The [LI.FI](https://li.fi) aggregator | Charges a fee | Fast |
271
+
272
+ `route` defaults to `"auto"`: the OFT path when both chains deploy the asset the token
273
+ addresses name, LI.FI otherwise. Forcing `"oft"` on an unsupported pair throws
274
+ `StableValidationError` rather than silently charging a LI.FI fee.
275
+
276
+ > `route: "usdt0"` is a deprecated alias for `"oft"`. It does not restrict the route to
277
+ > USDT0 — the token addresses decide the asset.
278
+
279
+ #### Native OFT assets
280
+
281
+ Two assets have their own OFT mesh, each with its own adapter and token address per
282
+ chain. A bridge stays within one mesh: the OFT mints its own peer on the far side, so it
283
+ cannot hand back a different token. A cross-asset request (USDT0 in, XAUt0 out) is a
284
+ trade, not a bridge, and `"auto"` sends it to LI.FI.
285
+
286
+ **USDT0** — Stable, Ethereum, Arbitrum, Bera, Flare, HyperEVM, Ink, MegaETH, Optimism,
287
+ Plasma, Polygon, Rootstock, Sei, Unichain, XLayer.
288
+
289
+ **XAUt0** (Tether Gold, 6 decimals) — Stable, Ethereum, Arbitrum, BSC, HyperEVM, Ink,
290
+ Plasma, Polygon. Every pair among those eight is wired in both directions.
291
+
292
+ ```ts
293
+ // XAUt0: Ethereum → Stable, over the native OFT mesh
294
+ const { txHash } = await stable.bridge({
295
+ fromChain: Chain.Ethereum,
296
+ toChain: Chain.Stable,
297
+ fromToken: "0x68749665FF8D2d112Fa859AA293F07A622782F38", // XAUt on Ethereum
298
+ toToken: "0xB8CE59FC3717ada4C02eaDF9682A9e934F625ebb", // XAUt0 on Stable
299
+ amount: 2, // 2 XAUt0
300
+ });
301
+ ```
302
+
303
+ Ethereum is XAUt0's lockbox — canonical XAUt is escrowed there and minted elsewhere, so
304
+ it is the only chain where a send needs an ERC-20 approve. Bridging XAUt0 from any other
305
+ chain burns from the caller and skips the approval transaction entirely.
306
+
307
+ BSC is XAUt0-only: it has no USDT0 deployment, so a BSC USDT bridge routes to LI.FI.
308
+
309
+ Addresses per chain are in `BRIDGE_CHAIN_CONFIGS[chain].tokens`, keyed by asset:
310
+
311
+ ```ts
312
+ import { BRIDGE_CHAIN_CONFIGS, bridgeAssetFor, Chain } from "@stablechain/sdk";
313
+
314
+ const stableChain = BRIDGE_CHAIN_CONFIGS[Chain.Stable];
315
+ stableChain.tokens.xaut0?.address; // XAUt0 ERC-20 on Stable
316
+ stableChain.tokens.xaut0?.oftAdapter; // its OFT
317
+
318
+ // Which asset an address refers to on a chain, if any
319
+ bridgeAssetFor(stableChain, someToken); // "usdt0" | "xaut0" | undefined
320
+ ```
321
+
322
+ ---
323
+
324
+ ### `estimateBridgeTime(params)` → `BridgeTimeEstimate`
325
+
326
+ How long a **native OFT (LayerZero)** bridge will take, before you send it. Throws for
327
+ chain pairs with no OFT adapter — LI.FI routes carry their own ETA in the quote.
328
+
329
+ ```ts
330
+ const est = await stable.estimateBridgeTime({
331
+ fromChain: Chain.Stable,
332
+ toChain: Chain.Arbitrum,
333
+ });
334
+
335
+ console.log(`~${Math.round(est.seconds / 60)} min`); // ~42 min
336
+ ```
337
+
338
+ Delivery time is per pathway, and USDT0 and XAUt0 are different pathways on the same
339
+ chain pair. Omitting the tokens estimates the USDT0 pathway; pass `fromToken`/`toToken`
340
+ to estimate a specific asset:
341
+
342
+ ```ts
343
+ const est = await stable.estimateBridgeTime({
344
+ fromChain: Chain.Ethereum,
345
+ toChain: Chain.Stable,
346
+ fromToken: "0x68749665FF8D2d112Fa859AA293F07A622782F38", // XAUt on Ethereum
347
+ toToken: "0xB8CE59FC3717ada4C02eaDF9682A9e934F625ebb", // XAUt0 on Stable
348
+ });
349
+ ```
350
+
351
+ Nearly all of the wait is **source-chain finality**: the DVNs securing the pathway will
352
+ not attest until the send transaction is buried under `confirmations` source blocks.
353
+ Destination execution is a few blocks on top.
354
+
355
+ The SDK resolves this from two independent inputs and reports both:
356
+
357
+ | Field | Meaning |
358
+ |-------------------|--------------------------------------------------------------------------------------------------|
359
+ | `seconds` | Best available estimate — display this |
360
+ | `source` | `"observed"` or `"formula"`, whichever produced `seconds` |
361
+ | `observedSeconds` | Median delivery time of recent messages on this exact pathway, from LayerZero Scan |
362
+ | `formulaSeconds` | LayerZero's documented estimate: `srcBlockTime × confirmations + dstBlockTime × (2 + dvnCount)` |
363
+ | `confirmations` | Source blocks the DVNs wait for, read live from the pathway's ULN config |
364
+ | `dvnCount` | DVNs verifying the pathway |
365
+
366
+ `observed` wins when available. The formula assumes block confirmations are the whole
367
+ wait, which holds for L1 sources but understates rollups badly — Arbitrum→Stable computes
368
+ to ~19 s (60 blocks × 0.25 s) against a real median of ~18 min, because the DVNs wait for
369
+ the L2 batch to finalize on Ethereum.
370
+
371
+ Pathway configs and observed medians are cached for 5 minutes; call
372
+ `clearBridgeEstimateCache()` to force a refetch.
373
+
374
+ Roughly what to expect on mainnet, measured 2026-07-30:
375
+
376
+ | Pathway | Confirmations | Typical delivery |
377
+ |---------------------|---------------|------------------|
378
+ | Polygon → Stable | 32 | ~1 min |
379
+ | Bera → Stable | 60 | ~2 min |
380
+ | Unichain → Stable | 1600 | ~8 min |
381
+ | Flare → Stable | 500 | ~11 min |
382
+ | Optimism → Stable | 1000 | ~15 min |
383
+ | Ethereum → Stable | 65 | ~17 min |
384
+ | Sei → Stable | 2000 | ~17 min |
385
+ | Arbitrum → Stable | 60 | ~18 min |
386
+ | Plasma → Stable | 1800 | ~30 min |
387
+ | Stable → any | 3600 | ~42 min |
388
+ | Rootstock → Stable | 120 | ~62 min |
389
+ | MegaETH → Stable | 5400 | ~90 min |
390
+ | X Layer → Stable | 9000 | ~2.5 h |
391
+ | HyperEVM → Stable | 43200 | ~12 h |
392
+
393
+ ---
394
+
395
+ ### `trackBridge(srcTxHash)` → `BridgeStatus`
396
+
397
+ Follow an in-flight native OFT bridge, via LayerZero Scan. A message Scan has not indexed yet
398
+ reports `"pending"` rather than throwing, so it is safe to call immediately after `bridge()`.
399
+
400
+ ```ts
401
+ const { txHash } = await stable.bridge({ /* … */ });
402
+
403
+ const status = await stable.trackBridge(txHash);
404
+ // { state: "inflight", elapsedSeconds: 1027, scanUrl: "https://layerzeroscan.com/tx/0x…" }
405
+
406
+ if (status.state === "delivered") console.log("Landed:", status.dstTxHash);
407
+ ```
408
+
409
+ `state` is one of `"pending" | "inflight" | "delivered" | "failed"`. `raw` carries the
410
+ underlying LayerZero Scan status when you need the exact value.
411
+
412
+ ---
413
+
414
+ ### `quoteSwap(params)` → `SwapQuote`
415
+
416
+ Fetch a LI.FI swap quote on Stable without sending a transaction.
417
+
418
+ ```ts
419
+ const quote = await stable.quoteSwap({
420
+ fromToken: "0x8a2B28364102Bea189D99A475C494330Ef2bDD0B", // e.g. USDCe
421
+ toToken: "0x779Ded0c9e1022225f8E0630b35a9b54bE713736", // USDT0
422
+ amount: 100,
423
+ fromDecimals: 6,
424
+ });
425
+
426
+ console.log("You'll receive:", quote.toAmount, "USDT0");
427
+ ```
428
+
429
+ ```ts
430
+ interface SwapQuote {
431
+ toAmount: number; // human-readable, formatted with toToken decimals
432
+ fromAmount: bigint; // raw source-side amount
433
+ fromToken: string; // ERC-20 to approve (or native sentinel)
434
+ approvalAddress?: string; // spender for ERC-20 approval (omitted for native)
435
+ transactionRequest: {
436
+ to: string;
437
+ data: string;
438
+ value: string;
439
+ gasLimit: string;
440
+ chainId: number;
441
+ };
442
+ }
443
+ ```
444
+
445
+ ---
446
+
447
+ ### `swap(params)`
448
+
449
+ Swap tokens on Stable via [LI.FI](https://li.fi). Handles ERC-20 approval automatically; MetaMask is asked to switch to Stable if needed.
450
+
451
+ Pass a pre-fetched `quote` from `quoteSwap()` to skip the LI.FI quote call.
452
+
453
+ ```ts
454
+ const quote = await stable.quoteSwap({ fromToken, toToken, amount: 100, fromDecimals: 6 });
455
+ const { txHash, toAmount } = await stable.swap({
456
+ fromToken,
457
+ toToken,
458
+ amount: 100,
459
+ fromDecimals: 6,
460
+ quote,
461
+ });
462
+ ```
463
+
464
+ | Param | Type | Default | Description |
465
+ |----------------|-------------|---------|---------------------------------------------------|
466
+ | `fromToken` | `string` | | Token address on Stable |
467
+ | `toToken` | `string` | | Token address on Stable |
468
+ | `amount` | `number` | | Human-readable amount |
469
+ | `fromDecimals` | `number` | `6` | Source token decimals |
470
+ | `toAddress` | `string` | | *(optional)* Recipient — defaults to signer |
471
+ | `quote` | `SwapQuote` | | *(optional)* Pre-fetched quote — skips LI.FI call |
472
+
473
+ ---
474
+
475
+ ### `authorization.*` — ERC-3009 gasless transfers
476
+
477
+ [ERC-3009](https://docs.stable.xyz/en/explanation/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.
478
+
479
+ 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.
480
+
481
+ ```ts
482
+ // Isolated subpath — pulls in zero bridge/earn/swap code.
483
+ import { signTransferAuthorization, erc3009Actions } from "@stablechain/sdk/erc3009";
484
+
485
+ const signed = await signTransferAuthorization(walletClient, { to, value, validBefore });
486
+ // or: const client = walletClient.extend(erc3009Actions);
487
+ ```
488
+
489
+ > 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.
490
+
491
+ ```ts
492
+ // Payer side — sign offline, hand the result off over HTTP / QR / x402
493
+ const signed = await stable.authorization.signTransferAuthorization({
494
+ to: "0xVendor",
495
+ value: 1_250_000n, // raw units — 1.25 USDT0 (6 dp)
496
+ validBefore: BigInt(Math.floor(Date.now() / 1000) + 3600),
497
+ });
498
+
499
+ // Submitter side — screen it gas-free, then settle (or hand off to a relayer)
500
+ const check = await stable.authorization.verifyAuthorization({ signed });
501
+ if (check.ok) await stable.authorization.transferWithAuthorization({ signed });
502
+ ```
503
+
504
+ | Method | Returns | Description |
505
+ |------------------------------------------------|-----------------------------|-----------------------------------------------------------------------------|
506
+ | `signTransferAuthorization(p)` | `SignedAuthorization` | Sign a portable `TransferWithAuthorization` (offline, gas-free) |
507
+ | `signReceiveAuthorization(p)` | `SignedAuthorization` | Same, but only the recipient (`to`) can submit it |
508
+ | `transferWithAuthorization(p)` | `Hash` | Submit a signed (or freshly-signed) authorization — anyone may submit |
509
+ | `receiveWithAuthorization(p)` | `Hash` | Submit as the recipient (enforced on-chain via `msg.sender`) |
510
+ | `cancelAuthorization(p)` | `Hash` | Revoke an unused nonce (best-effort — can race an in-flight settle) |
511
+ | `prepareTransferWithAuthorization(p)` | `PreparedAuthorizationCall` | Sign + return `{ to, data, value, chainId, signed }` for a relayer to submit |
512
+ | `verifyAuthorization(p)` | `VerifyAuthorizationResult` | Gas-free preflight: signer, nonce state, time window, solvency, `eth_call` |
513
+ | `getAuthorizationState(p)` | `boolean` | `true` once the nonce is used or cancelled |
514
+ | `getAuthorizationUsage(p)` | `AuthorizationUsage` | Resolve the consuming tx from the `AuthorizationUsed` event (no indexer) |
515
+
516
+ `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.
517
+
518
+ To relay gaslessly, pass `prepareTransferWithAuthorization`'s output to the [`@stablechain/enterprise`](../enterprise) gas-waiver rail.
519
+
520
+ > **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.
521
+
522
+ ---
523
+
524
+ ### `earn.deposit(params)` → `EarnResult`
525
+
526
+ Deposit USDT0 into the Stable vault to earn yield, powered by [Morpho](https://morpho.org). Handles ERC-20 approval and permit signatures automatically.
527
+
528
+ ```ts
529
+ import { createStable, Network, STABLE_VAULT_ADDRESS } from "@stablechain/sdk";
530
+
531
+ const stable = createStable({
532
+ network: Network.Mainnet,
533
+ earn: { vault: STABLE_VAULT_ADDRESS },
534
+ walletClient: myWalletClient,
535
+ });
536
+
537
+ const { txHash } = await stable.earn.deposit({ amount: 100 }); // 100 USDT0
538
+ ```
539
+
540
+ | Param | Type | Description |
541
+ |---------------------|----------|-------------------------------------------------------------------|
542
+ | `amount` | `number` | Amount in human-readable units |
543
+ | `vault` | `string` | *(optional)* Vault address — overrides the one set in config |
544
+ | `tokenDecimals` | `number` | *(optional)* Token decimals — fetched on-chain if omitted |
545
+ | `slippageTolerance` | `number` | *(optional)* Max slippage as a decimal (e.g. `0.01` = 1%) |
546
+ | `idempotencyKey` | `string` | *(optional)* Dedup key — concurrent calls with the same key return the same Promise |
547
+ | `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`. |
548
+
549
+ ---
550
+
551
+ ### `earn.withdraw(params)` / `earn.redeem(params)` → `EarnResult`
552
+
553
+ Exit the vault by asset amount (`withdraw`) or by share count (`redeem`).
554
+
555
+ ```ts
556
+ // Withdraw by asset amount — most common
557
+ const { txHash } = await stable.earn.withdraw({ amount: 100 });
558
+
559
+ // Redeem by share count — use reader.earn.position() to get sharesFormatted
560
+ const { sharesFormatted } = await reader.earn.position();
561
+ const { txHash } = await stable.earn.redeem({ shares: sharesFormatted });
562
+ ```
563
+
564
+ > **Shares ≠ assets.** The exchange rate increases over time as yield accrues. Use `withdraw` when you want a specific USDT0 amount back; use `redeem` when you want to exit by share count (e.g. close 100% of a position). Use `reader.earn.position()` to get the human-readable share balance (`sharesFormatted`).
565
+
566
+ When idle vault liquidity is insufficient, `withdraw` and `redeem` automatically pull liquidity from the underlying market adapters. No extra configuration needed.
567
+
568
+ ---
569
+
570
+ ### `earn.bulkDeposit(items, options?)` → `BulkEarnResult`
571
+
572
+ 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).
573
+
574
+ ```ts
575
+ import { type BulkEarnResult } from "@stablechain/sdk";
576
+
577
+ const result: BulkEarnResult = await stable.earn.bulkDeposit([
578
+ { amount: 50, vault: "0xVaultA" },
579
+ { amount: 30, vault: "0xVaultB" },
580
+ { amount: 20 }, // falls back to the vault set in config
581
+ ]);
582
+
583
+ console.log(`${result.succeeded} succeeded, ${result.failed} failed`);
584
+ result.results.forEach((r, i) => {
585
+ if (r.status === "fulfilled") console.log(`#${i + 1} [${r.vault}] txHash:`, r.txHash);
586
+ else console.error(`#${i + 1} [${r.vault}] error:`, r.error);
587
+ });
588
+ ```
589
+
590
+ **`BulkDepositOptions`**
591
+
592
+ | Option | Type | Default | Description |
593
+ |------------------|-----------|---------|---------------------------------------------------------------|
594
+ | `stopOnError` | `boolean` | `false` | Halt on first failure and throw `StableBulkError` |
595
+ | `idempotencyKey` | `string` | | Batch-level dedup key |
596
+
597
+ **`BulkEarnResultItem`**
598
+
599
+ | Field | Type | Description |
600
+ |-------------------|----------|------------------------------------------|
601
+ | `status` | `string` | `"fulfilled"` or `"rejected"` |
602
+ | `txHash` | `string` | *(fulfilled only)* Transaction hash |
603
+ | `error` | `string` | *(rejected only)* Error message |
604
+ | `vault` | `string` | *(optional)* Vault address for this item |
605
+ | `idempotencyKey` | `string` | *(optional)* Item-level dedup key |
606
+
607
+ ---
608
+
609
+ ### `earn.bulkWithdraw(items, options?)` → `BulkEarnResult`
610
+
611
+ Execute multiple withdrawals sequentially from one signer. Same failure-isolation semantics as `bulkDeposit`.
612
+
613
+ ```ts
614
+ const result = await stable.earn.bulkWithdraw([
615
+ { amount: 50 },
616
+ { amount: 30 },
617
+ ]);
618
+
619
+ console.log(`${result.succeeded} succeeded, ${result.failed} failed`);
620
+ ```
621
+
622
+ **`BulkWithdrawOptions`**
623
+
624
+ | Option | Type | Default | Description |
625
+ |------------------|-----------|---------|------------------------------------------------|
626
+ | `stopOnError` | `boolean` | `false` | Halt on first failure and throw `StableBulkError` |
627
+ | `idempotencyKey` | `string` | | Batch-level dedup key |
628
+
629
+ ---
630
+
631
+ ### `earn.incentiveRewards.fetch()` → `MerklRewardsResult`
632
+
633
+ Fetch pending [Merkl](https://merkl.xyz) incentive rewards for the signer on the configured chain.
634
+
635
+ ```ts
636
+ const { rewards } = await stable.earn.incentiveRewards.fetch();
637
+
638
+ rewards.forEach((r) => {
639
+ console.log(`${r.token.symbol}: ${r.amount} (${r.rawAmount} raw)`);
640
+ });
641
+ ```
642
+
643
+ **`MerklRewardsResult`**
644
+
645
+ | Field | Type | Description |
646
+ |-----------|----------------|------------------------------------------|
647
+ | `chainId` | `number` | Chain ID the rewards are from |
648
+ | `rewards` | `MerklReward[]`| Pending rewards, one entry per token |
649
+
650
+ **`MerklReward`**
651
+
652
+ | Field | Type | Description |
653
+ |-------------|--------------------|------------------------------------------------------|
654
+ | `token` | `MerklRewardToken` | Reward token info (`address`, `symbol`, `decimals`) |
655
+ | `amount` | `number` | Cumulative claimable amount (human-readable) |
656
+ | `rawAmount` | `string` | Cumulative claimable amount as a raw string (pass to `BigInt` for contract args) |
657
+ | `proofs` | `string[]` | Merkle proofs required for on-chain claiming |
658
+
659
+ > Merkl updates reward distributions roughly every 2 hours.
660
+
661
+ ---
662
+
663
+ ### `earn.incentiveRewards.claim()` → `MerklClaimResult`
664
+
665
+ Claim all pending Merkl rewards for the signer in a single transaction.
666
+
667
+ ```ts
668
+ const { txHash, tokenCount } = await stable.earn.incentiveRewards.claim();
669
+ console.log(`Claimed ${tokenCount} reward token(s). Tx: ${txHash}`);
670
+ ```
671
+
672
+ Throws `StableValidationError` if there are no pending rewards to claim.
673
+
674
+ **`MerklClaimResult`**
675
+
676
+ | Field | Type | Description |
677
+ |--------------|----------|------------------------------------------|
678
+ | `txHash` | `string` | Transaction hash |
679
+ | `tokenCount` | `number` | Number of distinct reward tokens claimed |
680
+
681
+ #### CORS in browser environments
682
+
683
+ 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`:
684
+
685
+ ```ts
686
+ // In your backend — forward /merkl-proxy/* to https://api.merkl.xyz/*
687
+ const stable = createStable({
688
+ merklApiBase: "/merkl-proxy",
689
+ // ...
690
+ });
691
+ ```
692
+
693
+ With Vite, add a dev-server proxy to `vite.config.ts`:
694
+
695
+ ```ts
696
+ server: {
697
+ proxy: {
698
+ "/merkl-proxy": {
699
+ target: "https://api.merkl.xyz",
700
+ changeOrigin: true,
701
+ rewrite: (path) => path.replace(/^\/merkl-proxy/, ""),
702
+ },
703
+ },
704
+ },
705
+ ```
706
+
707
+ ---
708
+
709
+ ### `createStableReader(config)` — read vault state
710
+
711
+ Read-only client for fetching position, yield, and rewards data — no wallet required.
712
+
713
+ ```ts
714
+ import { createStableReader, Network, STABLE_VAULT_ADDRESS } from "@stablechain/sdk";
715
+
716
+ const reader = createStableReader({
717
+ network: Network.Mainnet,
718
+ earn: { vault: STABLE_VAULT_ADDRESS },
719
+ address: "0xYourAddress",
720
+ });
721
+
722
+ // Current vault position
723
+ const { assets, sharesFormatted, shareDecimals } = await reader.earn.position();
724
+ console.log("Position:", assets, "USDT0");
725
+ console.log("Shares:", sharesFormatted); // human-readable share balance
726
+
727
+ // APY breakdown — native yield + per-token reward boosts
728
+ const { apy, native, rewards, performanceFee } = await reader.earn.getYield();
729
+ console.log("Net APY:", (apy * 100).toFixed(2) + "%");
730
+ console.log("Native:", (native * 100).toFixed(2) + "%");
731
+ rewards.forEach((r) => console.log(`${r.symbol} boost:`, (r.apr * 100).toFixed(2) + "%"));
732
+
733
+ // Projected yield over a horizon
734
+ const { projectedYield } = await reader.earn.preview({ amount: 1000, horizonDays: 365 });
735
+ console.log("Projected yield over 1 year:", projectedYield, "USDT0");
736
+
737
+ // Check instant withdrawability
738
+ const { isInstant, availableLiquidity } = await reader.earn.withdrawability({ amount: 500 });
739
+
740
+ // Pending Merkl rewards (read-only, no signer needed)
741
+ const { rewards: incentives } = await reader.earn.incentiveRewards.fetch();
742
+ ```
743
+
744
+ **`StableReaderConfig`**
745
+
746
+ | Param | Type | Description |
747
+ |----------------|----------|----------------------------------------------------------------------|
748
+ | `network` | `Network`| `Network.Mainnet` (default) or `Network.Testnet` |
749
+ | `rpc` | `string` | *(optional)* RPC override |
750
+ | `earn` | `object` | *(optional)* `{ vault: "0x..." }` — required for `reader.earn.*` |
751
+ | `address` | `string` | Address of the user whose position to read |
752
+ | `merklApiBase` | `string` | *(optional)* Base URL for the Merkl rewards API — see `StableConfig.merklApiBase` |
753
+
754
+ **`reader.earn` methods**
755
+
756
+ | Method | Returns | Description |
757
+ |------------------------------------|------------------|---------------------------------------------------------|
758
+ | `getYield()` | `VaultYield` | Current APY breakdown — native yield + per-token rewards |
759
+ | `position()` | `VaultPosition` | User's current shares and asset value |
760
+ | `preview({ amount, horizonDays })` | `YieldPreview` | Projected yield over a given horizon using live net APY |
761
+ | `withdrawability({ amount })` | `WithdrawStatus` | Whether the amount can be withdrawn from idle liquidity |
762
+ | `incentiveRewards.fetch()` | `MerklRewardsResult` | Pending Merkl rewards for the configured address |
763
+
764
+ **`VaultPosition`**
765
+
766
+ | Field | Type | Description |
767
+ |-------------------|----------|----------------------------------------------------|
768
+ | `assets` | `number` | Position value in USDT0 (human-readable) |
769
+ | `shares` | `bigint` | Raw vault share balance |
770
+ | `sharesFormatted` | `number` | Human-readable share balance (use this for `redeem`) |
771
+ | `shareDecimals` | `number` | Vault share token decimals |
772
+ | `tokenDecimals` | `number` | Underlying asset decimals |
773
+ | `assetAddress` | `string` | Underlying asset contract address |
774
+
775
+ **`VaultYield`**
776
+
777
+ | Field | Type | Description |
778
+ |------------------|---------------------|----------------------------------------------------------------------|
779
+ | `apy` | `number` | Total net APY after fees, including all rewards (e.g. `0.072` = 7.2%) |
780
+ | `native` | `number` | Base lending yield after fees, excluding reward boosts |
781
+ | `rewards` | `VaultYieldReward[]`| Per-token reward boosts (empty when no active incentive programs) |
782
+ | `performanceFee` | `number` | Performance fee as a decimal (e.g. `0.05` = 5%) |
783
+ | `managementFee` | `number` | Management fee as a decimal |
784
+
785
+ **`VaultYieldReward`**
786
+
787
+ | Field | Type | Description |
788
+ |-----------|----------|------------------------------------------------------|
789
+ | `symbol` | `string` | Reward token symbol (e.g. `"MORPHO"`) |
790
+ | `address` | `string` | Reward token contract address |
791
+ | `apr` | `number` | APR contribution from this token (e.g. `0.02` = 2%) |
792
+
793
+ **`YieldPreview`**
794
+
795
+ | Field | Type | Description |
796
+ |------------------|----------|-----------------------------------------|
797
+ | `apy` | `number` | Current net APY (e.g. `0.065` = 6.5%) |
798
+ | `projectedYield` | `number` | Expected yield over `horizonDays` |
799
+ | `horizonDays` | `number` | Horizon used for the projection |
800
+
801
+ > Earn is only available on Mainnet (`Network.Mainnet`).
802
+
803
+ ---
804
+
805
+ ## Supported chains
806
+
807
+ `bridge` and `quoteBridge` accept any of these via the `Chain` enum:
808
+
809
+ | Enum | Network | Chain ID | USDT decimals |
810
+ |-----------------------|------------------|-----------|---------------|
811
+ | `Chain.Sepolia` | Ethereum Sepolia | 11155111 | 6 |
812
+ | `Chain.StableTestnet` | Stable Testnet | 2201 | 6 |
813
+ | `Chain.Stable` | Stable Mainnet | 988 | 6 |
814
+ | `Chain.Ethereum` | Ethereum | 1 | 6 |
815
+ | `Chain.Arbitrum` | Arbitrum One | 42161 | 6 |
816
+ | `Chain.Ink` | Ink | 57073 | 6 |
817
+ | `Chain.Bera` | Berachain | 80094 | 6 |
818
+ | `Chain.MegaETH` | MegaETH | 4326 | 6 |
819
+ | `Chain.Base` | Base | 8453 | 6 |
820
+ | `Chain.BSC` | BSC | 56 | 18 |
821
+ | `Chain.HyperEVM` | HyperEVM | 999 | 6 |
822
+ | `Chain.Polygon` | Polygon | 137 | 6 |
823
+ | `Chain.Plasma` | Plasma | 9745 | 6 |
824
+ | `Chain.Optimism` | Optimism | 10 | 6 |
825
+ | `Chain.Unichain` | Unichain | 130 | 6 |
826
+ | `Chain.Sei` | Sei | 1329 | 6 |
827
+ | `Chain.Flare` | Flare | 14 | 6 |
828
+ | `Chain.XLayer` | X Layer | 196 | 6 |
829
+ | `Chain.Rootstock` | Rootstock | 30 | 6 |
830
+
831
+ Per-chain RPC and USDT contract addresses are exported as `CHAIN_CONFIGS` (`ChainConfig` type) from the package.
832
+
833
+ ---
834
+
835
+ ## Errors
836
+
837
+ All thrown errors extend `StableError` (which extends viem's `BaseError`). Catch by class to branch on the failure mode.
838
+
839
+ | Class | Thrown when |
840
+ |---------------------------|------------------------------------------------------------------------------|
841
+ | `StableValidationError` | Invalid input (bad address, non-positive amount, unsupported chain, missing signer). Carries `field` and `value`. |
842
+ | `StableQuoteError` | LI.FI quote rejected (4xx) or returned an unexpected shape / chain mismatch. Carries `provider`, `httpStatus`, `providerCode`, `body`. |
843
+ | `StableTransactionError` | Wallet or on-chain failure. Carries `phase: "switch_chain" \| "approve" \| "send" \| "receipt" \| "sign"`, optional `txHash`, `chainId`. |
844
+ | `StableNetworkError` | Transport-level failure reaching an external API (LI.FI, Merkl) after retries. |
845
+
846
+ LI.FI quote fetches retry automatically on 429 and 5xx responses with exponential backoff before surfacing.
847
+
848
+ ---
849
+
850
+ ## Testnet
851
+
852
+ ```ts
853
+ import { createStable, Network } from "@stablechain/sdk";
854
+ import { custom } from "viem";
855
+
856
+ const stable = createStable({
857
+ network: Network.Testnet,
858
+ transport: custom(window.ethereum),
859
+ });
860
+ ```
861
+
862
+ ---
863
+
864
+ ## Examples
865
+
866
+ Everything lives in [`examples/core`](../../examples/core). From the repo root:
867
+
868
+ ```bash
869
+ cd examples/core
870
+ bun install
871
+
872
+ # React + Vite app — transfer, bridge (with quote), swap, and earn
873
+ # (deposit / withdraw / redeem / bulk deposit / incentive rewards)
874
+ bun dev
875
+
876
+ # Zero-setup Node script — the ERC-3009 sign → verify → settle flow
877
+ bun run erc3009
878
+ ```