@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 +135 -19
- package/dist/index.cjs +310 -98
- package/dist/index.d.cts +320 -10
- package/dist/index.d.ts +320 -10
- package/dist/index.js +290 -78
- package/package.json +1 -1
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)
|
|
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
|
|
231
|
-
|
|
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
|
-
|
|
458
|
+
`estimateBridgeTime` reports that breakdown:
|
|
356
459
|
|
|
357
460
|
| Field | Meaning |
|
|
358
461
|
|-------------------|--------------------------------------------------------------------------------------------------|
|
|
359
|
-
| `seconds` |
|
|
360
|
-
| `source` | `"
|
|
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
|
-
`
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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,
|
|
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
|
-
|
|
|
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
|
-
|
|
|
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`
|