@owney/sdk 0.7.28-beta.3 → 0.7.28-beta.5

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/dist/index.cjs CHANGED
@@ -2884,6 +2884,33 @@ function swapChain(chainId) {
2884
2884
  return CHAINS[chainId];
2885
2885
  }
2886
2886
 
2887
+ // src/lib/swap/swap.gas-check.ts
2888
+ var CLASSIC_SWAP_GAS = 300000n;
2889
+ var APPROVE_GAS2 = 60000n;
2890
+ var isNative = (address) => address.toLowerCase().startsWith("0xeeee");
2891
+ async function swapGasShortfall(deps, quote) {
2892
+ if (quote.rail !== "classic" || isNative(quote.src.address)) return null;
2893
+ const gas = CLASSIC_SWAP_GAS + APPROVE_GAS2;
2894
+ const [balance, gasPrice] = await Promise.all([
2895
+ deps.readNativeBalance(),
2896
+ deps.getMaxFeePerGas()
2897
+ ]);
2898
+ const required = gas * gasPrice;
2899
+ return balance < required ? { chainId: quote.src.chainId, balance, required } : null;
2900
+ }
2901
+ function swapGasError(shortfall, nativeSymbol) {
2902
+ return new OwneyError(
2903
+ "SWAP_INSUFFICIENT_GAS",
2904
+ `This swap needs a little ${nativeSymbol} for network fees, and your wallet has too little. Add some ${nativeSymbol}, or choose a different asset to receive.`,
2905
+ {
2906
+ chainId: shortfall.chainId,
2907
+ symbol: nativeSymbol,
2908
+ balance: shortfall.balance.toString(),
2909
+ required: shortfall.required.toString()
2910
+ }
2911
+ );
2912
+ }
2913
+
2887
2914
  // src/lib/swap/swap.arrival.ts
2888
2915
  var DEFAULT_TIMEOUT_MS = 18e4;
2889
2916
  var DEFAULT_POLL_MS = 4e3;
@@ -3074,6 +3101,10 @@ var DEFAULT_POLL_MS2 = 5e3;
3074
3101
  var MAX_BACKOFF_MS = 3e4;
3075
3102
  var backoffFor = (failures, base4) => Math.min(base4 * 2 ** Math.min(failures, 5), MAX_BACKOFF_MS);
3076
3103
  var DEFAULT_TIMEOUT_MS2 = 15 * 60 * 1e3;
3104
+ var NOT_FOUND_GRACE_MS = 2 * 60 * 1e3;
3105
+ function isNotFound(error) {
3106
+ return error instanceof OwneyError && error.details?.statusCode === 404;
3107
+ }
3077
3108
  async function runFusionOrder(deps, options) {
3078
3109
  const {
3079
3110
  orderHash,
@@ -3084,6 +3115,7 @@ async function runFusionOrder(deps, options) {
3084
3115
  } = options;
3085
3116
  const deadline = deps.now() + timeoutMs;
3086
3117
  let failures = 0;
3118
+ let notFoundSince;
3087
3119
  const published = /* @__PURE__ */ new Set();
3088
3120
  onStage?.("swapping");
3089
3121
  for (; ; ) {
@@ -3125,7 +3157,20 @@ async function runFusionOrder(deps, options) {
3125
3157
  try {
3126
3158
  ({ status } = await deps.orderStatus(orderHash));
3127
3159
  failures = 0;
3128
- } catch {
3160
+ notFoundSince = void 0;
3161
+ } catch (error) {
3162
+ if (isNotFound(error)) {
3163
+ notFoundSince ??= deps.now();
3164
+ if (deps.now() - notFoundSince >= NOT_FOUND_GRACE_MS) {
3165
+ throw new OwneyError(
3166
+ "SWAP_ORDER_NOT_FOUND",
3167
+ "The swap provider has no record of this order, so nothing was exchanged.",
3168
+ { orderHash }
3169
+ );
3170
+ }
3171
+ } else {
3172
+ notFoundSince = void 0;
3173
+ }
3129
3174
  failures += 1;
3130
3175
  await deps.sleep(backoffFor(failures, pollIntervalMs));
3131
3176
  continue;
@@ -3183,9 +3228,9 @@ async function runSameChainOrder(deps, options) {
3183
3228
  for (; ; ) {
3184
3229
  if (deps.now() >= deadline) {
3185
3230
  throw new OwneyError(
3186
- "SWAP_ORDER_EXPIRED",
3187
- "No one filled the swap in time. Nothing was exchanged and your funds are still in your wallet.",
3188
- { orderHash }
3231
+ "SWAP_REQUEST_FAILED",
3232
+ "Timed out waiting for the swap to fill. It may still complete \u2014 if it does, the swapped funds will be in your wallet. Check before retrying.",
3233
+ { orderHash, settlementUnconfirmed: true }
3189
3234
  );
3190
3235
  }
3191
3236
  let status;
@@ -3382,8 +3427,8 @@ async function runClassic(deps, options) {
3382
3427
  onStage?.("quoting");
3383
3428
  debugLog("owney-sdk", "swap: fetching classic calldata");
3384
3429
  let { tx } = await deps.api.swapTx(swapTxRequest);
3385
- const isNative = BigInt(tx.value ?? "0") > 0n;
3386
- if (!isNative) {
3430
+ const isNative2 = BigInt(tx.value ?? "0") > 0n;
3431
+ if (!isNative2) {
3387
3432
  const needed = amount;
3388
3433
  const current = await deps.readAllowance(tx.to);
3389
3434
  debugLog("owney-sdk", "swap: allowance", {
@@ -3533,10 +3578,16 @@ async function runFusion(deps, options, walletAddress) {
3533
3578
  debugLog("owney-sdk", "swap: awaiting signature in wallet", {
3534
3579
  signingOnChain: quote.src.chainId
3535
3580
  });
3536
- const signature = await awaitWalletPrompt(
3537
- () => deps.signTypedData(built.typedData),
3538
- "sign the swap"
3539
- );
3581
+ let signature;
3582
+ try {
3583
+ signature = await awaitWalletPrompt(
3584
+ () => deps.signTypedData(built.typedData),
3585
+ "sign the swap"
3586
+ );
3587
+ } catch (error) {
3588
+ clearOrder(built.orderHash);
3589
+ throw error;
3590
+ }
3540
3591
  debugLog("owney-sdk", "swap: signed, submitting to relayer");
3541
3592
  await deps.api.submitOrder({
3542
3593
  srcChainId: quote.src.chainId,
@@ -3564,7 +3615,7 @@ async function runFusion(deps, options, walletAddress) {
3564
3615
  ...onStage ? { onStage } : {}
3565
3616
  });
3566
3617
  } catch (error) {
3567
- if (error instanceof OwneyError && (error.code === "SWAP_ORDER_REFUNDED" || error.code === "SWAP_ORDER_CANCELLED")) {
3618
+ if (error instanceof OwneyError && (error.code === "SWAP_ORDER_REFUNDED" || error.code === "SWAP_ORDER_CANCELLED" || error.code === "SWAP_ORDER_NOT_FOUND")) {
3568
3619
  clearOrder(built.orderHash);
3569
3620
  }
3570
3621
  throw error;
@@ -4443,6 +4494,15 @@ function makeSponsoredCallsCallback(deps) {
4443
4494
 
4444
4495
  // src/client.ts
4445
4496
  var WALLET_CATCHUP_TIMEOUT_MS = 3e4;
4497
+ function shortfallBalance(error, asked) {
4498
+ if (!(error instanceof OwneyError)) return void 0;
4499
+ if (error.code !== "DEPOSIT_INSUFFICIENT_BALANCE") return void 0;
4500
+ const seen = error.details?.balance;
4501
+ if (typeof seen !== "string") return void 0;
4502
+ const balance = BigInt(seen);
4503
+ if (balance <= 0n || balance >= asked) return void 0;
4504
+ return (asked - balance) * 100n <= asked ? balance : void 0;
4505
+ }
4446
4506
  function encodeMultiAgentCursor(map) {
4447
4507
  return Buffer.from(JSON.stringify(map), "utf8").toString("base64");
4448
4508
  }
@@ -5649,6 +5709,38 @@ var OwneySDK = class {
5649
5709
  const balance = await this.readTokenBalance(chainId, token);
5650
5710
  return (balance < received ? balance : received).toString();
5651
5711
  }
5712
+ /**
5713
+ * Deposit a swap's output, retrying once at the balance the deposit saw.
5714
+ *
5715
+ * `settleSwapDepositAmount` approves and THEN reads, but the read and the
5716
+ * deposit's own pre-check can disagree: the approval is confirmed through
5717
+ * the wallet's node, the read goes to a pinned RPC, and gas paid in WETH
5718
+ * (MetaMask offers this to a wallet with no ETH) can land after either.
5719
+ * ROUT-618 failed that way with the settle step in place.
5720
+ *
5721
+ * The pre-check reports the balance it saw, and that is the authority, so
5722
+ * a shortfall is retried once at that figure. Only a small one: a node that
5723
+ * has not caught up can read near zero, and depositing that would leave the
5724
+ * rest of the swap stranded behind a "success".
5725
+ */
5726
+ async depositSwapOutput(options) {
5727
+ const attempt = (amount) => this.deposit({
5728
+ amount,
5729
+ asset: options.asset,
5730
+ ...options.agentId ? { agentId: options.agentId } : {}
5731
+ });
5732
+ try {
5733
+ return {
5734
+ deposit: await attempt(options.amount),
5735
+ deposited: options.amount
5736
+ };
5737
+ } catch (error) {
5738
+ const balance = shortfallBalance(error, BigInt(options.amount));
5739
+ if (balance === void 0) throw error;
5740
+ const deposited = balance.toString();
5741
+ return { deposit: await attempt(deposited), deposited };
5742
+ }
5743
+ }
5652
5744
  async walletSeesBalance(chainId, token, amount) {
5653
5745
  const provider = this.requireConnectedProvider();
5654
5746
  const state = this.requireState();
@@ -5674,9 +5766,39 @@ var OwneySDK = class {
5674
5766
  * Runs a quoted swap. A named seam rather than an inline `executeSwap` call
5675
5767
  * so the deposit half of `swapAndDeposit` can be exercised on its own.
5676
5768
  */
5677
- runSwap(quote, options) {
5769
+ async runSwap(quote, options) {
5770
+ await this.checkSwapGas(quote);
5678
5771
  return executeSwap(this.buildSwapDeps(quote), { quote, ...options });
5679
5772
  }
5773
+ /**
5774
+ * Throws SWAP_INSUFFICIENT_GAS when the wallet cannot pay for this swap.
5775
+ *
5776
+ * Only the classic rail is ever refused: it is the one where the user sends
5777
+ * the transactions. Call it on a preview quote to warn BEFORE a withdrawal
5778
+ * moves anything — `withdrawAndSwap` repeats it on its own quote, but by
5779
+ * then the funds have already left the agent (ROUT-622).
5780
+ */
5781
+ async checkSwapGas(quote) {
5782
+ const state = this.requireState();
5783
+ const chain = swapChain(quote.src.chainId);
5784
+ if (!chain) return;
5785
+ const pub = (0, import_viem9.createPublicClient)({
5786
+ chain,
5787
+ transport: swapReadTransport(quote.src.chainId, this.zyfaiRpcUrls)
5788
+ });
5789
+ const shortfall = await swapGasShortfall(
5790
+ {
5791
+ readNativeBalance: () => pub.getBalance({ address: state.walletAddress }),
5792
+ // The 1559 ceiling, as the wrap reserve reads it.
5793
+ getMaxFeePerGas: async () => {
5794
+ const fees = await pub.estimateFeesPerGas();
5795
+ return fees.maxFeePerGas ?? pub.getGasPrice();
5796
+ }
5797
+ },
5798
+ quote
5799
+ );
5800
+ if (shortfall) throw swapGasError(shortfall, chain.nativeCurrency.symbol);
5801
+ }
5680
5802
  /**
5681
5803
  * The half of the swap deps that only ever talks to the routing API.
5682
5804
  *
@@ -5748,8 +5870,15 @@ var OwneySDK = class {
5748
5870
  );
5749
5871
  }
5750
5872
  await this.ensureSwapChain(entry.chainId);
5751
- const deposit = await this.deposit({
5752
- amount: entry.received,
5873
+ const token = entry.symbol === "WETH" ? SPONSORED_WETH_BY_CHAIN[entry.chainId] : void 0;
5874
+ const amount = token ? await this.settleSwapDepositAmount(
5875
+ entry.chainId,
5876
+ token,
5877
+ entry.symbol,
5878
+ BigInt(entry.received)
5879
+ ) : entry.received;
5880
+ const { deposit } = await this.depositSwapOutput({
5881
+ amount,
5753
5882
  asset: entry.symbol,
5754
5883
  ...entry.agentId ? { agentId: entry.agentId } : {}
5755
5884
  });
@@ -5799,7 +5928,7 @@ var OwneySDK = class {
5799
5928
  status: "completed"
5800
5929
  });
5801
5930
  } catch (error) {
5802
- if (error instanceof OwneyError && (error.code === "SWAP_ORDER_REFUNDED" || error.code === "SWAP_ORDER_CANCELLED")) {
5931
+ if (error instanceof OwneyError && (error.code === "SWAP_ORDER_REFUNDED" || error.code === "SWAP_ORDER_CANCELLED" || error.code === "SWAP_ORDER_NOT_FOUND")) {
5803
5932
  clearOrder(order.orderHash);
5804
5933
  updateWithdrawSwap(order.orderHash, { status: "refunded" });
5805
5934
  } else {
@@ -5989,12 +6118,12 @@ var OwneySDK = class {
5989
6118
  options.to.symbol,
5990
6119
  BigInt(swap.received)
5991
6120
  );
5992
- const deposit = await this.deposit({
6121
+ const { deposit, deposited } = await this.depositSwapOutput({
5993
6122
  amount,
5994
6123
  asset: options.to.symbol,
5995
6124
  ...options.agentId ? { agentId: options.agentId } : {}
5996
6125
  });
5997
- return { swap, deposit, deposited: amount };
6126
+ return { swap, deposit, deposited };
5998
6127
  } catch (error) {
5999
6128
  const pending = {
6000
6129
  id: `${quote.dst.chainId}-${options.to.symbol}-${Date.now()}`,
@@ -6098,6 +6227,7 @@ var OwneySDK = class {
6098
6227
  walletAddress: state.walletAddress
6099
6228
  });
6100
6229
  await this.ensureSwapChain(quote.src.chainId);
6230
+ await this.checkSwapGas(quote);
6101
6231
  const swap = await executeSwap(this.buildSwapDeps(quote), {
6102
6232
  quote,
6103
6233
  walletAddress: state.walletAddress,
package/dist/index.d.cts CHANGED
@@ -964,7 +964,7 @@ type AgentApyOptions = {
964
964
  chainId?: number;
965
965
  };
966
966
 
967
- type OwneyErrorCode = "NOT_CONNECTED" | "NO_ACTIVE_CHAIN" | "WALLET_NO_ACCOUNTS" | "WALLET_ADDRESS_REQUIRED" | "WALLET_NOT_DEPLOYED" | "AGENT_NOT_FOUND" | "AGENT_CHAIN_INCOMPATIBLE" | "AGENT_EMPTY_LIST" | "AGENT_DISABLED" | "CHAIN_UNSUPPORTED" | "CHAIN_NO_COMPATIBLE_AGENTS" | "CHAIN_MISMATCH" | "ASSET_UNSUPPORTED" | "ASSET_NO_COMPATIBLE_AGENTS" | "DEPOSIT_BALANCE_UNAVAILABLE" | "DEPOSIT_PARTIAL_FAILURE" | "DEPOSIT_AMOUNT_BELOW_MINIMUM" | "DEPOSIT_CALLBACK_REQUIRED" | "DEPOSIT_CALLBACK_INVALID" | "DEPOSIT_NO_PERMITTED_TOKENS" | "DEPOSIT_INSUFFICIENT_BALANCE" | "WITHDRAW_NO_PERMITTED_TOKENS" | "WITHDRAW_INSUFFICIENT_BALANCE" | "WITHDRAW_ALL_FAILED" | "WITHDRAW_NOTHING_TO_WITHDRAW" | "WITHDRAW_PARTIAL_FAILURE" | "WITHDRAW_FAILED" | "AGENT_RATE_LIMITED" | "API_ROUTING_ERROR" | "API_ROUTING_FAILED" | "API_NO_AGENTS" | "SPONSOR_REQUEST_FAILED" | "PERMIT2_APPROVAL_REQUIRED" | "SPONSORED_CALLS_UNSUPPORTED" | "SPONSORED_CALLS_NO_ID" | "SPONSORED_CALLS_NO_RECEIPT" | "SWAP_DISABLED" | "SWAP_RATE_LIMITED" | "SWAP_REQUEST_FAILED" | "SWAP_QUOTE_FAILED" | "SWAP_UNSUPPORTED_PAIR" | "SWAP_APPROVAL_REQUIRED" | "SWAP_BELOW_DEPOSIT_MINIMUM" | "SWAP_DEPOSIT_PENDING" | "SWAP_WALLET_PROMPT_TIMEOUT" | "SWAP_INSUFFICIENT_GAS" | "SWAP_NATIVE_UNSUPPORTED" | "SWAP_ORDER_EXPIRED" | "SWAP_ORDER_REFUNDED" | "SWAP_ORDER_CANCELLED" | "WITHDRAW_ARRIVAL_TIMEOUT" | "WITHDRAW_SWAP_FAILED" | "BALANCE_ALL_FAILED" | "ALLOCATION_ALL_FAILED" | "POOL_CATALOG_UNSUPPORTED" | "POOL_SELECTION_UNSUPPORTED" | "VALIDATION_INVALID_DAYS";
967
+ type OwneyErrorCode = "NOT_CONNECTED" | "NO_ACTIVE_CHAIN" | "WALLET_NO_ACCOUNTS" | "WALLET_ADDRESS_REQUIRED" | "WALLET_NOT_DEPLOYED" | "AGENT_NOT_FOUND" | "AGENT_CHAIN_INCOMPATIBLE" | "AGENT_EMPTY_LIST" | "AGENT_DISABLED" | "CHAIN_UNSUPPORTED" | "CHAIN_NO_COMPATIBLE_AGENTS" | "CHAIN_MISMATCH" | "ASSET_UNSUPPORTED" | "ASSET_NO_COMPATIBLE_AGENTS" | "DEPOSIT_BALANCE_UNAVAILABLE" | "DEPOSIT_PARTIAL_FAILURE" | "DEPOSIT_AMOUNT_BELOW_MINIMUM" | "DEPOSIT_CALLBACK_REQUIRED" | "DEPOSIT_CALLBACK_INVALID" | "DEPOSIT_NO_PERMITTED_TOKENS" | "DEPOSIT_INSUFFICIENT_BALANCE" | "WITHDRAW_NO_PERMITTED_TOKENS" | "WITHDRAW_INSUFFICIENT_BALANCE" | "WITHDRAW_ALL_FAILED" | "WITHDRAW_NOTHING_TO_WITHDRAW" | "WITHDRAW_PARTIAL_FAILURE" | "WITHDRAW_FAILED" | "AGENT_RATE_LIMITED" | "API_ROUTING_ERROR" | "API_ROUTING_FAILED" | "API_NO_AGENTS" | "SPONSOR_REQUEST_FAILED" | "PERMIT2_APPROVAL_REQUIRED" | "SPONSORED_CALLS_UNSUPPORTED" | "SPONSORED_CALLS_NO_ID" | "SPONSORED_CALLS_NO_RECEIPT" | "SWAP_DISABLED" | "SWAP_RATE_LIMITED" | "SWAP_REQUEST_FAILED" | "SWAP_QUOTE_FAILED" | "SWAP_UNSUPPORTED_PAIR" | "SWAP_APPROVAL_REQUIRED" | "SWAP_BELOW_DEPOSIT_MINIMUM" | "SWAP_DEPOSIT_PENDING" | "SWAP_WALLET_PROMPT_TIMEOUT" | "SWAP_INSUFFICIENT_GAS" | "SWAP_NATIVE_UNSUPPORTED" | "SWAP_ORDER_EXPIRED" | "SWAP_ORDER_REFUNDED" | "SWAP_ORDER_CANCELLED" | "SWAP_ORDER_NOT_FOUND" | "WITHDRAW_ARRIVAL_TIMEOUT" | "WITHDRAW_SWAP_FAILED" | "BALANCE_ALL_FAILED" | "ALLOCATION_ALL_FAILED" | "POOL_CATALOG_UNSUPPORTED" | "POOL_SELECTION_UNSUPPORTED" | "VALIDATION_INVALID_DAYS";
968
968
  declare class OwneyError extends Error {
969
969
  readonly code: OwneyErrorCode;
970
970
  readonly details?: Record<string, unknown>;
@@ -1393,12 +1393,36 @@ declare class OwneySDK {
1393
1393
  * where the sponsor covers the transfer and Permit2 never applies.
1394
1394
  */
1395
1395
  private settleSwapDepositAmount;
1396
+ /**
1397
+ * Deposit a swap's output, retrying once at the balance the deposit saw.
1398
+ *
1399
+ * `settleSwapDepositAmount` approves and THEN reads, but the read and the
1400
+ * deposit's own pre-check can disagree: the approval is confirmed through
1401
+ * the wallet's node, the read goes to a pinned RPC, and gas paid in WETH
1402
+ * (MetaMask offers this to a wallet with no ETH) can land after either.
1403
+ * ROUT-618 failed that way with the settle step in place.
1404
+ *
1405
+ * The pre-check reports the balance it saw, and that is the authority, so
1406
+ * a shortfall is retried once at that figure. Only a small one: a node that
1407
+ * has not caught up can read near zero, and depositing that would leave the
1408
+ * rest of the swap stranded behind a "success".
1409
+ */
1410
+ private depositSwapOutput;
1396
1411
  private walletSeesBalance;
1397
1412
  /**
1398
1413
  * Runs a quoted swap. A named seam rather than an inline `executeSwap` call
1399
1414
  * so the deposit half of `swapAndDeposit` can be exercised on its own.
1400
1415
  */
1401
1416
  private runSwap;
1417
+ /**
1418
+ * Throws SWAP_INSUFFICIENT_GAS when the wallet cannot pay for this swap.
1419
+ *
1420
+ * Only the classic rail is ever refused: it is the one where the user sends
1421
+ * the transactions. Call it on a preview quote to warn BEFORE a withdrawal
1422
+ * moves anything — `withdrawAndSwap` repeats it on its own quote, but by
1423
+ * then the funds have already left the agent (ROUT-622).
1424
+ */
1425
+ checkSwapGas(quote: SwapQuote): Promise<void>;
1402
1426
  /**
1403
1427
  * The half of the swap deps that only ever talks to the routing API.
1404
1428
  *
package/dist/index.d.ts CHANGED
@@ -964,7 +964,7 @@ type AgentApyOptions = {
964
964
  chainId?: number;
965
965
  };
966
966
 
967
- type OwneyErrorCode = "NOT_CONNECTED" | "NO_ACTIVE_CHAIN" | "WALLET_NO_ACCOUNTS" | "WALLET_ADDRESS_REQUIRED" | "WALLET_NOT_DEPLOYED" | "AGENT_NOT_FOUND" | "AGENT_CHAIN_INCOMPATIBLE" | "AGENT_EMPTY_LIST" | "AGENT_DISABLED" | "CHAIN_UNSUPPORTED" | "CHAIN_NO_COMPATIBLE_AGENTS" | "CHAIN_MISMATCH" | "ASSET_UNSUPPORTED" | "ASSET_NO_COMPATIBLE_AGENTS" | "DEPOSIT_BALANCE_UNAVAILABLE" | "DEPOSIT_PARTIAL_FAILURE" | "DEPOSIT_AMOUNT_BELOW_MINIMUM" | "DEPOSIT_CALLBACK_REQUIRED" | "DEPOSIT_CALLBACK_INVALID" | "DEPOSIT_NO_PERMITTED_TOKENS" | "DEPOSIT_INSUFFICIENT_BALANCE" | "WITHDRAW_NO_PERMITTED_TOKENS" | "WITHDRAW_INSUFFICIENT_BALANCE" | "WITHDRAW_ALL_FAILED" | "WITHDRAW_NOTHING_TO_WITHDRAW" | "WITHDRAW_PARTIAL_FAILURE" | "WITHDRAW_FAILED" | "AGENT_RATE_LIMITED" | "API_ROUTING_ERROR" | "API_ROUTING_FAILED" | "API_NO_AGENTS" | "SPONSOR_REQUEST_FAILED" | "PERMIT2_APPROVAL_REQUIRED" | "SPONSORED_CALLS_UNSUPPORTED" | "SPONSORED_CALLS_NO_ID" | "SPONSORED_CALLS_NO_RECEIPT" | "SWAP_DISABLED" | "SWAP_RATE_LIMITED" | "SWAP_REQUEST_FAILED" | "SWAP_QUOTE_FAILED" | "SWAP_UNSUPPORTED_PAIR" | "SWAP_APPROVAL_REQUIRED" | "SWAP_BELOW_DEPOSIT_MINIMUM" | "SWAP_DEPOSIT_PENDING" | "SWAP_WALLET_PROMPT_TIMEOUT" | "SWAP_INSUFFICIENT_GAS" | "SWAP_NATIVE_UNSUPPORTED" | "SWAP_ORDER_EXPIRED" | "SWAP_ORDER_REFUNDED" | "SWAP_ORDER_CANCELLED" | "WITHDRAW_ARRIVAL_TIMEOUT" | "WITHDRAW_SWAP_FAILED" | "BALANCE_ALL_FAILED" | "ALLOCATION_ALL_FAILED" | "POOL_CATALOG_UNSUPPORTED" | "POOL_SELECTION_UNSUPPORTED" | "VALIDATION_INVALID_DAYS";
967
+ type OwneyErrorCode = "NOT_CONNECTED" | "NO_ACTIVE_CHAIN" | "WALLET_NO_ACCOUNTS" | "WALLET_ADDRESS_REQUIRED" | "WALLET_NOT_DEPLOYED" | "AGENT_NOT_FOUND" | "AGENT_CHAIN_INCOMPATIBLE" | "AGENT_EMPTY_LIST" | "AGENT_DISABLED" | "CHAIN_UNSUPPORTED" | "CHAIN_NO_COMPATIBLE_AGENTS" | "CHAIN_MISMATCH" | "ASSET_UNSUPPORTED" | "ASSET_NO_COMPATIBLE_AGENTS" | "DEPOSIT_BALANCE_UNAVAILABLE" | "DEPOSIT_PARTIAL_FAILURE" | "DEPOSIT_AMOUNT_BELOW_MINIMUM" | "DEPOSIT_CALLBACK_REQUIRED" | "DEPOSIT_CALLBACK_INVALID" | "DEPOSIT_NO_PERMITTED_TOKENS" | "DEPOSIT_INSUFFICIENT_BALANCE" | "WITHDRAW_NO_PERMITTED_TOKENS" | "WITHDRAW_INSUFFICIENT_BALANCE" | "WITHDRAW_ALL_FAILED" | "WITHDRAW_NOTHING_TO_WITHDRAW" | "WITHDRAW_PARTIAL_FAILURE" | "WITHDRAW_FAILED" | "AGENT_RATE_LIMITED" | "API_ROUTING_ERROR" | "API_ROUTING_FAILED" | "API_NO_AGENTS" | "SPONSOR_REQUEST_FAILED" | "PERMIT2_APPROVAL_REQUIRED" | "SPONSORED_CALLS_UNSUPPORTED" | "SPONSORED_CALLS_NO_ID" | "SPONSORED_CALLS_NO_RECEIPT" | "SWAP_DISABLED" | "SWAP_RATE_LIMITED" | "SWAP_REQUEST_FAILED" | "SWAP_QUOTE_FAILED" | "SWAP_UNSUPPORTED_PAIR" | "SWAP_APPROVAL_REQUIRED" | "SWAP_BELOW_DEPOSIT_MINIMUM" | "SWAP_DEPOSIT_PENDING" | "SWAP_WALLET_PROMPT_TIMEOUT" | "SWAP_INSUFFICIENT_GAS" | "SWAP_NATIVE_UNSUPPORTED" | "SWAP_ORDER_EXPIRED" | "SWAP_ORDER_REFUNDED" | "SWAP_ORDER_CANCELLED" | "SWAP_ORDER_NOT_FOUND" | "WITHDRAW_ARRIVAL_TIMEOUT" | "WITHDRAW_SWAP_FAILED" | "BALANCE_ALL_FAILED" | "ALLOCATION_ALL_FAILED" | "POOL_CATALOG_UNSUPPORTED" | "POOL_SELECTION_UNSUPPORTED" | "VALIDATION_INVALID_DAYS";
968
968
  declare class OwneyError extends Error {
969
969
  readonly code: OwneyErrorCode;
970
970
  readonly details?: Record<string, unknown>;
@@ -1393,12 +1393,36 @@ declare class OwneySDK {
1393
1393
  * where the sponsor covers the transfer and Permit2 never applies.
1394
1394
  */
1395
1395
  private settleSwapDepositAmount;
1396
+ /**
1397
+ * Deposit a swap's output, retrying once at the balance the deposit saw.
1398
+ *
1399
+ * `settleSwapDepositAmount` approves and THEN reads, but the read and the
1400
+ * deposit's own pre-check can disagree: the approval is confirmed through
1401
+ * the wallet's node, the read goes to a pinned RPC, and gas paid in WETH
1402
+ * (MetaMask offers this to a wallet with no ETH) can land after either.
1403
+ * ROUT-618 failed that way with the settle step in place.
1404
+ *
1405
+ * The pre-check reports the balance it saw, and that is the authority, so
1406
+ * a shortfall is retried once at that figure. Only a small one: a node that
1407
+ * has not caught up can read near zero, and depositing that would leave the
1408
+ * rest of the swap stranded behind a "success".
1409
+ */
1410
+ private depositSwapOutput;
1396
1411
  private walletSeesBalance;
1397
1412
  /**
1398
1413
  * Runs a quoted swap. A named seam rather than an inline `executeSwap` call
1399
1414
  * so the deposit half of `swapAndDeposit` can be exercised on its own.
1400
1415
  */
1401
1416
  private runSwap;
1417
+ /**
1418
+ * Throws SWAP_INSUFFICIENT_GAS when the wallet cannot pay for this swap.
1419
+ *
1420
+ * Only the classic rail is ever refused: it is the one where the user sends
1421
+ * the transactions. Call it on a preview quote to warn BEFORE a withdrawal
1422
+ * moves anything — `withdrawAndSwap` repeats it on its own quote, but by
1423
+ * then the funds have already left the agent (ROUT-622).
1424
+ */
1425
+ checkSwapGas(quote: SwapQuote): Promise<void>;
1402
1426
  /**
1403
1427
  * The half of the swap deps that only ever talks to the routing API.
1404
1428
  *
package/dist/index.js CHANGED
@@ -2854,6 +2854,33 @@ function swapChain(chainId) {
2854
2854
  return CHAINS[chainId];
2855
2855
  }
2856
2856
 
2857
+ // src/lib/swap/swap.gas-check.ts
2858
+ var CLASSIC_SWAP_GAS = 300000n;
2859
+ var APPROVE_GAS2 = 60000n;
2860
+ var isNative = (address) => address.toLowerCase().startsWith("0xeeee");
2861
+ async function swapGasShortfall(deps, quote) {
2862
+ if (quote.rail !== "classic" || isNative(quote.src.address)) return null;
2863
+ const gas = CLASSIC_SWAP_GAS + APPROVE_GAS2;
2864
+ const [balance, gasPrice] = await Promise.all([
2865
+ deps.readNativeBalance(),
2866
+ deps.getMaxFeePerGas()
2867
+ ]);
2868
+ const required = gas * gasPrice;
2869
+ return balance < required ? { chainId: quote.src.chainId, balance, required } : null;
2870
+ }
2871
+ function swapGasError(shortfall, nativeSymbol) {
2872
+ return new OwneyError(
2873
+ "SWAP_INSUFFICIENT_GAS",
2874
+ `This swap needs a little ${nativeSymbol} for network fees, and your wallet has too little. Add some ${nativeSymbol}, or choose a different asset to receive.`,
2875
+ {
2876
+ chainId: shortfall.chainId,
2877
+ symbol: nativeSymbol,
2878
+ balance: shortfall.balance.toString(),
2879
+ required: shortfall.required.toString()
2880
+ }
2881
+ );
2882
+ }
2883
+
2857
2884
  // src/lib/swap/swap.arrival.ts
2858
2885
  var DEFAULT_TIMEOUT_MS = 18e4;
2859
2886
  var DEFAULT_POLL_MS = 4e3;
@@ -3044,6 +3071,10 @@ var DEFAULT_POLL_MS2 = 5e3;
3044
3071
  var MAX_BACKOFF_MS = 3e4;
3045
3072
  var backoffFor = (failures, base4) => Math.min(base4 * 2 ** Math.min(failures, 5), MAX_BACKOFF_MS);
3046
3073
  var DEFAULT_TIMEOUT_MS2 = 15 * 60 * 1e3;
3074
+ var NOT_FOUND_GRACE_MS = 2 * 60 * 1e3;
3075
+ function isNotFound(error) {
3076
+ return error instanceof OwneyError && error.details?.statusCode === 404;
3077
+ }
3047
3078
  async function runFusionOrder(deps, options) {
3048
3079
  const {
3049
3080
  orderHash,
@@ -3054,6 +3085,7 @@ async function runFusionOrder(deps, options) {
3054
3085
  } = options;
3055
3086
  const deadline = deps.now() + timeoutMs;
3056
3087
  let failures = 0;
3088
+ let notFoundSince;
3057
3089
  const published = /* @__PURE__ */ new Set();
3058
3090
  onStage?.("swapping");
3059
3091
  for (; ; ) {
@@ -3095,7 +3127,20 @@ async function runFusionOrder(deps, options) {
3095
3127
  try {
3096
3128
  ({ status } = await deps.orderStatus(orderHash));
3097
3129
  failures = 0;
3098
- } catch {
3130
+ notFoundSince = void 0;
3131
+ } catch (error) {
3132
+ if (isNotFound(error)) {
3133
+ notFoundSince ??= deps.now();
3134
+ if (deps.now() - notFoundSince >= NOT_FOUND_GRACE_MS) {
3135
+ throw new OwneyError(
3136
+ "SWAP_ORDER_NOT_FOUND",
3137
+ "The swap provider has no record of this order, so nothing was exchanged.",
3138
+ { orderHash }
3139
+ );
3140
+ }
3141
+ } else {
3142
+ notFoundSince = void 0;
3143
+ }
3099
3144
  failures += 1;
3100
3145
  await deps.sleep(backoffFor(failures, pollIntervalMs));
3101
3146
  continue;
@@ -3153,9 +3198,9 @@ async function runSameChainOrder(deps, options) {
3153
3198
  for (; ; ) {
3154
3199
  if (deps.now() >= deadline) {
3155
3200
  throw new OwneyError(
3156
- "SWAP_ORDER_EXPIRED",
3157
- "No one filled the swap in time. Nothing was exchanged and your funds are still in your wallet.",
3158
- { orderHash }
3201
+ "SWAP_REQUEST_FAILED",
3202
+ "Timed out waiting for the swap to fill. It may still complete \u2014 if it does, the swapped funds will be in your wallet. Check before retrying.",
3203
+ { orderHash, settlementUnconfirmed: true }
3159
3204
  );
3160
3205
  }
3161
3206
  let status;
@@ -3352,8 +3397,8 @@ async function runClassic(deps, options) {
3352
3397
  onStage?.("quoting");
3353
3398
  debugLog("owney-sdk", "swap: fetching classic calldata");
3354
3399
  let { tx } = await deps.api.swapTx(swapTxRequest);
3355
- const isNative = BigInt(tx.value ?? "0") > 0n;
3356
- if (!isNative) {
3400
+ const isNative2 = BigInt(tx.value ?? "0") > 0n;
3401
+ if (!isNative2) {
3357
3402
  const needed = amount;
3358
3403
  const current = await deps.readAllowance(tx.to);
3359
3404
  debugLog("owney-sdk", "swap: allowance", {
@@ -3503,10 +3548,16 @@ async function runFusion(deps, options, walletAddress) {
3503
3548
  debugLog("owney-sdk", "swap: awaiting signature in wallet", {
3504
3549
  signingOnChain: quote.src.chainId
3505
3550
  });
3506
- const signature = await awaitWalletPrompt(
3507
- () => deps.signTypedData(built.typedData),
3508
- "sign the swap"
3509
- );
3551
+ let signature;
3552
+ try {
3553
+ signature = await awaitWalletPrompt(
3554
+ () => deps.signTypedData(built.typedData),
3555
+ "sign the swap"
3556
+ );
3557
+ } catch (error) {
3558
+ clearOrder(built.orderHash);
3559
+ throw error;
3560
+ }
3510
3561
  debugLog("owney-sdk", "swap: signed, submitting to relayer");
3511
3562
  await deps.api.submitOrder({
3512
3563
  srcChainId: quote.src.chainId,
@@ -3534,7 +3585,7 @@ async function runFusion(deps, options, walletAddress) {
3534
3585
  ...onStage ? { onStage } : {}
3535
3586
  });
3536
3587
  } catch (error) {
3537
- if (error instanceof OwneyError && (error.code === "SWAP_ORDER_REFUNDED" || error.code === "SWAP_ORDER_CANCELLED")) {
3588
+ if (error instanceof OwneyError && (error.code === "SWAP_ORDER_REFUNDED" || error.code === "SWAP_ORDER_CANCELLED" || error.code === "SWAP_ORDER_NOT_FOUND")) {
3538
3589
  clearOrder(built.orderHash);
3539
3590
  }
3540
3591
  throw error;
@@ -4424,6 +4475,15 @@ function makeSponsoredCallsCallback(deps) {
4424
4475
 
4425
4476
  // src/client.ts
4426
4477
  var WALLET_CATCHUP_TIMEOUT_MS = 3e4;
4478
+ function shortfallBalance(error, asked) {
4479
+ if (!(error instanceof OwneyError)) return void 0;
4480
+ if (error.code !== "DEPOSIT_INSUFFICIENT_BALANCE") return void 0;
4481
+ const seen = error.details?.balance;
4482
+ if (typeof seen !== "string") return void 0;
4483
+ const balance = BigInt(seen);
4484
+ if (balance <= 0n || balance >= asked) return void 0;
4485
+ return (asked - balance) * 100n <= asked ? balance : void 0;
4486
+ }
4427
4487
  function encodeMultiAgentCursor(map) {
4428
4488
  return Buffer.from(JSON.stringify(map), "utf8").toString("base64");
4429
4489
  }
@@ -5630,6 +5690,38 @@ var OwneySDK = class {
5630
5690
  const balance = await this.readTokenBalance(chainId, token);
5631
5691
  return (balance < received ? balance : received).toString();
5632
5692
  }
5693
+ /**
5694
+ * Deposit a swap's output, retrying once at the balance the deposit saw.
5695
+ *
5696
+ * `settleSwapDepositAmount` approves and THEN reads, but the read and the
5697
+ * deposit's own pre-check can disagree: the approval is confirmed through
5698
+ * the wallet's node, the read goes to a pinned RPC, and gas paid in WETH
5699
+ * (MetaMask offers this to a wallet with no ETH) can land after either.
5700
+ * ROUT-618 failed that way with the settle step in place.
5701
+ *
5702
+ * The pre-check reports the balance it saw, and that is the authority, so
5703
+ * a shortfall is retried once at that figure. Only a small one: a node that
5704
+ * has not caught up can read near zero, and depositing that would leave the
5705
+ * rest of the swap stranded behind a "success".
5706
+ */
5707
+ async depositSwapOutput(options) {
5708
+ const attempt = (amount) => this.deposit({
5709
+ amount,
5710
+ asset: options.asset,
5711
+ ...options.agentId ? { agentId: options.agentId } : {}
5712
+ });
5713
+ try {
5714
+ return {
5715
+ deposit: await attempt(options.amount),
5716
+ deposited: options.amount
5717
+ };
5718
+ } catch (error) {
5719
+ const balance = shortfallBalance(error, BigInt(options.amount));
5720
+ if (balance === void 0) throw error;
5721
+ const deposited = balance.toString();
5722
+ return { deposit: await attempt(deposited), deposited };
5723
+ }
5724
+ }
5633
5725
  async walletSeesBalance(chainId, token, amount) {
5634
5726
  const provider = this.requireConnectedProvider();
5635
5727
  const state = this.requireState();
@@ -5655,9 +5747,39 @@ var OwneySDK = class {
5655
5747
  * Runs a quoted swap. A named seam rather than an inline `executeSwap` call
5656
5748
  * so the deposit half of `swapAndDeposit` can be exercised on its own.
5657
5749
  */
5658
- runSwap(quote, options) {
5750
+ async runSwap(quote, options) {
5751
+ await this.checkSwapGas(quote);
5659
5752
  return executeSwap(this.buildSwapDeps(quote), { quote, ...options });
5660
5753
  }
5754
+ /**
5755
+ * Throws SWAP_INSUFFICIENT_GAS when the wallet cannot pay for this swap.
5756
+ *
5757
+ * Only the classic rail is ever refused: it is the one where the user sends
5758
+ * the transactions. Call it on a preview quote to warn BEFORE a withdrawal
5759
+ * moves anything — `withdrawAndSwap` repeats it on its own quote, but by
5760
+ * then the funds have already left the agent (ROUT-622).
5761
+ */
5762
+ async checkSwapGas(quote) {
5763
+ const state = this.requireState();
5764
+ const chain = swapChain(quote.src.chainId);
5765
+ if (!chain) return;
5766
+ const pub = createPublicClient2({
5767
+ chain,
5768
+ transport: swapReadTransport(quote.src.chainId, this.zyfaiRpcUrls)
5769
+ });
5770
+ const shortfall = await swapGasShortfall(
5771
+ {
5772
+ readNativeBalance: () => pub.getBalance({ address: state.walletAddress }),
5773
+ // The 1559 ceiling, as the wrap reserve reads it.
5774
+ getMaxFeePerGas: async () => {
5775
+ const fees = await pub.estimateFeesPerGas();
5776
+ return fees.maxFeePerGas ?? pub.getGasPrice();
5777
+ }
5778
+ },
5779
+ quote
5780
+ );
5781
+ if (shortfall) throw swapGasError(shortfall, chain.nativeCurrency.symbol);
5782
+ }
5661
5783
  /**
5662
5784
  * The half of the swap deps that only ever talks to the routing API.
5663
5785
  *
@@ -5729,8 +5851,15 @@ var OwneySDK = class {
5729
5851
  );
5730
5852
  }
5731
5853
  await this.ensureSwapChain(entry.chainId);
5732
- const deposit = await this.deposit({
5733
- amount: entry.received,
5854
+ const token = entry.symbol === "WETH" ? SPONSORED_WETH_BY_CHAIN[entry.chainId] : void 0;
5855
+ const amount = token ? await this.settleSwapDepositAmount(
5856
+ entry.chainId,
5857
+ token,
5858
+ entry.symbol,
5859
+ BigInt(entry.received)
5860
+ ) : entry.received;
5861
+ const { deposit } = await this.depositSwapOutput({
5862
+ amount,
5734
5863
  asset: entry.symbol,
5735
5864
  ...entry.agentId ? { agentId: entry.agentId } : {}
5736
5865
  });
@@ -5780,7 +5909,7 @@ var OwneySDK = class {
5780
5909
  status: "completed"
5781
5910
  });
5782
5911
  } catch (error) {
5783
- if (error instanceof OwneyError && (error.code === "SWAP_ORDER_REFUNDED" || error.code === "SWAP_ORDER_CANCELLED")) {
5912
+ if (error instanceof OwneyError && (error.code === "SWAP_ORDER_REFUNDED" || error.code === "SWAP_ORDER_CANCELLED" || error.code === "SWAP_ORDER_NOT_FOUND")) {
5784
5913
  clearOrder(order.orderHash);
5785
5914
  updateWithdrawSwap(order.orderHash, { status: "refunded" });
5786
5915
  } else {
@@ -5970,12 +6099,12 @@ var OwneySDK = class {
5970
6099
  options.to.symbol,
5971
6100
  BigInt(swap.received)
5972
6101
  );
5973
- const deposit = await this.deposit({
6102
+ const { deposit, deposited } = await this.depositSwapOutput({
5974
6103
  amount,
5975
6104
  asset: options.to.symbol,
5976
6105
  ...options.agentId ? { agentId: options.agentId } : {}
5977
6106
  });
5978
- return { swap, deposit, deposited: amount };
6107
+ return { swap, deposit, deposited };
5979
6108
  } catch (error) {
5980
6109
  const pending = {
5981
6110
  id: `${quote.dst.chainId}-${options.to.symbol}-${Date.now()}`,
@@ -6079,6 +6208,7 @@ var OwneySDK = class {
6079
6208
  walletAddress: state.walletAddress
6080
6209
  });
6081
6210
  await this.ensureSwapChain(quote.src.chainId);
6211
+ await this.checkSwapGas(quote);
6082
6212
  const swap = await executeSwap(this.buildSwapDeps(quote), {
6083
6213
  quote,
6084
6214
  walletAddress: state.walletAddress,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owney/sdk",
3
- "version": "0.7.28-beta.3",
3
+ "version": "0.7.28-beta.5",
4
4
  "type": "module",
5
5
  "main": "dist/index.cjs",
6
6
  "module": "dist/index.js",