@stablechain/sdk 1.0.6 → 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,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: