@stablechain/sdk 1.0.5 → 1.0.7

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,7 +80,8 @@ interface StableClient {
79
80
  transfer(params: TransferParams): Promise<OperationResult>;
80
81
  quoteBridge(params: BridgeParams): Promise<BridgeQuote>;
81
82
  bridge(params: BridgeParams): Promise<OperationResult>;
82
- estimateBridgeTime(params: Pick<BridgeParams, "fromChain" | "toChain">): Promise<BridgeTimeEstimate>;
83
+ prepareBridgeCalldata(params: PrepareBridgeParams): Promise<BridgeCalldata>;
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>;
85
87
  swap(params: SwapParams): Promise<OperationResult>;
@@ -170,20 +172,93 @@ console.log("You receive:", quote.toAmount); // human units, on toChain
170
172
 
171
173
  `BridgeQuote.toAmount` is the expected destination-side amount in human units.
172
174
 
173
- On USDT0 (LayerZero OFT) routes the quote also carries `estimate` — how long delivery
174
- should take. See [`estimateBridgeTime`](#estimatebridgetimeparams--bridgetimeestimate).
175
+ On native OFT routes the quote is fully costed — `toAmount` comes from the adapter's
176
+ own `quoteOFT`, so it is the amount that actually lands, net of any OFT fee and of
177
+ dust dropped by decimal conversion:
178
+
179
+ | Field | Type | What it is |
180
+ |----------------|---------------------|-------------------------------------------------------------------------------|
181
+ | `toAmount` | `number` | What the recipient receives, human units |
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 |
184
+ | `lzTokenFee` | `bigint` | Same fee if paid in LZ token instead — `0n` on every route the SDK sends |
185
+ | `oft` | `BridgeOftQuote` | The adapter's own accounting: `amountSentLD`, `amountReceivedLD`, `fees[]`, and the pathway's `minAmountLD`/`maxAmountLD` |
186
+ | `srcGas` | `BridgeGasEstimate` | Source-chain execution cost: `gas`, `gasPriceWei`, `costWei`, `source` |
187
+ | `totalCostWei` | `bigint` | `srcGas.costWei + nativeFee` — everything leaving the sender's native balance |
188
+ | `estimate` | `BridgeTimeEstimate`| Delivery time — see [`estimateBridgeTime`](#estimatebridgetimeparams--bridgetimeestimate) |
175
189
 
176
190
  ```ts
191
+ import { formatEther } from "viem";
192
+
193
+ console.log("You receive:", quote.toAmount);
194
+ for (const fee of quote.oft?.fees ?? []) {
195
+ // Empty on every pathway the SDK configures today; a deployment that charges an
196
+ // OFT-level fee reports it here. Negative means a credit to the sender.
197
+ console.log(`${fee.description}: ${fee.amount}`);
198
+ }
199
+ if (quote.totalCostWei) {
200
+ console.log(`Costs ${formatEther(quote.totalCostWei)} of native token to send`);
201
+ }
177
202
  if (quote.estimate) {
178
203
  console.log(`Arrives in ~${Math.round(quote.estimate.seconds / 60)} min`);
179
204
  }
180
205
  ```
181
206
 
207
+ `srcGas.source` says how much to trust the cost: `"simulated"` means the real `send`
208
+ was simulated, `"fallback"` means the simulation reverted — which is what happens when
209
+ you quote before approving the adapter — and `gas` is a typical-send constant instead.
210
+ Every field above is absent on LI.FI routes, and `oft`/`srcGas` are also absent when
211
+ the adapter or the RPC would not answer, so treat them as optional rather than assuming
212
+ a native route populates them.
213
+
214
+ Both `quoteBridge()` and `bridge()` accept `dstNativeDrop` — wei of the **destination**
215
+ chain's native token to airdrop to the recipient, what bridge UIs call *gas on
216
+ destination*. The executor prices it into `nativeFee`, so quote with the same value you
217
+ send with:
218
+
219
+ ```ts
220
+ const quote = await stable.quoteBridge({ ...params, dstNativeDrop: 10n ** 14n });
221
+ await stable.bridge({ ...params, dstNativeDrop: 10n ** 14n, quote });
222
+ ```
223
+
224
+ Each pathway's executor caps the drop it will accept; asking for more makes the quote
225
+ throw `StableQuoteError` rather than silently clamping.
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
+
182
247
  ---
183
248
 
184
249
  ### `bridge(params)`
185
250
 
186
- Bridge tokens cross-chain via LI.FI. Handles ERC-20 approval automatically. Switches the wallet to the source chain (MetaMask will prompt) before sending.
251
+ Bridge tokens cross-chain, over the native LayerZero OFT mesh where the pair supports
252
+ it and LI.FI otherwise — see [Bridge routes](#bridge-routes). Handles ERC-20 approval
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.
187
262
 
188
263
  ```ts
189
264
  import { Chain } from "@stablechain/sdk";
@@ -204,19 +279,155 @@ const { txHash, toAmount } = await stable.bridge({
204
279
  | `fromToken` | `string` | Source token contract address |
205
280
  | `toToken` | `string` | Destination token contract address |
206
281
  | `amount` | `number` | Amount to bridge (human-readable) |
207
- | `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 |
208
283
  | `recipient` | `string` | *(optional)* Destination address — defaults to the signer |
209
- | `quote` | `BridgeQuote` | *(optional)* Reserved for future use; the current implementation re-fetches a LI.FI quote regardless |
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 |
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 |
288
+ | `route` | `BridgeRoute` | *(optional)* `"auto"` (default), `"oft"`, or `"lifi"` — see [Bridge routes](#bridge-routes) |
210
289
 
211
290
  `txHash` is the **source-chain** transaction hash. Destination delivery is asynchronous —
212
291
  poll [`trackBridge(txHash)`](#trackbridgesrctxhash--bridgestatus) to follow it.
213
292
 
214
293
  ---
215
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
+
366
+ ### Bridge routes
367
+
368
+ `bridge()` and `quoteBridge()` pick between two paths:
369
+
370
+ | Route | What it is | Cost | Speed |
371
+ |----------|-------------------------------------|---------------------------------|--------|
372
+ | `"oft"` | The token's native LayerZero OFT | No protocol fee — gas + LZ messaging fee | Slower |
373
+ | `"lifi"` | The [LI.FI](https://li.fi) aggregator | Charges a fee | Fast |
374
+
375
+ `route` defaults to `"auto"`: the OFT path when both chains deploy the asset the token
376
+ addresses name, LI.FI otherwise. Forcing `"oft"` on an unsupported pair throws
377
+ `StableValidationError` rather than silently charging a LI.FI fee.
378
+
379
+ > `route: "usdt0"` is a deprecated alias for `"oft"`. It does not restrict the route to
380
+ > USDT0 — the token addresses decide the asset.
381
+
382
+ #### Native OFT assets
383
+
384
+ Two assets have their own OFT mesh, each with its own adapter and token address per
385
+ chain. A bridge stays within one mesh: the OFT mints its own peer on the far side, so it
386
+ cannot hand back a different token. A cross-asset request (USDT0 in, XAUt0 out) is a
387
+ trade, not a bridge, and `"auto"` sends it to LI.FI.
388
+
389
+ **USDT0** — Stable, Ethereum, Arbitrum, Bera, Flare, HyperEVM, Ink, MegaETH, Optimism,
390
+ Plasma, Polygon, Rootstock, Sei, Unichain, XLayer.
391
+
392
+ **XAUt0** (Tether Gold, 6 decimals) — Stable, Ethereum, Arbitrum, BSC, HyperEVM, Ink,
393
+ Plasma, Polygon. Every pair among those eight is wired in both directions.
394
+
395
+ ```ts
396
+ // XAUt0: Ethereum → Stable, over the native OFT mesh
397
+ const { txHash } = await stable.bridge({
398
+ fromChain: Chain.Ethereum,
399
+ toChain: Chain.Stable,
400
+ fromToken: "0x68749665FF8D2d112Fa859AA293F07A622782F38", // XAUt on Ethereum
401
+ toToken: "0xB8CE59FC3717ada4C02eaDF9682A9e934F625ebb", // XAUt0 on Stable
402
+ amount: 2, // 2 XAUt0
403
+ });
404
+ ```
405
+
406
+ Ethereum is XAUt0's lockbox — canonical XAUt is escrowed there and minted elsewhere, so
407
+ it is the only chain where a send needs an ERC-20 approve. Bridging XAUt0 from any other
408
+ chain burns from the caller and skips the approval transaction entirely.
409
+
410
+ BSC is XAUt0-only: it has no USDT0 deployment, so a BSC USDT bridge routes to LI.FI.
411
+
412
+ Addresses per chain are in `BRIDGE_CHAIN_CONFIGS[chain].tokens`, keyed by asset:
413
+
414
+ ```ts
415
+ import { BRIDGE_CHAIN_CONFIGS, bridgeAssetFor, Chain } from "@stablechain/sdk";
416
+
417
+ const stableChain = BRIDGE_CHAIN_CONFIGS[Chain.Stable];
418
+ stableChain.tokens.xaut0?.address; // XAUt0 ERC-20 on Stable
419
+ stableChain.tokens.xaut0?.oftAdapter; // its OFT
420
+
421
+ // Which asset an address refers to on a chain, if any
422
+ bridgeAssetFor(stableChain, someToken); // "usdt0" | "xaut0" | undefined
423
+ ```
424
+
425
+ ---
426
+
216
427
  ### `estimateBridgeTime(params)` → `BridgeTimeEstimate`
217
428
 
218
- How long a **USDT0 (LayerZero OFT)** bridge will take, before you send it. Throws for
219
- chain pairs with no USDT0 adapter — LI.FI routes carry their own ETA in the quote.
429
+ How long a **native OFT (LayerZero)** bridge will take, before you send it. Throws for
430
+ chain pairs with no OFT adapter — LI.FI routes carry their own ETA in the quote.
220
431
 
221
432
  ```ts
222
433
  const est = await stable.estimateBridgeTime({
@@ -227,6 +438,19 @@ const est = await stable.estimateBridgeTime({
227
438
  console.log(`~${Math.round(est.seconds / 60)} min`); // ~42 min
228
439
  ```
229
440
 
441
+ Delivery time is per pathway, and USDT0 and XAUt0 are different pathways on the same
442
+ chain pair. Omitting the tokens estimates the USDT0 pathway; pass `fromToken`/`toToken`
443
+ to estimate a specific asset:
444
+
445
+ ```ts
446
+ const est = await stable.estimateBridgeTime({
447
+ fromChain: Chain.Ethereum,
448
+ toChain: Chain.Stable,
449
+ fromToken: "0x68749665FF8D2d112Fa859AA293F07A622782F38", // XAUt on Ethereum
450
+ toToken: "0xB8CE59FC3717ada4C02eaDF9682A9e934F625ebb", // XAUt0 on Stable
451
+ });
452
+ ```
453
+
230
454
  Nearly all of the wait is **source-chain finality**: the DVNs securing the pathway will
231
455
  not attest until the send transaction is buried under `confirmations` source blocks.
232
456
  Destination execution is a few blocks on top.
@@ -273,7 +497,7 @@ Roughly what to expect on mainnet, measured 2026-07-30:
273
497
 
274
498
  ### `trackBridge(srcTxHash)` → `BridgeStatus`
275
499
 
276
- Follow an in-flight USDT0 bridge, via LayerZero Scan. A message Scan has not indexed yet
500
+ Follow an in-flight native OFT bridge, via LayerZero Scan. A message Scan has not indexed yet
277
501
  reports `"pending"` rather than throwing, so it is safe to call immediately after `bridge()`.
278
502
 
279
503
  ```ts