@owney/sdk 0.7.26-beta.2 → 0.7.26-beta.4

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
@@ -2309,6 +2309,21 @@ function createSwapApi(baseUrl, apiKey) {
2309
2309
  * Already filtered server-side to what a quote will accept, so anything
2310
2310
  * returned here can be paid with.
2311
2311
  */
2312
+ /**
2313
+ * Tokens the wallet holds on one chain.
2314
+ *
2315
+ * Already filtered upstream to non-zero balances of tokens the routing API
2316
+ * would quote — 1inch answers with every token it knows about, zeros
2317
+ * included, and the curation that decides what is offerable lives there.
2318
+ */
2319
+ walletBalances: (params) => request(
2320
+ baseUrl,
2321
+ apiKey,
2322
+ `/balances?${new URLSearchParams({
2323
+ chainId: String(params.chainId),
2324
+ walletAddress: params.walletAddress
2325
+ })}`
2326
+ ),
2312
2327
  searchTokens: (params) => request(
2313
2328
  baseUrl,
2314
2329
  apiKey,
@@ -2366,9 +2381,10 @@ function createSwapApi(baseUrl, apiKey) {
2366
2381
  dstSymbol: params.to.symbol,
2367
2382
  amount: params.from.amount,
2368
2383
  walletAddress: params.walletAddress,
2369
- secretHashes: params.secretHashes,
2384
+ ...params.secretHashes ? { secretHashes: params.secretHashes } : {},
2370
2385
  ...params.direction ? { direction: params.direction } : {},
2371
- ...params.receiver ? { receiver: params.receiver } : {}
2386
+ ...params.receiver ? { receiver: params.receiver } : {},
2387
+ ...params.permit ? { permit: params.permit } : {}
2372
2388
  }
2373
2389
  }),
2374
2390
  submitOrder: (body) => request(baseUrl, apiKey, "/order", { method: "POST", body }),
@@ -2381,7 +2397,16 @@ function createSwapApi(baseUrl, apiKey) {
2381
2397
  method: "POST",
2382
2398
  body: { orderHash, secret }
2383
2399
  }),
2384
- orderStatus: (orderHash) => request(baseUrl, apiKey, `/order/${orderHash}`),
2400
+ /**
2401
+ * @param chainId present only for a SAME-CHAIN order. That product keys its
2402
+ * orders per chain and the Fusion+ endpoint does not know them, so asking
2403
+ * without it answers 404 — which reads as an order that vanished.
2404
+ */
2405
+ orderStatus: (orderHash, chainId) => request(
2406
+ baseUrl,
2407
+ apiKey,
2408
+ chainId === void 0 ? `/order/${orderHash}` : `/order/${orderHash}?chainId=${chainId}`
2409
+ ),
2385
2410
  readyForSecrets: (orderHash) => request(
2386
2411
  baseUrl,
2387
2412
  apiKey,
@@ -2524,7 +2549,12 @@ var SWAP_TERMINAL_STATUSES = [
2524
2549
  "executed",
2525
2550
  "expired",
2526
2551
  "cancelled",
2527
- "refunded"
2552
+ "refunded",
2553
+ "filled",
2554
+ "false-predicate",
2555
+ "not-enough-balance-or-allowance",
2556
+ "wrong-permit",
2557
+ "invalid-signature"
2528
2558
  ];
2529
2559
  var isSwapTerminal = (status) => SWAP_TERMINAL_STATUSES.includes(status);
2530
2560
 
@@ -2602,6 +2632,71 @@ async function runFusionOrder(deps, options) {
2602
2632
  }
2603
2633
  }
2604
2634
 
2635
+ // src/lib/swap/swap.same-chain-runner.ts
2636
+ var DEFAULT_POLL_MS2 = 5e3;
2637
+ var MAX_BACKOFF_MS2 = 3e4;
2638
+ var backoffFor2 = (failures, base3) => Math.min(base3 * 2 ** Math.min(failures, 5), MAX_BACKOFF_MS2);
2639
+ var DEFAULT_TIMEOUT_MS2 = 3 * 60 * 1e3;
2640
+ function explain(status) {
2641
+ switch (status) {
2642
+ case "expired":
2643
+ return "No one filled the swap in time, so nothing was exchanged. Your funds never left your wallet.";
2644
+ case "cancelled":
2645
+ return "The swap was cancelled before anyone filled it. Your funds never left your wallet.";
2646
+ case "not-enough-balance-or-allowance":
2647
+ return "The swap could not be filled because the balance or approval had changed since it was signed. Nothing was exchanged.";
2648
+ case "wrong-permit":
2649
+ return "The approval signed with this swap was not accepted. Nothing was exchanged \u2014 try again, approving in your wallet if you are asked to.";
2650
+ case "invalid-signature":
2651
+ return "The swap signature was rejected. Nothing was exchanged.";
2652
+ case "false-predicate":
2653
+ return "The swap's conditions no longer held when it was filled. Nothing was exchanged.";
2654
+ default:
2655
+ return "The swap did not complete. Nothing was exchanged.";
2656
+ }
2657
+ }
2658
+ async function runSameChainOrder(deps, options) {
2659
+ const {
2660
+ orderHash,
2661
+ onStage,
2662
+ pollIntervalMs = DEFAULT_POLL_MS2,
2663
+ timeoutMs = DEFAULT_TIMEOUT_MS2
2664
+ } = options;
2665
+ const deadline = deps.now() + timeoutMs;
2666
+ let failures = 0;
2667
+ onStage?.("swapping");
2668
+ for (; ; ) {
2669
+ if (deps.now() >= deadline) {
2670
+ throw new OwneyError(
2671
+ "SWAP_ORDER_EXPIRED",
2672
+ "No one filled the swap in time. Nothing was exchanged and your funds are still in your wallet.",
2673
+ { orderHash }
2674
+ );
2675
+ }
2676
+ let status;
2677
+ try {
2678
+ ({ status } = await deps.orderStatus(orderHash));
2679
+ failures = 0;
2680
+ } catch {
2681
+ failures += 1;
2682
+ await deps.sleep(backoffFor2(failures, pollIntervalMs));
2683
+ continue;
2684
+ }
2685
+ if (isSwapTerminal(status)) {
2686
+ if (status === "filled" || status === "executed") {
2687
+ onStage?.("swapped");
2688
+ return { status, filled: true };
2689
+ }
2690
+ throw new OwneyError(
2691
+ status === "cancelled" ? "SWAP_ORDER_CANCELLED" : "SWAP_ORDER_EXPIRED",
2692
+ explain(status),
2693
+ { orderHash, status }
2694
+ );
2695
+ }
2696
+ await deps.sleep(pollIntervalMs);
2697
+ }
2698
+ }
2699
+
2605
2700
  // src/lib/swap/swap.secret-store.ts
2606
2701
  var KEY_PREFIX2 = "owney.swap.order";
2607
2702
  var MAX_AGE_MS = 24 * 60 * 60 * 1e3;
@@ -2685,7 +2780,7 @@ async function executeSwap(deps, options) {
2685
2780
  });
2686
2781
  const before = await deps.readTargetBalance();
2687
2782
  debugLog("owney-sdk", "swap: target balance before", before.toString());
2688
- const result = quote.rail === "classic" ? await runClassic(deps, options) : await runFusion(deps, options, walletAddress);
2783
+ const result = quote.rail === "classic" ? await runClassic(deps, options) : quote.rail === "fusion" ? await runSameChainFusion(deps, options, walletAddress) : await runFusion(deps, options, walletAddress);
2689
2784
  const after = await deps.readTargetBalance();
2690
2785
  const received = after - before;
2691
2786
  debugLog("owney-sdk", "swap: target balance after", {
@@ -2756,25 +2851,75 @@ async function runClassic(deps, options) {
2756
2851
  onStage?.("swapped");
2757
2852
  return { txHash };
2758
2853
  }
2759
- async function runFusion(deps, options, walletAddress) {
2854
+ async function runSameChainFusion(deps, options, walletAddress) {
2760
2855
  const { quote, direction, onStage } = options;
2761
2856
  const isNativeSource = quote.src.address.toLowerCase().startsWith("0xeeee");
2762
2857
  const amount = isNativeSource ? BigInt(quote.src.amount) : await affordableAmount(deps, BigInt(quote.src.amount));
2763
- if (quote.spender && !isNativeSource) {
2764
- const needed = amount;
2765
- const current = await deps.readAllowance(quote.spender);
2766
- debugLog("owney-sdk", "swap: fusion allowance", {
2767
- spender: quote.spender,
2768
- current: current.toString(),
2769
- needed: needed.toString()
2858
+ const permit = await authoriseSpend(deps, options, amount, isNativeSource);
2859
+ onStage?.("quoting");
2860
+ debugLog("owney-sdk", "swap: building same-chain fusion order");
2861
+ const built = await deps.api.buildOrder({
2862
+ from: {
2863
+ chainId: quote.src.chainId,
2864
+ symbol: quote.src.symbol,
2865
+ amount: amount.toString()
2866
+ },
2867
+ to: { chainId: quote.dst.chainId, symbol: quote.dst.symbol },
2868
+ walletAddress,
2869
+ ...direction ? { direction } : {},
2870
+ ...permit ? { permit } : {}
2871
+ });
2872
+ debugLog("owney-sdk", "swap: same-chain order built", {
2873
+ orderHash: built.orderHash
2874
+ });
2875
+ onStage?.("signing");
2876
+ await deps.ensureChain(quote.src.chainId);
2877
+ const signature = await deps.signTypedData(built.typedData);
2878
+ debugLog("owney-sdk", "swap: signed, submitting same-chain order");
2879
+ await deps.api.submitOrder({
2880
+ // Without this the routing API hands the order to the Fusion+ relayer,
2881
+ // which does not know it and answers with an error that names nothing.
2882
+ rail: "fusion",
2883
+ srcChainId: quote.src.chainId,
2884
+ order: built.order,
2885
+ signature,
2886
+ quoteId: built.quoteId,
2887
+ ...built.extension ? { extension: built.extension } : {}
2888
+ });
2889
+ await runSameChainOrder(deps.sameChainRunner ?? deps.runner, {
2890
+ orderHash: built.orderHash,
2891
+ ...onStage ? { onStage } : {}
2892
+ });
2893
+ return { orderHash: built.orderHash };
2894
+ }
2895
+ async function authoriseSpend(deps, options, amount, isNativeSource) {
2896
+ const { quote, onStage } = options;
2897
+ if (!quote.spender || isNativeSource) return void 0;
2898
+ const current = await deps.readAllowance(quote.spender);
2899
+ debugLog("owney-sdk", "swap: fusion allowance", {
2900
+ spender: quote.spender,
2901
+ current: current.toString(),
2902
+ needed: amount.toString()
2903
+ });
2904
+ if (current >= amount) return void 0;
2905
+ onStage?.("approving");
2906
+ await deps.ensureChain(quote.src.chainId);
2907
+ const permit = quote.permitSupported ? await deps.buildPermit?.(quote.spender, MAX_UINT256) ?? void 0 : void 0;
2908
+ if (permit) {
2909
+ debugLog("owney-sdk", "swap: permitting limit order protocol", {
2910
+ bytes: (permit.length - 2) / 2
2770
2911
  });
2771
- if (current < needed) {
2772
- onStage?.("approving");
2773
- await deps.ensureChain(quote.src.chainId);
2774
- await deps.approve(quote.spender, MAX_UINT256);
2775
- debugLog("owney-sdk", "swap: approved limit order protocol");
2776
- }
2912
+ return permit;
2777
2913
  }
2914
+ await deps.approve(quote.spender, MAX_UINT256);
2915
+ debugLog("owney-sdk", "swap: approved limit order protocol");
2916
+ return void 0;
2917
+ }
2918
+ async function runFusion(deps, options, walletAddress) {
2919
+ const { quote, direction, onStage } = options;
2920
+ const isNativeSource = quote.src.address.toLowerCase().startsWith("0xeeee");
2921
+ const amount = isNativeSource ? BigInt(quote.src.amount) : await affordableAmount(deps, BigInt(quote.src.amount));
2922
+ const permit = await authoriseSpend(deps, options, amount, isNativeSource);
2778
2923
  const { secrets, secretHashes } = mintSecrets(quote.secretsCount ?? 1);
2779
2924
  onStage?.("quoting");
2780
2925
  debugLog("owney-sdk", "swap: building fusion order", {
@@ -2790,7 +2935,8 @@ async function runFusion(deps, options, walletAddress) {
2790
2935
  to: { chainId: quote.dst.chainId, symbol: quote.dst.symbol },
2791
2936
  walletAddress,
2792
2937
  secretHashes,
2793
- ...direction ? { direction } : {}
2938
+ ...direction ? { direction } : {},
2939
+ ...permit ? { permit } : {}
2794
2940
  });
2795
2941
  saveOrder({
2796
2942
  orderHash: built.orderHash,
@@ -2841,16 +2987,114 @@ async function runFusion(deps, options, walletAddress) {
2841
2987
  return { orderHash: built.orderHash };
2842
2988
  }
2843
2989
 
2990
+ // src/lib/swap/swap.permit.ts
2991
+ var import_viem5 = require("viem");
2992
+ var PERMIT_ABI = (0, import_viem5.parseAbi)([
2993
+ "function nonces(address owner) view returns (uint256)",
2994
+ "function name() view returns (string)",
2995
+ "function version() view returns (string)",
2996
+ "function DOMAIN_SEPARATOR() view returns (bytes32)"
2997
+ ]);
2998
+ var PERMIT_TYPES = {
2999
+ Permit: [
3000
+ { name: "owner", type: "address" },
3001
+ { name: "spender", type: "address" },
3002
+ { name: "value", type: "uint256" },
3003
+ { name: "nonce", type: "uint256" },
3004
+ { name: "deadline", type: "uint256" }
3005
+ ]
3006
+ };
3007
+ var VERSION_CANDIDATES = ["1", "2"];
3008
+ var PERMIT_TTL_SECONDS = 3600;
3009
+ async function tryRead(read) {
3010
+ try {
3011
+ return await read();
3012
+ } catch {
3013
+ return null;
3014
+ }
3015
+ }
3016
+ function matchDomain(name, versions, chainId, token, onChainSeparator) {
3017
+ for (const version of versions) {
3018
+ const domain = { name, version, chainId, verifyingContract: token };
3019
+ if ((0, import_viem5.domainSeparator)({ domain }) === onChainSeparator) return domain;
3020
+ }
3021
+ return null;
3022
+ }
3023
+ async function buildErc2612Permit(reads, input) {
3024
+ const { token, owner, spender, value, chainId } = input;
3025
+ const call = (functionName, args) => reads.readContract({
3026
+ address: token,
3027
+ abi: PERMIT_ABI,
3028
+ functionName,
3029
+ ...args ? { args } : {}
3030
+ });
3031
+ const nonce = await tryRead(() => call("nonces", [owner]));
3032
+ if (nonce === null) {
3033
+ debugLog("owney-sdk", "permit: token has no nonces(), using approval", {
3034
+ token
3035
+ });
3036
+ return null;
3037
+ }
3038
+ const separator = await tryRead(
3039
+ () => call("DOMAIN_SEPARATOR")
3040
+ );
3041
+ const name = await tryRead(() => call("name"));
3042
+ if (!separator || !name) {
3043
+ debugLog("owney-sdk", "permit: domain unverifiable, using approval", {
3044
+ token,
3045
+ hasSeparator: Boolean(separator),
3046
+ hasName: Boolean(name)
3047
+ });
3048
+ return null;
3049
+ }
3050
+ const declared = await tryRead(() => call("version"));
3051
+ const domain = matchDomain(
3052
+ name,
3053
+ declared ? [declared, ...VERSION_CANDIDATES] : VERSION_CANDIDATES,
3054
+ chainId,
3055
+ token,
3056
+ separator
3057
+ );
3058
+ if (!domain) {
3059
+ debugLog("owney-sdk", "permit: no domain matched, using approval", {
3060
+ token,
3061
+ declared
3062
+ });
3063
+ return null;
3064
+ }
3065
+ const deadline = BigInt(Math.floor((input.now?.() ?? Date.now()) / 1e3)) + BigInt(PERMIT_TTL_SECONDS);
3066
+ const signature = await input.signTypedData({
3067
+ domain,
3068
+ types: PERMIT_TYPES,
3069
+ primaryType: "Permit",
3070
+ message: { owner, spender, value, nonce, deadline }
3071
+ });
3072
+ const { r, s, v, yParity } = (0, import_viem5.parseSignature)(signature);
3073
+ const recoveryV = v ?? BigInt(yParity + 27);
3074
+ return (0, import_viem5.encodeAbiParameters)(
3075
+ [
3076
+ { type: "address" },
3077
+ { type: "address" },
3078
+ { type: "uint256" },
3079
+ { type: "uint256" },
3080
+ { type: "uint8" },
3081
+ { type: "bytes32" },
3082
+ { type: "bytes32" }
3083
+ ],
3084
+ [owner, spender, value, deadline, Number(recoveryV), r, s]
3085
+ );
3086
+ }
3087
+
2844
3088
  // src/lib/swap/swap.arrival.ts
2845
- var DEFAULT_TIMEOUT_MS2 = 18e4;
2846
- var DEFAULT_POLL_MS2 = 4e3;
3089
+ var DEFAULT_TIMEOUT_MS3 = 18e4;
3090
+ var DEFAULT_POLL_MS3 = 4e3;
2847
3091
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
2848
3092
  async function awaitWithdrawalArrival(options) {
2849
3093
  const {
2850
3094
  readBalance,
2851
3095
  baseline,
2852
- timeoutMs = DEFAULT_TIMEOUT_MS2,
2853
- pollMs = DEFAULT_POLL_MS2
3096
+ timeoutMs = DEFAULT_TIMEOUT_MS3,
3097
+ pollMs = DEFAULT_POLL_MS3
2854
3098
  } = options;
2855
3099
  const deadline = Date.now() + timeoutMs;
2856
3100
  debugLog("owney-sdk", "withdraw: waiting for funds to land", {
@@ -2925,7 +3169,7 @@ async function withFailureReporting(apiKey, agentType, fn, baseUrl) {
2925
3169
  }
2926
3170
 
2927
3171
  // src/lib/helpers/withdraw-helper.ts
2928
- var import_viem5 = require("viem");
3172
+ var import_viem6 = require("viem");
2929
3173
  function projectAgentBalancesForAsset(agents, aggregated, chainId, asset, decimals) {
2930
3174
  const target = asset.toUpperCase();
2931
3175
  return agents.map((agent) => {
@@ -2934,7 +3178,7 @@ function projectAgentBalancesForAsset(agents, aggregated, chainId, asset, decima
2934
3178
  (t) => t.chainId === chainId && t.asset.toUpperCase() === target
2935
3179
  );
2936
3180
  if (!tokenBalance) return { agent, balance: 0n };
2937
- return { agent, balance: (0, import_viem5.parseUnits)(tokenBalance.amount, decimals) };
3181
+ return { agent, balance: (0, import_viem6.parseUnits)(tokenBalance.amount, decimals) };
2938
3182
  });
2939
3183
  }
2940
3184
  function planProportionalShares(balances, requested, totalAvailable) {
@@ -3081,11 +3325,11 @@ function aggregateApyByChainAndAsset(agentApys, agentBalances) {
3081
3325
  }
3082
3326
 
3083
3327
  // src/client.ts
3084
- var import_viem8 = require("viem");
3328
+ var import_viem9 = require("viem");
3085
3329
  var import_chains2 = require("viem/chains");
3086
3330
 
3087
3331
  // src/lib/transfer-auth.ts
3088
- var import_viem6 = require("viem");
3332
+ var import_viem7 = require("viem");
3089
3333
  var ERC20_META_ABI = [
3090
3334
  { type: "function", name: "name", stateMutability: "view", inputs: [], outputs: [{ name: "", type: "string" }] },
3091
3335
  { type: "function", name: "version", stateMutability: "view", inputs: [], outputs: [{ name: "", type: "string" }] }
@@ -3117,7 +3361,7 @@ async function readTokenMeta(publicClient, token) {
3117
3361
  function randomAuthNonce() {
3118
3362
  const bytes = new Uint8Array(32);
3119
3363
  globalThis.crypto.getRandomValues(bytes);
3120
- return (0, import_viem6.bytesToHex)(bytes);
3364
+ return (0, import_viem7.bytesToHex)(bytes);
3121
3365
  }
3122
3366
 
3123
3367
  // src/lib/sponsor-client.ts
@@ -3399,7 +3643,7 @@ function makeSponsoredWethCallback(deps) {
3399
3643
  }
3400
3644
 
3401
3645
  // src/lib/sponsored-calls-deposit.ts
3402
- var import_viem7 = require("viem");
3646
+ var import_viem8 = require("viem");
3403
3647
  var DEFAULT_POLL_INTERVAL_MS = 1500;
3404
3648
  var DEFAULT_MAX_POLLS = 30;
3405
3649
  async function paymasterSupported(provider, owner, chainId) {
@@ -3407,7 +3651,7 @@ async function paymasterSupported(provider, owner, chainId) {
3407
3651
  method: "wallet_getCapabilities",
3408
3652
  params: [owner]
3409
3653
  });
3410
- const forChain = caps?.[(0, import_viem7.toHex)(chainId)] ?? caps?.[String(chainId)];
3654
+ const forChain = caps?.[(0, import_viem8.toHex)(chainId)] ?? caps?.[String(chainId)];
3411
3655
  return Boolean(forChain?.paymasterService?.supported);
3412
3656
  }
3413
3657
  function makeSponsoredCallsCallback(deps) {
@@ -3441,8 +3685,8 @@ function makeSponsoredCallsCallback(deps) {
3441
3685
  { chainId }
3442
3686
  );
3443
3687
  }
3444
- const data = (0, import_viem7.encodeFunctionData)({
3445
- abi: import_viem7.erc20Abi,
3688
+ const data = (0, import_viem8.encodeFunctionData)({
3689
+ abi: import_viem8.erc20Abi,
3446
3690
  functionName: "transfer",
3447
3691
  args: [smartWallet, BigInt(amount)]
3448
3692
  });
@@ -3452,7 +3696,7 @@ function makeSponsoredCallsCallback(deps) {
3452
3696
  {
3453
3697
  version: "2.0.0",
3454
3698
  from: deps.ownerAddress,
3455
- chainId: (0, import_viem7.toHex)(chainId),
3699
+ chainId: (0, import_viem8.toHex)(chainId),
3456
3700
  atomicRequired: false,
3457
3701
  calls: [{ to: token, value: "0x0", data }],
3458
3702
  capabilities: {
@@ -3674,14 +3918,14 @@ var OwneySDK = class {
3674
3918
  // Casts work around viem's chain-narrowed Client vs the generic
3675
3919
  // PublicClient/WalletClient param types — structurally identical at
3676
3920
  // runtime, but the two share a name TS treats as unrelated.
3677
- getPublicClient: (cid) => (0, import_viem8.createPublicClient)({
3921
+ getPublicClient: (cid) => (0, import_viem9.createPublicClient)({
3678
3922
  chain: VIEM_CHAIN2[cid],
3679
- transport: (0, import_viem8.custom)(provider)
3923
+ transport: (0, import_viem9.custom)(provider)
3680
3924
  }),
3681
- getWalletClient: (cid) => (0, import_viem8.createWalletClient)({
3925
+ getWalletClient: (cid) => (0, import_viem9.createWalletClient)({
3682
3926
  account: owner,
3683
3927
  chain: VIEM_CHAIN2[cid],
3684
- transport: (0, import_viem8.custom)(provider)
3928
+ transport: (0, import_viem9.custom)(provider)
3685
3929
  })
3686
3930
  });
3687
3931
  if (!onApproved) this.cachedSponsoredCallback = callback;
@@ -3727,14 +3971,14 @@ var OwneySDK = class {
3727
3971
  // Casts work around viem's chain-narrowed Client vs the generic
3728
3972
  // PublicClient/WalletClient param types — structurally identical at
3729
3973
  // runtime, but the two share a name TS treats as unrelated.
3730
- getPublicClient: (cid) => (0, import_viem8.createPublicClient)({
3974
+ getPublicClient: (cid) => (0, import_viem9.createPublicClient)({
3731
3975
  chain: VIEM_CHAIN2[cid],
3732
- transport: (0, import_viem8.custom)(provider)
3976
+ transport: (0, import_viem9.custom)(provider)
3733
3977
  }),
3734
- getWalletClient: (cid) => (0, import_viem8.createWalletClient)({
3978
+ getWalletClient: (cid) => (0, import_viem9.createWalletClient)({
3735
3979
  account: owner,
3736
3980
  chain: VIEM_CHAIN2[cid],
3737
- transport: (0, import_viem8.custom)(provider)
3981
+ transport: (0, import_viem9.custom)(provider)
3738
3982
  })
3739
3983
  });
3740
3984
  if (!onApproved) this.cachedWethSponsoredCallback = callback;
@@ -4316,11 +4560,11 @@ var OwneySDK = class {
4316
4560
  );
4317
4561
  }
4318
4562
  await ensureWalletOnChain(
4319
- (0, import_viem8.createPublicClient)({ chain, transport: (0, import_viem8.custom)(provider) }),
4320
- (0, import_viem8.createWalletClient)({
4563
+ (0, import_viem9.createPublicClient)({ chain, transport: (0, import_viem9.custom)(provider) }),
4564
+ (0, import_viem9.createWalletClient)({
4321
4565
  account: state.walletAddress,
4322
4566
  chain,
4323
- transport: (0, import_viem8.custom)(provider)
4567
+ transport: (0, import_viem9.custom)(provider)
4324
4568
  }),
4325
4569
  chainId
4326
4570
  );
@@ -4336,16 +4580,16 @@ var OwneySDK = class {
4336
4580
  const provider = this.requireConnectedProvider();
4337
4581
  const srcChain = VIEM_CHAIN2[quote.src.chainId];
4338
4582
  const dstChain = VIEM_CHAIN2[quote.dst.chainId];
4339
- const wallet = (0, import_viem8.createWalletClient)({
4583
+ const wallet = (0, import_viem9.createWalletClient)({
4340
4584
  account: state.walletAddress,
4341
4585
  chain: srcChain,
4342
- transport: (0, import_viem8.custom)(provider)
4586
+ transport: (0, import_viem9.custom)(provider)
4343
4587
  });
4344
- const srcPublic = (0, import_viem8.createPublicClient)({
4588
+ const srcPublic = (0, import_viem9.createPublicClient)({
4345
4589
  chain: srcChain,
4346
4590
  transport: swapReadTransport(quote.src.chainId, this.zyfaiRpcUrls)
4347
4591
  });
4348
- const dstPublic = (0, import_viem8.createPublicClient)({
4592
+ const dstPublic = (0, import_viem9.createPublicClient)({
4349
4593
  chain: dstChain,
4350
4594
  transport: swapReadTransport(quote.dst.chainId, this.zyfaiRpcUrls)
4351
4595
  });
@@ -4362,7 +4606,7 @@ var OwneySDK = class {
4362
4606
  }
4363
4607
  return dstPublic.readContract({
4364
4608
  address: dst,
4365
- abi: import_viem8.erc20Abi,
4609
+ abi: import_viem9.erc20Abi,
4366
4610
  functionName: "balanceOf",
4367
4611
  args: [state.walletAddress]
4368
4612
  });
@@ -4430,6 +4674,41 @@ var OwneySDK = class {
4430
4674
  return hash;
4431
4675
  },
4432
4676
  ensureChain: (chainId) => this.ensureSwapChain(chainId),
4677
+ /**
4678
+ * Sign the approval instead of paying for it. Null means the token
4679
+ * cannot be permitted (WETH has no `permit`) or its domain could not be
4680
+ * verified, and the executor sends an ordinary approval instead.
4681
+ *
4682
+ * Reads go through srcPublic like every other read here — the domain is
4683
+ * chain-scoped, and reading a nonce off whatever chain the wallet
4684
+ * happens to sit on would sign a permit the token rejects.
4685
+ */
4686
+ buildPermit: (spender, amount) => buildErc2612Permit(
4687
+ {
4688
+ readContract: (args) => srcPublic.readContract(args)
4689
+ },
4690
+ {
4691
+ token: quote.src.address,
4692
+ owner: state.walletAddress,
4693
+ spender,
4694
+ value: amount,
4695
+ chainId: quote.src.chainId,
4696
+ signTypedData: (payload) => wallet.signTypedData({
4697
+ account: state.walletAddress,
4698
+ ...payload
4699
+ })
4700
+ }
4701
+ ),
4702
+ /**
4703
+ * Same-chain polling, with the chain bound in. That product keys its
4704
+ * orders per chain; asking the Fusion+ endpoint for one answers 404,
4705
+ * which would read as an order that vanished mid-swap.
4706
+ */
4707
+ sameChainRunner: {
4708
+ orderStatus: (h) => this.swapApi().orderStatus(h, quote.src.chainId),
4709
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
4710
+ now: () => Date.now()
4711
+ },
4433
4712
  runner: {
4434
4713
  readyForSecrets: (h) => this.swapApi().readyForSecrets(h),
4435
4714
  submitSecret: (h, secret) => this.swapApi().submitSecret(h, secret),
@@ -4448,6 +4727,39 @@ var OwneySDK = class {
4448
4727
  async getSwapTokens() {
4449
4728
  return this.swapApi().listTokens();
4450
4729
  }
4730
+ /**
4731
+ * What the wallet actually holds, across every supported chain, restricted
4732
+ * to tokens a swap could use.
4733
+ *
4734
+ * `getSwapTokens` answers "what may someone pay with in principle" from a
4735
+ * fixed list. This answers "what has this wallet got", which is a different
4736
+ * question and the one a picker needs: a user can hold something routable —
4737
+ * tBTC, say — that no fixed list of ours would ever mention.
4738
+ *
4739
+ * Each chain is asked separately because that is how the balance product is
4740
+ * shaped, and a chain that fails contributes nothing rather than failing the
4741
+ * lot. A picker with two chains in it beats an error. (ROUT-242)
4742
+ */
4743
+ async getWalletBalances(chainIds) {
4744
+ const state = this.requireState();
4745
+ const chains = chainIds ?? SUPPORTED_CHAIN_IDS;
4746
+ const failedChainIds = [];
4747
+ const results = await Promise.all(
4748
+ chains.map(async (chainId) => {
4749
+ try {
4750
+ const { tokens } = await this.swapApi().walletBalances({
4751
+ chainId,
4752
+ walletAddress: state.walletAddress
4753
+ });
4754
+ return tokens.map((token) => ({ ...token, chainId }));
4755
+ } catch {
4756
+ failedChainIds.push(chainId);
4757
+ return [];
4758
+ }
4759
+ })
4760
+ );
4761
+ return { tokens: results.flat(), failedChainIds };
4762
+ }
4451
4763
  /**
4452
4764
  * Search the assets a user may pay with, across every supported chain.
4453
4765
  *
@@ -4575,13 +4887,13 @@ var OwneySDK = class {
4575
4887
  );
4576
4888
  }
4577
4889
  const srcChain = VIEM_CHAIN2[options.from.chainId];
4578
- const srcPublic = (0, import_viem8.createPublicClient)({
4890
+ const srcPublic = (0, import_viem9.createPublicClient)({
4579
4891
  chain: srcChain,
4580
4892
  transport: swapReadTransport(options.from.chainId, this.zyfaiRpcUrls)
4581
4893
  });
4582
4894
  const readWalletBalance = () => srcPublic.readContract({
4583
4895
  address: asset.address,
4584
- abi: import_viem8.erc20Abi,
4896
+ abi: import_viem9.erc20Abi,
4585
4897
  functionName: "balanceOf",
4586
4898
  args: [state.walletAddress]
4587
4899
  });
@@ -4593,7 +4905,7 @@ var OwneySDK = class {
4593
4905
  options.onSwapProgress?.("withdrawing");
4594
4906
  const withdraw = await this.withdraw({
4595
4907
  asset: options.from.symbol,
4596
- ...options.amount === void 0 ? {} : { amount: (0, import_viem8.parseUnits)(options.amount, asset.decimals).toString() },
4908
+ ...options.amount === void 0 ? {} : { amount: (0, import_viem9.parseUnits)(options.amount, asset.decimals).toString() },
4597
4909
  ...options.agentId ? { agentId: options.agentId } : {}
4598
4910
  });
4599
4911
  const arrived = await awaitWithdrawalArrival({
@@ -5229,10 +5541,10 @@ var OwneySDK = class {
5229
5541
  );
5230
5542
  }
5231
5543
  const provider = this.requireConnectedProvider();
5232
- const wallet = (0, import_viem8.createWalletClient)({
5544
+ const wallet = (0, import_viem9.createWalletClient)({
5233
5545
  account: state.walletAddress,
5234
5546
  chain: VIEM_CHAIN2[chainId],
5235
- transport: (0, import_viem8.custom)(provider)
5547
+ transport: (0, import_viem9.custom)(provider)
5236
5548
  });
5237
5549
  const hash = await wallet.writeContract({
5238
5550
  address: token,
@@ -5242,9 +5554,9 @@ var OwneySDK = class {
5242
5554
  account: state.walletAddress,
5243
5555
  chain: VIEM_CHAIN2[chainId]
5244
5556
  });
5245
- const publicClient = (0, import_viem8.createPublicClient)({
5557
+ const publicClient = (0, import_viem9.createPublicClient)({
5246
5558
  chain: VIEM_CHAIN2[chainId],
5247
- transport: (0, import_viem8.custom)(provider)
5559
+ transport: (0, import_viem9.custom)(provider)
5248
5560
  });
5249
5561
  const receipt = await publicClient.waitForTransactionReceipt({
5250
5562
  hash,
@@ -5370,7 +5682,7 @@ var OwneySDK = class {
5370
5682
  };
5371
5683
 
5372
5684
  // src/agents/zyfai/zyfai.siwx.ts
5373
- var import_viem9 = require("viem");
5685
+ var import_viem10 = require("viem");
5374
5686
  var import_siwe = require("siwe");
5375
5687
  var import_sdk2 = require("@zyfai/sdk");
5376
5688
 
@@ -5497,7 +5809,7 @@ function buildSIWXConfig(deps) {
5497
5809
  issuedAt,
5498
5810
  toString() {
5499
5811
  return new import_siwe.SiweMessage({
5500
- address: (0, import_viem9.getAddress)(accountAddress),
5812
+ address: (0, import_viem10.getAddress)(accountAddress),
5501
5813
  chainId: numericChainId(chainId),
5502
5814
  domain,
5503
5815
  uri,
package/dist/index.d.cts CHANGED
@@ -13,11 +13,16 @@ import { SIWXConfig } from '@reown/appkit-controllers';
13
13
  * `classic` — same chain. One atomic transaction: it either completes or
14
14
  * nothing moved.
15
15
  *
16
+ * `fusion` — same chain, filled by a resolver against a signed intent. No
17
+ * transaction from the user, so no native gas: that is the entire reason it
18
+ * exists. The trade is certainty — the order sits in an auction and can expire
19
+ * unfilled, where a classic swap resolves immediately either way.
20
+ *
16
21
  * `fusion-plus` — crossing chains. The user's funds sit in an escrow while a
17
22
  * resolver fills the other side, so the order has a lifecycle and can end in
18
23
  * `expired` → `refunding` → `refunded` without ever depositing.
19
24
  */
20
- type SwapRail = "classic" | "fusion-plus";
25
+ type SwapRail = "classic" | "fusion" | "fusion-plus";
21
26
  type SwapTokenInfo = {
22
27
  readonly symbol: string;
23
28
  readonly address: string;
@@ -49,6 +54,26 @@ type SwapChainTokens = {
49
54
  * the bulk list does, and the extra fields are the ones a picker needs to let
50
55
  * someone choose safely between three tokens all called PEPE.
51
56
  */
57
+ /**
58
+ * A token the wallet actually holds, as the Pay with picker needs it.
59
+ *
60
+ * The picker used to read a fixed list of (token, chain) pairs, so it could
61
+ * only ever offer those. This is the other direction — what is really there —
62
+ * already intersected server-side with what the routing API would quote, so
63
+ * every row can be acted on. (ROUT-242)
64
+ */
65
+ type SwapWalletBalance = {
66
+ readonly symbol: string;
67
+ readonly address: `0x${string}`;
68
+ readonly decimals: number;
69
+ readonly chainId: number;
70
+ readonly name?: string;
71
+ readonly logoURI?: string;
72
+ readonly tags?: readonly string[];
73
+ readonly isNative?: true;
74
+ /** Smallest unit, as a decimal string. */
75
+ readonly balance: string;
76
+ };
52
77
  type SwapTokenSearchResult = SwapTokenInfo & {
53
78
  readonly chainId: number;
54
79
  readonly name?: string;
@@ -110,6 +135,17 @@ type SwapQuote = {
110
135
  * calldata instead.
111
136
  */
112
137
  spender?: string;
138
+ /**
139
+ * Cross-chain only. True when the routing API will accept an EIP-2612 permit
140
+ * on the order, so the approval is performed by the resolver inside the fill
141
+ * and the user needs no native ETH. False or absent means send an approval
142
+ * transaction, which is what every swap did before ROUT-242.
143
+ *
144
+ * It has to arrive on the QUOTE rather than being tried and recovered from:
145
+ * the client decides whether to spend the user's gas before the order it
146
+ * would be attached to exists.
147
+ */
148
+ permitSupported?: boolean;
113
149
  /**
114
150
  * True only for a cross-chain swap FROM native ETH, which needs an on-chain
115
151
  * order creation carrying the full amount as msg.value. The user's funds
@@ -155,7 +191,7 @@ type SwapQuote = {
155
191
  * `refunding` is the window the returning-funds screen renders: the order has
156
192
  * failed and the money is on its way back, but is not back yet.
157
193
  */
158
- type SwapOrderStatus = "pending" | "executed" | "expired" | "cancelled" | "refunding" | "refunded" | "unpublished";
194
+ type SwapOrderStatus = "pending" | "executed" | "expired" | "cancelled" | "refunding" | "refunded" | "unpublished" | "filled" | "partially-filled" | "false-predicate" | "not-enough-balance-or-allowance" | "wrong-permit" | "invalid-signature";
159
195
  /**
160
196
  * Stage reported to the UI while a swap runs, in either direction.
161
197
  *
@@ -909,6 +945,24 @@ declare class OwneySDK {
909
945
  getSwapTokens(): Promise<{
910
946
  chains: SwapChainTokens[];
911
947
  }>;
948
+ /**
949
+ * What the wallet actually holds, across every supported chain, restricted
950
+ * to tokens a swap could use.
951
+ *
952
+ * `getSwapTokens` answers "what may someone pay with in principle" from a
953
+ * fixed list. This answers "what has this wallet got", which is a different
954
+ * question and the one a picker needs: a user can hold something routable —
955
+ * tBTC, say — that no fixed list of ours would ever mention.
956
+ *
957
+ * Each chain is asked separately because that is how the balance product is
958
+ * shaped, and a chain that fails contributes nothing rather than failing the
959
+ * lot. A picker with two chains in it beats an error. (ROUT-242)
960
+ */
961
+ getWalletBalances(chainIds?: readonly number[]): Promise<{
962
+ tokens: SwapWalletBalance[];
963
+ /** Chains that could not be read, so the caller can say so or retry. */
964
+ failedChainIds: number[];
965
+ }>;
912
966
  /**
913
967
  * Search the assets a user may pay with, across every supported chain.
914
968
  *
package/dist/index.d.ts CHANGED
@@ -13,11 +13,16 @@ import { SIWXConfig } from '@reown/appkit-controllers';
13
13
  * `classic` — same chain. One atomic transaction: it either completes or
14
14
  * nothing moved.
15
15
  *
16
+ * `fusion` — same chain, filled by a resolver against a signed intent. No
17
+ * transaction from the user, so no native gas: that is the entire reason it
18
+ * exists. The trade is certainty — the order sits in an auction and can expire
19
+ * unfilled, where a classic swap resolves immediately either way.
20
+ *
16
21
  * `fusion-plus` — crossing chains. The user's funds sit in an escrow while a
17
22
  * resolver fills the other side, so the order has a lifecycle and can end in
18
23
  * `expired` → `refunding` → `refunded` without ever depositing.
19
24
  */
20
- type SwapRail = "classic" | "fusion-plus";
25
+ type SwapRail = "classic" | "fusion" | "fusion-plus";
21
26
  type SwapTokenInfo = {
22
27
  readonly symbol: string;
23
28
  readonly address: string;
@@ -49,6 +54,26 @@ type SwapChainTokens = {
49
54
  * the bulk list does, and the extra fields are the ones a picker needs to let
50
55
  * someone choose safely between three tokens all called PEPE.
51
56
  */
57
+ /**
58
+ * A token the wallet actually holds, as the Pay with picker needs it.
59
+ *
60
+ * The picker used to read a fixed list of (token, chain) pairs, so it could
61
+ * only ever offer those. This is the other direction — what is really there —
62
+ * already intersected server-side with what the routing API would quote, so
63
+ * every row can be acted on. (ROUT-242)
64
+ */
65
+ type SwapWalletBalance = {
66
+ readonly symbol: string;
67
+ readonly address: `0x${string}`;
68
+ readonly decimals: number;
69
+ readonly chainId: number;
70
+ readonly name?: string;
71
+ readonly logoURI?: string;
72
+ readonly tags?: readonly string[];
73
+ readonly isNative?: true;
74
+ /** Smallest unit, as a decimal string. */
75
+ readonly balance: string;
76
+ };
52
77
  type SwapTokenSearchResult = SwapTokenInfo & {
53
78
  readonly chainId: number;
54
79
  readonly name?: string;
@@ -110,6 +135,17 @@ type SwapQuote = {
110
135
  * calldata instead.
111
136
  */
112
137
  spender?: string;
138
+ /**
139
+ * Cross-chain only. True when the routing API will accept an EIP-2612 permit
140
+ * on the order, so the approval is performed by the resolver inside the fill
141
+ * and the user needs no native ETH. False or absent means send an approval
142
+ * transaction, which is what every swap did before ROUT-242.
143
+ *
144
+ * It has to arrive on the QUOTE rather than being tried and recovered from:
145
+ * the client decides whether to spend the user's gas before the order it
146
+ * would be attached to exists.
147
+ */
148
+ permitSupported?: boolean;
113
149
  /**
114
150
  * True only for a cross-chain swap FROM native ETH, which needs an on-chain
115
151
  * order creation carrying the full amount as msg.value. The user's funds
@@ -155,7 +191,7 @@ type SwapQuote = {
155
191
  * `refunding` is the window the returning-funds screen renders: the order has
156
192
  * failed and the money is on its way back, but is not back yet.
157
193
  */
158
- type SwapOrderStatus = "pending" | "executed" | "expired" | "cancelled" | "refunding" | "refunded" | "unpublished";
194
+ type SwapOrderStatus = "pending" | "executed" | "expired" | "cancelled" | "refunding" | "refunded" | "unpublished" | "filled" | "partially-filled" | "false-predicate" | "not-enough-balance-or-allowance" | "wrong-permit" | "invalid-signature";
159
195
  /**
160
196
  * Stage reported to the UI while a swap runs, in either direction.
161
197
  *
@@ -909,6 +945,24 @@ declare class OwneySDK {
909
945
  getSwapTokens(): Promise<{
910
946
  chains: SwapChainTokens[];
911
947
  }>;
948
+ /**
949
+ * What the wallet actually holds, across every supported chain, restricted
950
+ * to tokens a swap could use.
951
+ *
952
+ * `getSwapTokens` answers "what may someone pay with in principle" from a
953
+ * fixed list. This answers "what has this wallet got", which is a different
954
+ * question and the one a picker needs: a user can hold something routable —
955
+ * tBTC, say — that no fixed list of ours would ever mention.
956
+ *
957
+ * Each chain is asked separately because that is how the balance product is
958
+ * shaped, and a chain that fails contributes nothing rather than failing the
959
+ * lot. A picker with two chains in it beats an error. (ROUT-242)
960
+ */
961
+ getWalletBalances(chainIds?: readonly number[]): Promise<{
962
+ tokens: SwapWalletBalance[];
963
+ /** Chains that could not be read, so the caller can say so or retry. */
964
+ failedChainIds: number[];
965
+ }>;
912
966
  /**
913
967
  * Search the assets a user may pay with, across every supported chain.
914
968
  *
package/dist/index.js CHANGED
@@ -2275,6 +2275,21 @@ function createSwapApi(baseUrl, apiKey) {
2275
2275
  * Already filtered server-side to what a quote will accept, so anything
2276
2276
  * returned here can be paid with.
2277
2277
  */
2278
+ /**
2279
+ * Tokens the wallet holds on one chain.
2280
+ *
2281
+ * Already filtered upstream to non-zero balances of tokens the routing API
2282
+ * would quote — 1inch answers with every token it knows about, zeros
2283
+ * included, and the curation that decides what is offerable lives there.
2284
+ */
2285
+ walletBalances: (params) => request(
2286
+ baseUrl,
2287
+ apiKey,
2288
+ `/balances?${new URLSearchParams({
2289
+ chainId: String(params.chainId),
2290
+ walletAddress: params.walletAddress
2291
+ })}`
2292
+ ),
2278
2293
  searchTokens: (params) => request(
2279
2294
  baseUrl,
2280
2295
  apiKey,
@@ -2332,9 +2347,10 @@ function createSwapApi(baseUrl, apiKey) {
2332
2347
  dstSymbol: params.to.symbol,
2333
2348
  amount: params.from.amount,
2334
2349
  walletAddress: params.walletAddress,
2335
- secretHashes: params.secretHashes,
2350
+ ...params.secretHashes ? { secretHashes: params.secretHashes } : {},
2336
2351
  ...params.direction ? { direction: params.direction } : {},
2337
- ...params.receiver ? { receiver: params.receiver } : {}
2352
+ ...params.receiver ? { receiver: params.receiver } : {},
2353
+ ...params.permit ? { permit: params.permit } : {}
2338
2354
  }
2339
2355
  }),
2340
2356
  submitOrder: (body) => request(baseUrl, apiKey, "/order", { method: "POST", body }),
@@ -2347,7 +2363,16 @@ function createSwapApi(baseUrl, apiKey) {
2347
2363
  method: "POST",
2348
2364
  body: { orderHash, secret }
2349
2365
  }),
2350
- orderStatus: (orderHash) => request(baseUrl, apiKey, `/order/${orderHash}`),
2366
+ /**
2367
+ * @param chainId present only for a SAME-CHAIN order. That product keys its
2368
+ * orders per chain and the Fusion+ endpoint does not know them, so asking
2369
+ * without it answers 404 — which reads as an order that vanished.
2370
+ */
2371
+ orderStatus: (orderHash, chainId) => request(
2372
+ baseUrl,
2373
+ apiKey,
2374
+ chainId === void 0 ? `/order/${orderHash}` : `/order/${orderHash}?chainId=${chainId}`
2375
+ ),
2351
2376
  readyForSecrets: (orderHash) => request(
2352
2377
  baseUrl,
2353
2378
  apiKey,
@@ -2490,7 +2515,12 @@ var SWAP_TERMINAL_STATUSES = [
2490
2515
  "executed",
2491
2516
  "expired",
2492
2517
  "cancelled",
2493
- "refunded"
2518
+ "refunded",
2519
+ "filled",
2520
+ "false-predicate",
2521
+ "not-enough-balance-or-allowance",
2522
+ "wrong-permit",
2523
+ "invalid-signature"
2494
2524
  ];
2495
2525
  var isSwapTerminal = (status) => SWAP_TERMINAL_STATUSES.includes(status);
2496
2526
 
@@ -2568,6 +2598,71 @@ async function runFusionOrder(deps, options) {
2568
2598
  }
2569
2599
  }
2570
2600
 
2601
+ // src/lib/swap/swap.same-chain-runner.ts
2602
+ var DEFAULT_POLL_MS2 = 5e3;
2603
+ var MAX_BACKOFF_MS2 = 3e4;
2604
+ var backoffFor2 = (failures, base3) => Math.min(base3 * 2 ** Math.min(failures, 5), MAX_BACKOFF_MS2);
2605
+ var DEFAULT_TIMEOUT_MS2 = 3 * 60 * 1e3;
2606
+ function explain(status) {
2607
+ switch (status) {
2608
+ case "expired":
2609
+ return "No one filled the swap in time, so nothing was exchanged. Your funds never left your wallet.";
2610
+ case "cancelled":
2611
+ return "The swap was cancelled before anyone filled it. Your funds never left your wallet.";
2612
+ case "not-enough-balance-or-allowance":
2613
+ return "The swap could not be filled because the balance or approval had changed since it was signed. Nothing was exchanged.";
2614
+ case "wrong-permit":
2615
+ return "The approval signed with this swap was not accepted. Nothing was exchanged \u2014 try again, approving in your wallet if you are asked to.";
2616
+ case "invalid-signature":
2617
+ return "The swap signature was rejected. Nothing was exchanged.";
2618
+ case "false-predicate":
2619
+ return "The swap's conditions no longer held when it was filled. Nothing was exchanged.";
2620
+ default:
2621
+ return "The swap did not complete. Nothing was exchanged.";
2622
+ }
2623
+ }
2624
+ async function runSameChainOrder(deps, options) {
2625
+ const {
2626
+ orderHash,
2627
+ onStage,
2628
+ pollIntervalMs = DEFAULT_POLL_MS2,
2629
+ timeoutMs = DEFAULT_TIMEOUT_MS2
2630
+ } = options;
2631
+ const deadline = deps.now() + timeoutMs;
2632
+ let failures = 0;
2633
+ onStage?.("swapping");
2634
+ for (; ; ) {
2635
+ if (deps.now() >= deadline) {
2636
+ throw new OwneyError(
2637
+ "SWAP_ORDER_EXPIRED",
2638
+ "No one filled the swap in time. Nothing was exchanged and your funds are still in your wallet.",
2639
+ { orderHash }
2640
+ );
2641
+ }
2642
+ let status;
2643
+ try {
2644
+ ({ status } = await deps.orderStatus(orderHash));
2645
+ failures = 0;
2646
+ } catch {
2647
+ failures += 1;
2648
+ await deps.sleep(backoffFor2(failures, pollIntervalMs));
2649
+ continue;
2650
+ }
2651
+ if (isSwapTerminal(status)) {
2652
+ if (status === "filled" || status === "executed") {
2653
+ onStage?.("swapped");
2654
+ return { status, filled: true };
2655
+ }
2656
+ throw new OwneyError(
2657
+ status === "cancelled" ? "SWAP_ORDER_CANCELLED" : "SWAP_ORDER_EXPIRED",
2658
+ explain(status),
2659
+ { orderHash, status }
2660
+ );
2661
+ }
2662
+ await deps.sleep(pollIntervalMs);
2663
+ }
2664
+ }
2665
+
2571
2666
  // src/lib/swap/swap.secret-store.ts
2572
2667
  var KEY_PREFIX2 = "owney.swap.order";
2573
2668
  var MAX_AGE_MS = 24 * 60 * 60 * 1e3;
@@ -2651,7 +2746,7 @@ async function executeSwap(deps, options) {
2651
2746
  });
2652
2747
  const before = await deps.readTargetBalance();
2653
2748
  debugLog("owney-sdk", "swap: target balance before", before.toString());
2654
- const result = quote.rail === "classic" ? await runClassic(deps, options) : await runFusion(deps, options, walletAddress);
2749
+ const result = quote.rail === "classic" ? await runClassic(deps, options) : quote.rail === "fusion" ? await runSameChainFusion(deps, options, walletAddress) : await runFusion(deps, options, walletAddress);
2655
2750
  const after = await deps.readTargetBalance();
2656
2751
  const received = after - before;
2657
2752
  debugLog("owney-sdk", "swap: target balance after", {
@@ -2722,25 +2817,75 @@ async function runClassic(deps, options) {
2722
2817
  onStage?.("swapped");
2723
2818
  return { txHash };
2724
2819
  }
2725
- async function runFusion(deps, options, walletAddress) {
2820
+ async function runSameChainFusion(deps, options, walletAddress) {
2726
2821
  const { quote, direction, onStage } = options;
2727
2822
  const isNativeSource = quote.src.address.toLowerCase().startsWith("0xeeee");
2728
2823
  const amount = isNativeSource ? BigInt(quote.src.amount) : await affordableAmount(deps, BigInt(quote.src.amount));
2729
- if (quote.spender && !isNativeSource) {
2730
- const needed = amount;
2731
- const current = await deps.readAllowance(quote.spender);
2732
- debugLog("owney-sdk", "swap: fusion allowance", {
2733
- spender: quote.spender,
2734
- current: current.toString(),
2735
- needed: needed.toString()
2824
+ const permit = await authoriseSpend(deps, options, amount, isNativeSource);
2825
+ onStage?.("quoting");
2826
+ debugLog("owney-sdk", "swap: building same-chain fusion order");
2827
+ const built = await deps.api.buildOrder({
2828
+ from: {
2829
+ chainId: quote.src.chainId,
2830
+ symbol: quote.src.symbol,
2831
+ amount: amount.toString()
2832
+ },
2833
+ to: { chainId: quote.dst.chainId, symbol: quote.dst.symbol },
2834
+ walletAddress,
2835
+ ...direction ? { direction } : {},
2836
+ ...permit ? { permit } : {}
2837
+ });
2838
+ debugLog("owney-sdk", "swap: same-chain order built", {
2839
+ orderHash: built.orderHash
2840
+ });
2841
+ onStage?.("signing");
2842
+ await deps.ensureChain(quote.src.chainId);
2843
+ const signature = await deps.signTypedData(built.typedData);
2844
+ debugLog("owney-sdk", "swap: signed, submitting same-chain order");
2845
+ await deps.api.submitOrder({
2846
+ // Without this the routing API hands the order to the Fusion+ relayer,
2847
+ // which does not know it and answers with an error that names nothing.
2848
+ rail: "fusion",
2849
+ srcChainId: quote.src.chainId,
2850
+ order: built.order,
2851
+ signature,
2852
+ quoteId: built.quoteId,
2853
+ ...built.extension ? { extension: built.extension } : {}
2854
+ });
2855
+ await runSameChainOrder(deps.sameChainRunner ?? deps.runner, {
2856
+ orderHash: built.orderHash,
2857
+ ...onStage ? { onStage } : {}
2858
+ });
2859
+ return { orderHash: built.orderHash };
2860
+ }
2861
+ async function authoriseSpend(deps, options, amount, isNativeSource) {
2862
+ const { quote, onStage } = options;
2863
+ if (!quote.spender || isNativeSource) return void 0;
2864
+ const current = await deps.readAllowance(quote.spender);
2865
+ debugLog("owney-sdk", "swap: fusion allowance", {
2866
+ spender: quote.spender,
2867
+ current: current.toString(),
2868
+ needed: amount.toString()
2869
+ });
2870
+ if (current >= amount) return void 0;
2871
+ onStage?.("approving");
2872
+ await deps.ensureChain(quote.src.chainId);
2873
+ const permit = quote.permitSupported ? await deps.buildPermit?.(quote.spender, MAX_UINT256) ?? void 0 : void 0;
2874
+ if (permit) {
2875
+ debugLog("owney-sdk", "swap: permitting limit order protocol", {
2876
+ bytes: (permit.length - 2) / 2
2736
2877
  });
2737
- if (current < needed) {
2738
- onStage?.("approving");
2739
- await deps.ensureChain(quote.src.chainId);
2740
- await deps.approve(quote.spender, MAX_UINT256);
2741
- debugLog("owney-sdk", "swap: approved limit order protocol");
2742
- }
2878
+ return permit;
2743
2879
  }
2880
+ await deps.approve(quote.spender, MAX_UINT256);
2881
+ debugLog("owney-sdk", "swap: approved limit order protocol");
2882
+ return void 0;
2883
+ }
2884
+ async function runFusion(deps, options, walletAddress) {
2885
+ const { quote, direction, onStage } = options;
2886
+ const isNativeSource = quote.src.address.toLowerCase().startsWith("0xeeee");
2887
+ const amount = isNativeSource ? BigInt(quote.src.amount) : await affordableAmount(deps, BigInt(quote.src.amount));
2888
+ const permit = await authoriseSpend(deps, options, amount, isNativeSource);
2744
2889
  const { secrets, secretHashes } = mintSecrets(quote.secretsCount ?? 1);
2745
2890
  onStage?.("quoting");
2746
2891
  debugLog("owney-sdk", "swap: building fusion order", {
@@ -2756,7 +2901,8 @@ async function runFusion(deps, options, walletAddress) {
2756
2901
  to: { chainId: quote.dst.chainId, symbol: quote.dst.symbol },
2757
2902
  walletAddress,
2758
2903
  secretHashes,
2759
- ...direction ? { direction } : {}
2904
+ ...direction ? { direction } : {},
2905
+ ...permit ? { permit } : {}
2760
2906
  });
2761
2907
  saveOrder({
2762
2908
  orderHash: built.orderHash,
@@ -2807,16 +2953,119 @@ async function runFusion(deps, options, walletAddress) {
2807
2953
  return { orderHash: built.orderHash };
2808
2954
  }
2809
2955
 
2956
+ // src/lib/swap/swap.permit.ts
2957
+ import {
2958
+ domainSeparator,
2959
+ encodeAbiParameters,
2960
+ parseAbi as parseAbi2,
2961
+ parseSignature
2962
+ } from "viem";
2963
+ var PERMIT_ABI = parseAbi2([
2964
+ "function nonces(address owner) view returns (uint256)",
2965
+ "function name() view returns (string)",
2966
+ "function version() view returns (string)",
2967
+ "function DOMAIN_SEPARATOR() view returns (bytes32)"
2968
+ ]);
2969
+ var PERMIT_TYPES = {
2970
+ Permit: [
2971
+ { name: "owner", type: "address" },
2972
+ { name: "spender", type: "address" },
2973
+ { name: "value", type: "uint256" },
2974
+ { name: "nonce", type: "uint256" },
2975
+ { name: "deadline", type: "uint256" }
2976
+ ]
2977
+ };
2978
+ var VERSION_CANDIDATES = ["1", "2"];
2979
+ var PERMIT_TTL_SECONDS = 3600;
2980
+ async function tryRead(read) {
2981
+ try {
2982
+ return await read();
2983
+ } catch {
2984
+ return null;
2985
+ }
2986
+ }
2987
+ function matchDomain(name, versions, chainId, token, onChainSeparator) {
2988
+ for (const version of versions) {
2989
+ const domain = { name, version, chainId, verifyingContract: token };
2990
+ if (domainSeparator({ domain }) === onChainSeparator) return domain;
2991
+ }
2992
+ return null;
2993
+ }
2994
+ async function buildErc2612Permit(reads, input) {
2995
+ const { token, owner, spender, value, chainId } = input;
2996
+ const call = (functionName, args) => reads.readContract({
2997
+ address: token,
2998
+ abi: PERMIT_ABI,
2999
+ functionName,
3000
+ ...args ? { args } : {}
3001
+ });
3002
+ const nonce = await tryRead(() => call("nonces", [owner]));
3003
+ if (nonce === null) {
3004
+ debugLog("owney-sdk", "permit: token has no nonces(), using approval", {
3005
+ token
3006
+ });
3007
+ return null;
3008
+ }
3009
+ const separator = await tryRead(
3010
+ () => call("DOMAIN_SEPARATOR")
3011
+ );
3012
+ const name = await tryRead(() => call("name"));
3013
+ if (!separator || !name) {
3014
+ debugLog("owney-sdk", "permit: domain unverifiable, using approval", {
3015
+ token,
3016
+ hasSeparator: Boolean(separator),
3017
+ hasName: Boolean(name)
3018
+ });
3019
+ return null;
3020
+ }
3021
+ const declared = await tryRead(() => call("version"));
3022
+ const domain = matchDomain(
3023
+ name,
3024
+ declared ? [declared, ...VERSION_CANDIDATES] : VERSION_CANDIDATES,
3025
+ chainId,
3026
+ token,
3027
+ separator
3028
+ );
3029
+ if (!domain) {
3030
+ debugLog("owney-sdk", "permit: no domain matched, using approval", {
3031
+ token,
3032
+ declared
3033
+ });
3034
+ return null;
3035
+ }
3036
+ const deadline = BigInt(Math.floor((input.now?.() ?? Date.now()) / 1e3)) + BigInt(PERMIT_TTL_SECONDS);
3037
+ const signature = await input.signTypedData({
3038
+ domain,
3039
+ types: PERMIT_TYPES,
3040
+ primaryType: "Permit",
3041
+ message: { owner, spender, value, nonce, deadline }
3042
+ });
3043
+ const { r, s, v, yParity } = parseSignature(signature);
3044
+ const recoveryV = v ?? BigInt(yParity + 27);
3045
+ return encodeAbiParameters(
3046
+ [
3047
+ { type: "address" },
3048
+ { type: "address" },
3049
+ { type: "uint256" },
3050
+ { type: "uint256" },
3051
+ { type: "uint8" },
3052
+ { type: "bytes32" },
3053
+ { type: "bytes32" }
3054
+ ],
3055
+ [owner, spender, value, deadline, Number(recoveryV), r, s]
3056
+ );
3057
+ }
3058
+
2810
3059
  // src/lib/swap/swap.arrival.ts
2811
- var DEFAULT_TIMEOUT_MS2 = 18e4;
2812
- var DEFAULT_POLL_MS2 = 4e3;
3060
+ var DEFAULT_TIMEOUT_MS3 = 18e4;
3061
+ var DEFAULT_POLL_MS3 = 4e3;
2813
3062
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
2814
3063
  async function awaitWithdrawalArrival(options) {
2815
3064
  const {
2816
3065
  readBalance,
2817
3066
  baseline,
2818
- timeoutMs = DEFAULT_TIMEOUT_MS2,
2819
- pollMs = DEFAULT_POLL_MS2
3067
+ timeoutMs = DEFAULT_TIMEOUT_MS3,
3068
+ pollMs = DEFAULT_POLL_MS3
2820
3069
  } = options;
2821
3070
  const deadline = Date.now() + timeoutMs;
2822
3071
  debugLog("owney-sdk", "withdraw: waiting for funds to land", {
@@ -4402,6 +4651,41 @@ var OwneySDK = class {
4402
4651
  return hash;
4403
4652
  },
4404
4653
  ensureChain: (chainId) => this.ensureSwapChain(chainId),
4654
+ /**
4655
+ * Sign the approval instead of paying for it. Null means the token
4656
+ * cannot be permitted (WETH has no `permit`) or its domain could not be
4657
+ * verified, and the executor sends an ordinary approval instead.
4658
+ *
4659
+ * Reads go through srcPublic like every other read here — the domain is
4660
+ * chain-scoped, and reading a nonce off whatever chain the wallet
4661
+ * happens to sit on would sign a permit the token rejects.
4662
+ */
4663
+ buildPermit: (spender, amount) => buildErc2612Permit(
4664
+ {
4665
+ readContract: (args) => srcPublic.readContract(args)
4666
+ },
4667
+ {
4668
+ token: quote.src.address,
4669
+ owner: state.walletAddress,
4670
+ spender,
4671
+ value: amount,
4672
+ chainId: quote.src.chainId,
4673
+ signTypedData: (payload) => wallet.signTypedData({
4674
+ account: state.walletAddress,
4675
+ ...payload
4676
+ })
4677
+ }
4678
+ ),
4679
+ /**
4680
+ * Same-chain polling, with the chain bound in. That product keys its
4681
+ * orders per chain; asking the Fusion+ endpoint for one answers 404,
4682
+ * which would read as an order that vanished mid-swap.
4683
+ */
4684
+ sameChainRunner: {
4685
+ orderStatus: (h) => this.swapApi().orderStatus(h, quote.src.chainId),
4686
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
4687
+ now: () => Date.now()
4688
+ },
4405
4689
  runner: {
4406
4690
  readyForSecrets: (h) => this.swapApi().readyForSecrets(h),
4407
4691
  submitSecret: (h, secret) => this.swapApi().submitSecret(h, secret),
@@ -4420,6 +4704,39 @@ var OwneySDK = class {
4420
4704
  async getSwapTokens() {
4421
4705
  return this.swapApi().listTokens();
4422
4706
  }
4707
+ /**
4708
+ * What the wallet actually holds, across every supported chain, restricted
4709
+ * to tokens a swap could use.
4710
+ *
4711
+ * `getSwapTokens` answers "what may someone pay with in principle" from a
4712
+ * fixed list. This answers "what has this wallet got", which is a different
4713
+ * question and the one a picker needs: a user can hold something routable —
4714
+ * tBTC, say — that no fixed list of ours would ever mention.
4715
+ *
4716
+ * Each chain is asked separately because that is how the balance product is
4717
+ * shaped, and a chain that fails contributes nothing rather than failing the
4718
+ * lot. A picker with two chains in it beats an error. (ROUT-242)
4719
+ */
4720
+ async getWalletBalances(chainIds) {
4721
+ const state = this.requireState();
4722
+ const chains = chainIds ?? SUPPORTED_CHAIN_IDS;
4723
+ const failedChainIds = [];
4724
+ const results = await Promise.all(
4725
+ chains.map(async (chainId) => {
4726
+ try {
4727
+ const { tokens } = await this.swapApi().walletBalances({
4728
+ chainId,
4729
+ walletAddress: state.walletAddress
4730
+ });
4731
+ return tokens.map((token) => ({ ...token, chainId }));
4732
+ } catch {
4733
+ failedChainIds.push(chainId);
4734
+ return [];
4735
+ }
4736
+ })
4737
+ );
4738
+ return { tokens: results.flat(), failedChainIds };
4739
+ }
4423
4740
  /**
4424
4741
  * Search the assets a user may pay with, across every supported chain.
4425
4742
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owney/sdk",
3
- "version": "0.7.26-beta.2",
3
+ "version": "0.7.26-beta.4",
4
4
  "type": "module",
5
5
  "main": "dist/index.cjs",
6
6
  "module": "dist/index.js",