@stablechain/sdk 1.0.6 → 1.0.8

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 CHANGED
@@ -64,6 +64,7 @@ const stable = createStable({
64
64
  |-----------------|----------------|----------------------------------------------------------------------|
65
65
  | `network` | `Network` | `Network.Mainnet` (default) or `Network.Testnet` |
66
66
  | `rpc` | `string` | *(optional)* RPC override for the Stable chain |
67
+ | `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 |
67
68
  | `account` | `Account` | *(optional)* viem `Account` for server-side signing |
68
69
  | `transport` | `Transport` | *(optional)* viem `Transport` (e.g. `custom(window.ethereum)`) |
69
70
  | `walletClient` | `WalletClient` | *(optional)* Pre-built viem `WalletClient` — takes precedence |
@@ -79,6 +80,7 @@ interface StableClient {
79
80
  transfer(params: TransferParams): Promise<OperationResult>;
80
81
  quoteBridge(params: BridgeParams): Promise<BridgeQuote>;
81
82
  bridge(params: BridgeParams): Promise<OperationResult>;
83
+ prepareBridgeCalldata(params: PrepareBridgeParams): Promise<BridgeCalldata>;
82
84
  estimateBridgeTime(params: Pick<BridgeParams, "fromChain" | "toChain"> & Partial<Pick<BridgeParams, "fromToken" | "toToken">>): Promise<BridgeTimeEstimate>;
83
85
  trackBridge(srcTxHash: string): Promise<BridgeStatus>;
84
86
  quoteSwap(params: SwapParams): Promise<SwapQuote>;
@@ -177,7 +179,8 @@ dust dropped by decimal conversion:
177
179
  | Field | Type | What it is |
178
180
  |----------------|---------------------|-------------------------------------------------------------------------------|
179
181
  | `toAmount` | `number` | What the recipient receives, human units |
180
- | `nativeFee` | `bigint` | LayerZero messaging fee (wei), paid as the `send` call's `value` |
182
+ | `nativeFee` | `bigint` | LayerZero messaging fee (wei) as quoted — the number to show a user |
183
+ | `nativeFeeMax` | `bigint` | What a send built from this quote carries as its `value`: `nativeFee` padded by `feeBufferBps`. Check native balances against this |
181
184
  | `lzTokenFee` | `bigint` | Same fee if paid in LZ token instead — `0n` on every route the SDK sends |
182
185
  | `oft` | `BridgeOftQuote` | The adapter's own accounting: `amountSentLD`, `amountReceivedLD`, `fees[]`, and the pathway's `minAmountLD`/`maxAmountLD` |
183
186
  | `srcGas` | `BridgeGasEstimate` | Source-chain execution cost: `gas`, `gasPriceWei`, `costWei`, `source` |
@@ -221,14 +224,41 @@ await stable.bridge({ ...params, dstNativeDrop: 10n ** 14n, quote });
221
224
  Each pathway's executor caps the drop it will accept; asking for more makes the quote
222
225
  throw `StableQuoteError` rather than silently clamping.
223
226
 
227
+ #### The messaging fee is padded before sending
228
+
229
+ A send has to carry the fee the pathway costs **at the moment it lands**, and `send`
230
+ demands an exact match: `msg.value` must equal `_fee.nativeFee`, the endpoint reverts
231
+ with `LZ_InsufficientFee` when that falls short of what it needs, and refunds the
232
+ excess to the sender when it does not. Gas moves between a quote and a signature, and
233
+ only one direction of that costs anything — a fee that rose reverts the send with the
234
+ gas already spent, while a fee that was padded comes back.
235
+
236
+ So `bridge()` and `prepareBridgeCalldata()` send `nativeFee` padded by `feeBufferBps`,
237
+ 5% by default:
238
+
239
+ ```ts
240
+ await stable.bridge({ ...params, feeBufferBps: 1_000 }); // 10%
241
+ await stable.bridge({ ...params, feeBufferBps: 0 }); // exactly the quoted fee
242
+ ```
243
+
244
+ `quoteBridge()` reports both numbers and pads neither: `nativeFee` is the honest quote
245
+ to display, `nativeFeeMax` is what will actually leave the wallet.
246
+
224
247
  ---
225
248
 
226
249
  ### `bridge(params)`
227
250
 
228
251
  Bridge tokens cross-chain, over the native LayerZero OFT mesh where the pair supports
229
252
  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.
253
+ automatically. Switches the wallet to the source chain (MetaMask will prompt) before
254
+ sending.
255
+
256
+ Only a lockbox pathway needs an allowance at all — Ethereum for USDT0 and XAUt0, and
257
+ Sepolia on testnet. Every other deployment is an OFT that burns from the caller, so no
258
+ approve and no allowance read is issued there. Where one is needed and the current
259
+ allowance is short but not zero, it takes **two** approvals: legacy USDT refuses to
260
+ overwrite a non-zero allowance with another non-zero value, so the SDK sends
261
+ `approve(0)` first.
232
262
 
233
263
  ```ts
234
264
  import { Chain } from "@stablechain/sdk";
@@ -249,9 +279,11 @@ const { txHash, toAmount } = await stable.bridge({
249
279
  | `fromToken` | `string` | Source token contract address |
250
280
  | `toToken` | `string` | Destination token contract address |
251
281
  | `amount` | `number` | Amount to bridge (human-readable) |
252
- | `fromDecimals` | `number` | *(optional)* Source token decimals — defaults to `6` |
282
+ | `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 |
253
283
  | `recipient` | `string` | *(optional)* Destination address — defaults to the signer |
254
284
  | `dstNativeDrop`| `bigint` | *(optional)* Destination native token (wei) to airdrop to the recipient — native OFT routes only |
285
+ | `feeBufferBps` | `number` | *(optional)* Padding on the quoted messaging fee, in basis points — defaults to `500` (5%). Native OFT routes only |
286
+ | `refundAddress`| `string` | *(optional)* Where LayerZero returns messaging fee it did not need — defaults to the sender. Native OFT routes only |
255
287
  | `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
288
  | `route` | `BridgeRoute` | *(optional)* `"auto"` (default), `"oft"`, or `"lifi"` — see [Bridge routes](#bridge-routes) |
257
289
 
@@ -260,6 +292,77 @@ poll [`trackBridge(txHash)`](#trackbridgesrctxhash--bridgestatus) to follow it.
260
292
 
261
293
  ---
262
294
 
295
+ ### `prepareBridgeCalldata(params)` → `BridgeCalldata`
296
+
297
+ Build a bridge's transactions without signing or sending any of them — for the split
298
+ where a backend quotes, builds and checks while the holder's wallet only signs. Native
299
+ OFT routes only: a pair that resolves to LI.FI throws, because LI.FI hands back its own
300
+ transaction request from its API.
301
+
302
+ ```ts
303
+ import { Chain } from "@stablechain/sdk";
304
+
305
+ const built = await stable.prepareBridgeCalldata({
306
+ from: "0xUserWallet", // whose allowance decides the approvals
307
+ fromChain: Chain.Ethereum,
308
+ toChain: Chain.Stable,
309
+ fromToken: "0xdAC17F958D2ee523a2206206994597C13D831ec7",
310
+ toToken: "0x779Ded0c9e1022225f8E0630b35a9b54bE713736",
311
+ amount: 100,
312
+ });
313
+
314
+ for (const step of built.steps) {
315
+ // Send in order, each confirmed before the next.
316
+ await walletClient.sendTransaction(step);
317
+ }
318
+ ```
319
+
320
+ Takes everything [`bridge()`](#bridgeparams) does, plus:
321
+
322
+ | Param | Type | Description |
323
+ |-----------------|----------|-----------------------------------------------------------------------------------|
324
+ | `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 |
325
+
326
+ `steps` is ordered and must be sent in order, each confirmed before the next:
327
+
328
+ | Step | When |
329
+ |-------------|---------------------------------------------------------------------------------------|
330
+ | `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 |
331
+ | `approve(n)`| The pathway needs an allowance and the current one is short |
332
+ | `send` | Always. Carries the buffered messaging fee as its `value` |
333
+
334
+ Each step is `{ chainId, to, data, value }`, and `chainId` is the source chain on every
335
+ one of them. Alongside them:
336
+
337
+ | Field | Type | What it is |
338
+ |-----------|-----------------|---------------------------------------------------------------------|
339
+ | `chainId` | `number` | The source chain every step must be sent on |
340
+ | `asset` | `"usdt0" \| "xaut0"` | Which OFT mesh this send moves through |
341
+ | `quote` | `BridgeQuote` | The quote the steps were built from — supplied or freshly read |
342
+ | `send` | `object` | The `send` step's arguments decoded: `dstEid`, `recipient`, `to`, `amountLD`, `minAmountLD`, `extraOptions`, `nativeFee`, `lzTokenFee`, `refundAddress` |
343
+
344
+ `send` is there so a backend can check what it built without re-deriving the ABI
345
+ encoding — and `OFT_ADAPTER_ABI` and `buildLzOptions` are exported for the checks that
346
+ do want the encoding:
347
+
348
+ ```ts
349
+ import { OFT_ADAPTER_ABI } from "@stablechain/sdk";
350
+ import { decodeFunctionData } from "viem";
351
+
352
+ const { args } = decodeFunctionData({
353
+ abi: OFT_ADAPTER_ABI,
354
+ data: built.steps.at(-1)!.data as `0x${string}`,
355
+ });
356
+ ```
357
+
358
+ This refuses to build when the adapter will not answer `quoteOFT`: its receipt is what
359
+ floors the send at the amount it would actually deliver, and there is no safe stand-in
360
+ for that floor — the full input amount reverts with `SlippageExceeded` on any pathway
361
+ that charges a fee. `quoteBridge()` still degrades to a fee-only quote in that case;
362
+ only the paths that build a send refuse.
363
+
364
+ ---
365
+
263
366
  ### Bridge routes
264
367
 
265
368
  `bridge()` and `quoteBridge()` pick between two paths:
@@ -352,44 +455,57 @@ Nearly all of the wait is **source-chain finality**: the DVNs securing the pathw
352
455
  not attest until the send transaction is buried under `confirmations` source blocks.
353
456
  Destination execution is a few blocks on top.
354
457
 
355
- The SDK resolves this from two independent inputs and reports both:
458
+ `estimateBridgeTime` reports that breakdown:
356
459
 
357
460
  | Field | Meaning |
358
461
  |-------------------|--------------------------------------------------------------------------------------------------|
359
- | `seconds` | Best available estimate — display this |
360
- | `source` | `"observed"` or `"formula"`, whichever produced `seconds` |
462
+ | `seconds` | The estimate — display this |
463
+ | `source` | `"formula"`, `"floor"`, or `"observed"` — which input produced `seconds` |
361
464
  | `observedSeconds` | Median delivery time of recent messages on this exact pathway, from LayerZero Scan |
362
465
  | `formulaSeconds` | LayerZero's documented estimate: `srcBlockTime × confirmations + dstBlockTime × (2 + dvnCount)` |
363
466
  | `confirmations` | Source blocks the DVNs wait for, read live from the pathway's ULN config |
364
467
  | `dvnCount` | DVNs verifying the pathway |
365
468
 
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.
469
+ `seconds` comes from the formula — the pathway's live ULN config is what the DVNs are
470
+ actually bound by, so a config change is reflected the moment it lands. On every pathway
471
+ that is purely confirmation-bound it agrees with both usdt0.to's own estimate and the
472
+ delivery times LayerZero Scan reports to well under a percent.
473
+
474
+ Some source chains carry a floor. DVN attestation and destination execution sit outside the
475
+ formula, and on Ethereum and Arbitrum the DVNs additionally hold until the send finalizes on
476
+ the beacon chain. Where confirmations are high that overhead vanishes into the rounding;
477
+ where they are low it dominates — Arbitrum's 60 confirmations compute to ~19 s against a real
478
+ ~18 min. Those chains are floored at a measured minimum, and `source` reports `"floor"`.
479
+
480
+ `observedSeconds` is fetched alongside and reported for comparison, but does not drive
481
+ `seconds`: a 50-message window can reach back months, so on a pathway that has been
482
+ reconfigured or has gone quiet it answers from a config that no longer exists.
370
483
 
371
484
  Pathway configs and observed medians are cached for 5 minutes; call
372
485
  `clearBridgeEstimateCache()` to force a refetch.
373
486
 
374
- Roughly what to expect on mainnet, measured 2026-07-30:
487
+ Roughly what to expect on mainnet, from configs read 2026-08-27:
375
488
 
376
489
  | Pathway | Confirmations | Typical delivery |
377
490
  |---------------------|---------------|------------------|
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 |
491
+ | Polygon → Stable | 32 | ~1 min ᶠ |
492
+ | Bera → Stable | 60 | ~2 min ᶠ |
493
+ | Flare → Stable | 500 | ~10 min ᶠ |
384
494
  | Sei → Stable | 2000 | ~17 min |
385
- | Arbitrum → Stable | 60 | ~18 min |
495
+ | Ethereum → Stable | 65 | ~19.5 min ᶠ |
496
+ | Arbitrum → Stable | 60 | ~22.5 min ᶠ |
497
+ | Unichain → Stable | 1600 | ~27 min |
386
498
  | Plasma → Stable | 1800 | ~30 min |
499
+ | Optimism → Stable | 1000 | ~33 min |
387
500
  | Stable → any | 3600 | ~42 min |
388
501
  | Rootstock → Stable | 120 | ~62 min |
389
502
  | MegaETH → Stable | 5400 | ~90 min |
390
503
  | X Layer → Stable | 9000 | ~2.5 h |
391
504
  | HyperEVM → Stable | 43200 | ~12 h |
392
505
 
506
+ ᶠ Floored at the source chain's measured minimum, because its confirmation count is not the
507
+ whole wait — Ethereum and Arbitrum would otherwise compute to ~13 min and ~19 s.
508
+
393
509
  ---
394
510
 
395
511
  ### `trackBridge(srcTxHash)` → `BridgeStatus`