@owney/sdk 0.7.26-beta.1 → 0.7.26-beta.3

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
@@ -2208,7 +2208,21 @@ var CHAIN_NAMES = {
2208
2208
  function chainName(chainId) {
2209
2209
  return CHAIN_NAMES[chainId] ?? `chain ${chainId}`;
2210
2210
  }
2211
- async function ensureWalletOnChain(pub, wallet, expected) {
2211
+ var SETTLE_TIMEOUT_MS = 8e3;
2212
+ var POLL_INTERVAL_MS = 200;
2213
+ var wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2214
+ async function readChainId(pub) {
2215
+ try {
2216
+ return await pub.getChainId();
2217
+ } catch {
2218
+ return void 0;
2219
+ }
2220
+ }
2221
+ async function ensureWalletOnChain(pub, wallet, expected, options = {}) {
2222
+ const {
2223
+ settleTimeoutMs = SETTLE_TIMEOUT_MS,
2224
+ pollIntervalMs = POLL_INTERVAL_MS
2225
+ } = options;
2212
2226
  const actual = await pub.getChainId();
2213
2227
  if (actual === expected) return;
2214
2228
  try {
@@ -2224,14 +2238,18 @@ async function ensureWalletOnChain(pub, wallet, expected) {
2224
2238
  }
2225
2239
  );
2226
2240
  }
2227
- const after = await pub.getChainId();
2228
- if (after !== expected) {
2229
- throw new OwneyError(
2230
- "CHAIN_MISMATCH",
2231
- `Your wallet is still on ${chainName(after)}. Switch it to ${chainName(expected)} and try again.`,
2232
- { expectedChainId: expected, actualChainId: after }
2233
- );
2234
- }
2241
+ const deadline = Date.now() + settleTimeoutMs;
2242
+ let after = await readChainId(pub);
2243
+ while (after !== expected && Date.now() < deadline) {
2244
+ await wait(pollIntervalMs);
2245
+ after = await readChainId(pub);
2246
+ }
2247
+ if (after === expected) return;
2248
+ throw new OwneyError(
2249
+ "CHAIN_MISMATCH",
2250
+ `Your wallet is still on ${chainName(after ?? actual)}. Switch it to ${chainName(expected)} and try again.`,
2251
+ { expectedChainId: expected, actualChainId: after ?? actual }
2252
+ );
2235
2253
  }
2236
2254
 
2237
2255
  // src/lib/swap/swap-api.ts
@@ -2348,9 +2366,10 @@ function createSwapApi(baseUrl, apiKey) {
2348
2366
  dstSymbol: params.to.symbol,
2349
2367
  amount: params.from.amount,
2350
2368
  walletAddress: params.walletAddress,
2351
- secretHashes: params.secretHashes,
2369
+ ...params.secretHashes ? { secretHashes: params.secretHashes } : {},
2352
2370
  ...params.direction ? { direction: params.direction } : {},
2353
- ...params.receiver ? { receiver: params.receiver } : {}
2371
+ ...params.receiver ? { receiver: params.receiver } : {},
2372
+ ...params.permit ? { permit: params.permit } : {}
2354
2373
  }
2355
2374
  }),
2356
2375
  submitOrder: (body) => request(baseUrl, apiKey, "/order", { method: "POST", body }),
@@ -2363,7 +2382,16 @@ function createSwapApi(baseUrl, apiKey) {
2363
2382
  method: "POST",
2364
2383
  body: { orderHash, secret }
2365
2384
  }),
2366
- orderStatus: (orderHash) => request(baseUrl, apiKey, `/order/${orderHash}`),
2385
+ /**
2386
+ * @param chainId present only for a SAME-CHAIN order. That product keys its
2387
+ * orders per chain and the Fusion+ endpoint does not know them, so asking
2388
+ * without it answers 404 — which reads as an order that vanished.
2389
+ */
2390
+ orderStatus: (orderHash, chainId) => request(
2391
+ baseUrl,
2392
+ apiKey,
2393
+ chainId === void 0 ? `/order/${orderHash}` : `/order/${orderHash}?chainId=${chainId}`
2394
+ ),
2367
2395
  readyForSecrets: (orderHash) => request(
2368
2396
  baseUrl,
2369
2397
  apiKey,
@@ -2506,7 +2534,12 @@ var SWAP_TERMINAL_STATUSES = [
2506
2534
  "executed",
2507
2535
  "expired",
2508
2536
  "cancelled",
2509
- "refunded"
2537
+ "refunded",
2538
+ "filled",
2539
+ "false-predicate",
2540
+ "not-enough-balance-or-allowance",
2541
+ "wrong-permit",
2542
+ "invalid-signature"
2510
2543
  ];
2511
2544
  var isSwapTerminal = (status) => SWAP_TERMINAL_STATUSES.includes(status);
2512
2545
 
@@ -2584,6 +2617,71 @@ async function runFusionOrder(deps, options) {
2584
2617
  }
2585
2618
  }
2586
2619
 
2620
+ // src/lib/swap/swap.same-chain-runner.ts
2621
+ var DEFAULT_POLL_MS2 = 5e3;
2622
+ var MAX_BACKOFF_MS2 = 3e4;
2623
+ var backoffFor2 = (failures, base3) => Math.min(base3 * 2 ** Math.min(failures, 5), MAX_BACKOFF_MS2);
2624
+ var DEFAULT_TIMEOUT_MS2 = 3 * 60 * 1e3;
2625
+ function explain(status) {
2626
+ switch (status) {
2627
+ case "expired":
2628
+ return "No one filled the swap in time, so nothing was exchanged. Your funds never left your wallet.";
2629
+ case "cancelled":
2630
+ return "The swap was cancelled before anyone filled it. Your funds never left your wallet.";
2631
+ case "not-enough-balance-or-allowance":
2632
+ return "The swap could not be filled because the balance or approval had changed since it was signed. Nothing was exchanged.";
2633
+ case "wrong-permit":
2634
+ 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.";
2635
+ case "invalid-signature":
2636
+ return "The swap signature was rejected. Nothing was exchanged.";
2637
+ case "false-predicate":
2638
+ return "The swap's conditions no longer held when it was filled. Nothing was exchanged.";
2639
+ default:
2640
+ return "The swap did not complete. Nothing was exchanged.";
2641
+ }
2642
+ }
2643
+ async function runSameChainOrder(deps, options) {
2644
+ const {
2645
+ orderHash,
2646
+ onStage,
2647
+ pollIntervalMs = DEFAULT_POLL_MS2,
2648
+ timeoutMs = DEFAULT_TIMEOUT_MS2
2649
+ } = options;
2650
+ const deadline = deps.now() + timeoutMs;
2651
+ let failures = 0;
2652
+ onStage?.("swapping");
2653
+ for (; ; ) {
2654
+ if (deps.now() >= deadline) {
2655
+ throw new OwneyError(
2656
+ "SWAP_ORDER_EXPIRED",
2657
+ "No one filled the swap in time. Nothing was exchanged and your funds are still in your wallet.",
2658
+ { orderHash }
2659
+ );
2660
+ }
2661
+ let status;
2662
+ try {
2663
+ ({ status } = await deps.orderStatus(orderHash));
2664
+ failures = 0;
2665
+ } catch {
2666
+ failures += 1;
2667
+ await deps.sleep(backoffFor2(failures, pollIntervalMs));
2668
+ continue;
2669
+ }
2670
+ if (isSwapTerminal(status)) {
2671
+ if (status === "filled" || status === "executed") {
2672
+ onStage?.("swapped");
2673
+ return { status, filled: true };
2674
+ }
2675
+ throw new OwneyError(
2676
+ status === "cancelled" ? "SWAP_ORDER_CANCELLED" : "SWAP_ORDER_EXPIRED",
2677
+ explain(status),
2678
+ { orderHash, status }
2679
+ );
2680
+ }
2681
+ await deps.sleep(pollIntervalMs);
2682
+ }
2683
+ }
2684
+
2587
2685
  // src/lib/swap/swap.secret-store.ts
2588
2686
  var KEY_PREFIX2 = "owney.swap.order";
2589
2687
  var MAX_AGE_MS = 24 * 60 * 60 * 1e3;
@@ -2667,7 +2765,7 @@ async function executeSwap(deps, options) {
2667
2765
  });
2668
2766
  const before = await deps.readTargetBalance();
2669
2767
  debugLog("owney-sdk", "swap: target balance before", before.toString());
2670
- const result = quote.rail === "classic" ? await runClassic(deps, options) : await runFusion(deps, options, walletAddress);
2768
+ const result = quote.rail === "classic" ? await runClassic(deps, options) : quote.rail === "fusion" ? await runSameChainFusion(deps, options, walletAddress) : await runFusion(deps, options, walletAddress);
2671
2769
  const after = await deps.readTargetBalance();
2672
2770
  const received = after - before;
2673
2771
  debugLog("owney-sdk", "swap: target balance after", {
@@ -2738,25 +2836,75 @@ async function runClassic(deps, options) {
2738
2836
  onStage?.("swapped");
2739
2837
  return { txHash };
2740
2838
  }
2741
- async function runFusion(deps, options, walletAddress) {
2839
+ async function runSameChainFusion(deps, options, walletAddress) {
2742
2840
  const { quote, direction, onStage } = options;
2743
2841
  const isNativeSource = quote.src.address.toLowerCase().startsWith("0xeeee");
2744
2842
  const amount = isNativeSource ? BigInt(quote.src.amount) : await affordableAmount(deps, BigInt(quote.src.amount));
2745
- if (quote.spender && !isNativeSource) {
2746
- const needed = amount;
2747
- const current = await deps.readAllowance(quote.spender);
2748
- debugLog("owney-sdk", "swap: fusion allowance", {
2749
- spender: quote.spender,
2750
- current: current.toString(),
2751
- needed: needed.toString()
2843
+ const permit = await authoriseSpend(deps, options, amount, isNativeSource);
2844
+ onStage?.("quoting");
2845
+ debugLog("owney-sdk", "swap: building same-chain fusion order");
2846
+ const built = await deps.api.buildOrder({
2847
+ from: {
2848
+ chainId: quote.src.chainId,
2849
+ symbol: quote.src.symbol,
2850
+ amount: amount.toString()
2851
+ },
2852
+ to: { chainId: quote.dst.chainId, symbol: quote.dst.symbol },
2853
+ walletAddress,
2854
+ ...direction ? { direction } : {},
2855
+ ...permit ? { permit } : {}
2856
+ });
2857
+ debugLog("owney-sdk", "swap: same-chain order built", {
2858
+ orderHash: built.orderHash
2859
+ });
2860
+ onStage?.("signing");
2861
+ await deps.ensureChain(quote.src.chainId);
2862
+ const signature = await deps.signTypedData(built.typedData);
2863
+ debugLog("owney-sdk", "swap: signed, submitting same-chain order");
2864
+ await deps.api.submitOrder({
2865
+ // Without this the routing API hands the order to the Fusion+ relayer,
2866
+ // which does not know it and answers with an error that names nothing.
2867
+ rail: "fusion",
2868
+ srcChainId: quote.src.chainId,
2869
+ order: built.order,
2870
+ signature,
2871
+ quoteId: built.quoteId,
2872
+ ...built.extension ? { extension: built.extension } : {}
2873
+ });
2874
+ await runSameChainOrder(deps.sameChainRunner ?? deps.runner, {
2875
+ orderHash: built.orderHash,
2876
+ ...onStage ? { onStage } : {}
2877
+ });
2878
+ return { orderHash: built.orderHash };
2879
+ }
2880
+ async function authoriseSpend(deps, options, amount, isNativeSource) {
2881
+ const { quote, onStage } = options;
2882
+ if (!quote.spender || isNativeSource) return void 0;
2883
+ const current = await deps.readAllowance(quote.spender);
2884
+ debugLog("owney-sdk", "swap: fusion allowance", {
2885
+ spender: quote.spender,
2886
+ current: current.toString(),
2887
+ needed: amount.toString()
2888
+ });
2889
+ if (current >= amount) return void 0;
2890
+ onStage?.("approving");
2891
+ await deps.ensureChain(quote.src.chainId);
2892
+ const permit = quote.permitSupported ? await deps.buildPermit?.(quote.spender, MAX_UINT256) ?? void 0 : void 0;
2893
+ if (permit) {
2894
+ debugLog("owney-sdk", "swap: permitting limit order protocol", {
2895
+ bytes: (permit.length - 2) / 2
2752
2896
  });
2753
- if (current < needed) {
2754
- onStage?.("approving");
2755
- await deps.ensureChain(quote.src.chainId);
2756
- await deps.approve(quote.spender, MAX_UINT256);
2757
- debugLog("owney-sdk", "swap: approved limit order protocol");
2758
- }
2897
+ return permit;
2759
2898
  }
2899
+ await deps.approve(quote.spender, MAX_UINT256);
2900
+ debugLog("owney-sdk", "swap: approved limit order protocol");
2901
+ return void 0;
2902
+ }
2903
+ async function runFusion(deps, options, walletAddress) {
2904
+ const { quote, direction, onStage } = options;
2905
+ const isNativeSource = quote.src.address.toLowerCase().startsWith("0xeeee");
2906
+ const amount = isNativeSource ? BigInt(quote.src.amount) : await affordableAmount(deps, BigInt(quote.src.amount));
2907
+ const permit = await authoriseSpend(deps, options, amount, isNativeSource);
2760
2908
  const { secrets, secretHashes } = mintSecrets(quote.secretsCount ?? 1);
2761
2909
  onStage?.("quoting");
2762
2910
  debugLog("owney-sdk", "swap: building fusion order", {
@@ -2772,7 +2920,8 @@ async function runFusion(deps, options, walletAddress) {
2772
2920
  to: { chainId: quote.dst.chainId, symbol: quote.dst.symbol },
2773
2921
  walletAddress,
2774
2922
  secretHashes,
2775
- ...direction ? { direction } : {}
2923
+ ...direction ? { direction } : {},
2924
+ ...permit ? { permit } : {}
2776
2925
  });
2777
2926
  saveOrder({
2778
2927
  orderHash: built.orderHash,
@@ -2823,16 +2972,114 @@ async function runFusion(deps, options, walletAddress) {
2823
2972
  return { orderHash: built.orderHash };
2824
2973
  }
2825
2974
 
2975
+ // src/lib/swap/swap.permit.ts
2976
+ var import_viem5 = require("viem");
2977
+ var PERMIT_ABI = (0, import_viem5.parseAbi)([
2978
+ "function nonces(address owner) view returns (uint256)",
2979
+ "function name() view returns (string)",
2980
+ "function version() view returns (string)",
2981
+ "function DOMAIN_SEPARATOR() view returns (bytes32)"
2982
+ ]);
2983
+ var PERMIT_TYPES = {
2984
+ Permit: [
2985
+ { name: "owner", type: "address" },
2986
+ { name: "spender", type: "address" },
2987
+ { name: "value", type: "uint256" },
2988
+ { name: "nonce", type: "uint256" },
2989
+ { name: "deadline", type: "uint256" }
2990
+ ]
2991
+ };
2992
+ var VERSION_CANDIDATES = ["1", "2"];
2993
+ var PERMIT_TTL_SECONDS = 3600;
2994
+ async function tryRead(read) {
2995
+ try {
2996
+ return await read();
2997
+ } catch {
2998
+ return null;
2999
+ }
3000
+ }
3001
+ function matchDomain(name, versions, chainId, token, onChainSeparator) {
3002
+ for (const version of versions) {
3003
+ const domain = { name, version, chainId, verifyingContract: token };
3004
+ if ((0, import_viem5.domainSeparator)({ domain }) === onChainSeparator) return domain;
3005
+ }
3006
+ return null;
3007
+ }
3008
+ async function buildErc2612Permit(reads, input) {
3009
+ const { token, owner, spender, value, chainId } = input;
3010
+ const call = (functionName, args) => reads.readContract({
3011
+ address: token,
3012
+ abi: PERMIT_ABI,
3013
+ functionName,
3014
+ ...args ? { args } : {}
3015
+ });
3016
+ const nonce = await tryRead(() => call("nonces", [owner]));
3017
+ if (nonce === null) {
3018
+ debugLog("owney-sdk", "permit: token has no nonces(), using approval", {
3019
+ token
3020
+ });
3021
+ return null;
3022
+ }
3023
+ const separator = await tryRead(
3024
+ () => call("DOMAIN_SEPARATOR")
3025
+ );
3026
+ const name = await tryRead(() => call("name"));
3027
+ if (!separator || !name) {
3028
+ debugLog("owney-sdk", "permit: domain unverifiable, using approval", {
3029
+ token,
3030
+ hasSeparator: Boolean(separator),
3031
+ hasName: Boolean(name)
3032
+ });
3033
+ return null;
3034
+ }
3035
+ const declared = await tryRead(() => call("version"));
3036
+ const domain = matchDomain(
3037
+ name,
3038
+ declared ? [declared, ...VERSION_CANDIDATES] : VERSION_CANDIDATES,
3039
+ chainId,
3040
+ token,
3041
+ separator
3042
+ );
3043
+ if (!domain) {
3044
+ debugLog("owney-sdk", "permit: no domain matched, using approval", {
3045
+ token,
3046
+ declared
3047
+ });
3048
+ return null;
3049
+ }
3050
+ const deadline = BigInt(Math.floor((input.now?.() ?? Date.now()) / 1e3)) + BigInt(PERMIT_TTL_SECONDS);
3051
+ const signature = await input.signTypedData({
3052
+ domain,
3053
+ types: PERMIT_TYPES,
3054
+ primaryType: "Permit",
3055
+ message: { owner, spender, value, nonce, deadline }
3056
+ });
3057
+ const { r, s, v, yParity } = (0, import_viem5.parseSignature)(signature);
3058
+ const recoveryV = v ?? BigInt(yParity + 27);
3059
+ return (0, import_viem5.encodeAbiParameters)(
3060
+ [
3061
+ { type: "address" },
3062
+ { type: "address" },
3063
+ { type: "uint256" },
3064
+ { type: "uint256" },
3065
+ { type: "uint8" },
3066
+ { type: "bytes32" },
3067
+ { type: "bytes32" }
3068
+ ],
3069
+ [owner, spender, value, deadline, Number(recoveryV), r, s]
3070
+ );
3071
+ }
3072
+
2826
3073
  // src/lib/swap/swap.arrival.ts
2827
- var DEFAULT_TIMEOUT_MS2 = 18e4;
2828
- var DEFAULT_POLL_MS2 = 4e3;
3074
+ var DEFAULT_TIMEOUT_MS3 = 18e4;
3075
+ var DEFAULT_POLL_MS3 = 4e3;
2829
3076
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
2830
3077
  async function awaitWithdrawalArrival(options) {
2831
3078
  const {
2832
3079
  readBalance,
2833
3080
  baseline,
2834
- timeoutMs = DEFAULT_TIMEOUT_MS2,
2835
- pollMs = DEFAULT_POLL_MS2
3081
+ timeoutMs = DEFAULT_TIMEOUT_MS3,
3082
+ pollMs = DEFAULT_POLL_MS3
2836
3083
  } = options;
2837
3084
  const deadline = Date.now() + timeoutMs;
2838
3085
  debugLog("owney-sdk", "withdraw: waiting for funds to land", {
@@ -2907,7 +3154,7 @@ async function withFailureReporting(apiKey, agentType, fn, baseUrl) {
2907
3154
  }
2908
3155
 
2909
3156
  // src/lib/helpers/withdraw-helper.ts
2910
- var import_viem5 = require("viem");
3157
+ var import_viem6 = require("viem");
2911
3158
  function projectAgentBalancesForAsset(agents, aggregated, chainId, asset, decimals) {
2912
3159
  const target = asset.toUpperCase();
2913
3160
  return agents.map((agent) => {
@@ -2916,7 +3163,7 @@ function projectAgentBalancesForAsset(agents, aggregated, chainId, asset, decima
2916
3163
  (t) => t.chainId === chainId && t.asset.toUpperCase() === target
2917
3164
  );
2918
3165
  if (!tokenBalance) return { agent, balance: 0n };
2919
- return { agent, balance: (0, import_viem5.parseUnits)(tokenBalance.amount, decimals) };
3166
+ return { agent, balance: (0, import_viem6.parseUnits)(tokenBalance.amount, decimals) };
2920
3167
  });
2921
3168
  }
2922
3169
  function planProportionalShares(balances, requested, totalAvailable) {
@@ -3063,11 +3310,11 @@ function aggregateApyByChainAndAsset(agentApys, agentBalances) {
3063
3310
  }
3064
3311
 
3065
3312
  // src/client.ts
3066
- var import_viem8 = require("viem");
3313
+ var import_viem9 = require("viem");
3067
3314
  var import_chains2 = require("viem/chains");
3068
3315
 
3069
3316
  // src/lib/transfer-auth.ts
3070
- var import_viem6 = require("viem");
3317
+ var import_viem7 = require("viem");
3071
3318
  var ERC20_META_ABI = [
3072
3319
  { type: "function", name: "name", stateMutability: "view", inputs: [], outputs: [{ name: "", type: "string" }] },
3073
3320
  { type: "function", name: "version", stateMutability: "view", inputs: [], outputs: [{ name: "", type: "string" }] }
@@ -3099,7 +3346,7 @@ async function readTokenMeta(publicClient, token) {
3099
3346
  function randomAuthNonce() {
3100
3347
  const bytes = new Uint8Array(32);
3101
3348
  globalThis.crypto.getRandomValues(bytes);
3102
- return (0, import_viem6.bytesToHex)(bytes);
3349
+ return (0, import_viem7.bytesToHex)(bytes);
3103
3350
  }
3104
3351
 
3105
3352
  // src/lib/sponsor-client.ts
@@ -3381,7 +3628,7 @@ function makeSponsoredWethCallback(deps) {
3381
3628
  }
3382
3629
 
3383
3630
  // src/lib/sponsored-calls-deposit.ts
3384
- var import_viem7 = require("viem");
3631
+ var import_viem8 = require("viem");
3385
3632
  var DEFAULT_POLL_INTERVAL_MS = 1500;
3386
3633
  var DEFAULT_MAX_POLLS = 30;
3387
3634
  async function paymasterSupported(provider, owner, chainId) {
@@ -3389,7 +3636,7 @@ async function paymasterSupported(provider, owner, chainId) {
3389
3636
  method: "wallet_getCapabilities",
3390
3637
  params: [owner]
3391
3638
  });
3392
- const forChain = caps?.[(0, import_viem7.toHex)(chainId)] ?? caps?.[String(chainId)];
3639
+ const forChain = caps?.[(0, import_viem8.toHex)(chainId)] ?? caps?.[String(chainId)];
3393
3640
  return Boolean(forChain?.paymasterService?.supported);
3394
3641
  }
3395
3642
  function makeSponsoredCallsCallback(deps) {
@@ -3423,8 +3670,8 @@ function makeSponsoredCallsCallback(deps) {
3423
3670
  { chainId }
3424
3671
  );
3425
3672
  }
3426
- const data = (0, import_viem7.encodeFunctionData)({
3427
- abi: import_viem7.erc20Abi,
3673
+ const data = (0, import_viem8.encodeFunctionData)({
3674
+ abi: import_viem8.erc20Abi,
3428
3675
  functionName: "transfer",
3429
3676
  args: [smartWallet, BigInt(amount)]
3430
3677
  });
@@ -3434,7 +3681,7 @@ function makeSponsoredCallsCallback(deps) {
3434
3681
  {
3435
3682
  version: "2.0.0",
3436
3683
  from: deps.ownerAddress,
3437
- chainId: (0, import_viem7.toHex)(chainId),
3684
+ chainId: (0, import_viem8.toHex)(chainId),
3438
3685
  atomicRequired: false,
3439
3686
  calls: [{ to: token, value: "0x0", data }],
3440
3687
  capabilities: {
@@ -3656,14 +3903,14 @@ var OwneySDK = class {
3656
3903
  // Casts work around viem's chain-narrowed Client vs the generic
3657
3904
  // PublicClient/WalletClient param types — structurally identical at
3658
3905
  // runtime, but the two share a name TS treats as unrelated.
3659
- getPublicClient: (cid) => (0, import_viem8.createPublicClient)({
3906
+ getPublicClient: (cid) => (0, import_viem9.createPublicClient)({
3660
3907
  chain: VIEM_CHAIN2[cid],
3661
- transport: (0, import_viem8.custom)(provider)
3908
+ transport: (0, import_viem9.custom)(provider)
3662
3909
  }),
3663
- getWalletClient: (cid) => (0, import_viem8.createWalletClient)({
3910
+ getWalletClient: (cid) => (0, import_viem9.createWalletClient)({
3664
3911
  account: owner,
3665
3912
  chain: VIEM_CHAIN2[cid],
3666
- transport: (0, import_viem8.custom)(provider)
3913
+ transport: (0, import_viem9.custom)(provider)
3667
3914
  })
3668
3915
  });
3669
3916
  if (!onApproved) this.cachedSponsoredCallback = callback;
@@ -3709,14 +3956,14 @@ var OwneySDK = class {
3709
3956
  // Casts work around viem's chain-narrowed Client vs the generic
3710
3957
  // PublicClient/WalletClient param types — structurally identical at
3711
3958
  // runtime, but the two share a name TS treats as unrelated.
3712
- getPublicClient: (cid) => (0, import_viem8.createPublicClient)({
3959
+ getPublicClient: (cid) => (0, import_viem9.createPublicClient)({
3713
3960
  chain: VIEM_CHAIN2[cid],
3714
- transport: (0, import_viem8.custom)(provider)
3961
+ transport: (0, import_viem9.custom)(provider)
3715
3962
  }),
3716
- getWalletClient: (cid) => (0, import_viem8.createWalletClient)({
3963
+ getWalletClient: (cid) => (0, import_viem9.createWalletClient)({
3717
3964
  account: owner,
3718
3965
  chain: VIEM_CHAIN2[cid],
3719
- transport: (0, import_viem8.custom)(provider)
3966
+ transport: (0, import_viem9.custom)(provider)
3720
3967
  })
3721
3968
  });
3722
3969
  if (!onApproved) this.cachedWethSponsoredCallback = callback;
@@ -4298,11 +4545,11 @@ var OwneySDK = class {
4298
4545
  );
4299
4546
  }
4300
4547
  await ensureWalletOnChain(
4301
- (0, import_viem8.createPublicClient)({ chain, transport: (0, import_viem8.custom)(provider) }),
4302
- (0, import_viem8.createWalletClient)({
4548
+ (0, import_viem9.createPublicClient)({ chain, transport: (0, import_viem9.custom)(provider) }),
4549
+ (0, import_viem9.createWalletClient)({
4303
4550
  account: state.walletAddress,
4304
4551
  chain,
4305
- transport: (0, import_viem8.custom)(provider)
4552
+ transport: (0, import_viem9.custom)(provider)
4306
4553
  }),
4307
4554
  chainId
4308
4555
  );
@@ -4318,16 +4565,16 @@ var OwneySDK = class {
4318
4565
  const provider = this.requireConnectedProvider();
4319
4566
  const srcChain = VIEM_CHAIN2[quote.src.chainId];
4320
4567
  const dstChain = VIEM_CHAIN2[quote.dst.chainId];
4321
- const wallet = (0, import_viem8.createWalletClient)({
4568
+ const wallet = (0, import_viem9.createWalletClient)({
4322
4569
  account: state.walletAddress,
4323
4570
  chain: srcChain,
4324
- transport: (0, import_viem8.custom)(provider)
4571
+ transport: (0, import_viem9.custom)(provider)
4325
4572
  });
4326
- const srcPublic = (0, import_viem8.createPublicClient)({
4573
+ const srcPublic = (0, import_viem9.createPublicClient)({
4327
4574
  chain: srcChain,
4328
4575
  transport: swapReadTransport(quote.src.chainId, this.zyfaiRpcUrls)
4329
4576
  });
4330
- const dstPublic = (0, import_viem8.createPublicClient)({
4577
+ const dstPublic = (0, import_viem9.createPublicClient)({
4331
4578
  chain: dstChain,
4332
4579
  transport: swapReadTransport(quote.dst.chainId, this.zyfaiRpcUrls)
4333
4580
  });
@@ -4344,7 +4591,7 @@ var OwneySDK = class {
4344
4591
  }
4345
4592
  return dstPublic.readContract({
4346
4593
  address: dst,
4347
- abi: import_viem8.erc20Abi,
4594
+ abi: import_viem9.erc20Abi,
4348
4595
  functionName: "balanceOf",
4349
4596
  args: [state.walletAddress]
4350
4597
  });
@@ -4412,6 +4659,41 @@ var OwneySDK = class {
4412
4659
  return hash;
4413
4660
  },
4414
4661
  ensureChain: (chainId) => this.ensureSwapChain(chainId),
4662
+ /**
4663
+ * Sign the approval instead of paying for it. Null means the token
4664
+ * cannot be permitted (WETH has no `permit`) or its domain could not be
4665
+ * verified, and the executor sends an ordinary approval instead.
4666
+ *
4667
+ * Reads go through srcPublic like every other read here — the domain is
4668
+ * chain-scoped, and reading a nonce off whatever chain the wallet
4669
+ * happens to sit on would sign a permit the token rejects.
4670
+ */
4671
+ buildPermit: (spender, amount) => buildErc2612Permit(
4672
+ {
4673
+ readContract: (args) => srcPublic.readContract(args)
4674
+ },
4675
+ {
4676
+ token: quote.src.address,
4677
+ owner: state.walletAddress,
4678
+ spender,
4679
+ value: amount,
4680
+ chainId: quote.src.chainId,
4681
+ signTypedData: (payload) => wallet.signTypedData({
4682
+ account: state.walletAddress,
4683
+ ...payload
4684
+ })
4685
+ }
4686
+ ),
4687
+ /**
4688
+ * Same-chain polling, with the chain bound in. That product keys its
4689
+ * orders per chain; asking the Fusion+ endpoint for one answers 404,
4690
+ * which would read as an order that vanished mid-swap.
4691
+ */
4692
+ sameChainRunner: {
4693
+ orderStatus: (h) => this.swapApi().orderStatus(h, quote.src.chainId),
4694
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
4695
+ now: () => Date.now()
4696
+ },
4415
4697
  runner: {
4416
4698
  readyForSecrets: (h) => this.swapApi().readyForSecrets(h),
4417
4699
  submitSecret: (h, secret) => this.swapApi().submitSecret(h, secret),
@@ -4557,13 +4839,13 @@ var OwneySDK = class {
4557
4839
  );
4558
4840
  }
4559
4841
  const srcChain = VIEM_CHAIN2[options.from.chainId];
4560
- const srcPublic = (0, import_viem8.createPublicClient)({
4842
+ const srcPublic = (0, import_viem9.createPublicClient)({
4561
4843
  chain: srcChain,
4562
4844
  transport: swapReadTransport(options.from.chainId, this.zyfaiRpcUrls)
4563
4845
  });
4564
4846
  const readWalletBalance = () => srcPublic.readContract({
4565
4847
  address: asset.address,
4566
- abi: import_viem8.erc20Abi,
4848
+ abi: import_viem9.erc20Abi,
4567
4849
  functionName: "balanceOf",
4568
4850
  args: [state.walletAddress]
4569
4851
  });
@@ -4575,7 +4857,7 @@ var OwneySDK = class {
4575
4857
  options.onSwapProgress?.("withdrawing");
4576
4858
  const withdraw = await this.withdraw({
4577
4859
  asset: options.from.symbol,
4578
- ...options.amount === void 0 ? {} : { amount: (0, import_viem8.parseUnits)(options.amount, asset.decimals).toString() },
4860
+ ...options.amount === void 0 ? {} : { amount: (0, import_viem9.parseUnits)(options.amount, asset.decimals).toString() },
4579
4861
  ...options.agentId ? { agentId: options.agentId } : {}
4580
4862
  });
4581
4863
  const arrived = await awaitWithdrawalArrival({
@@ -5211,10 +5493,10 @@ var OwneySDK = class {
5211
5493
  );
5212
5494
  }
5213
5495
  const provider = this.requireConnectedProvider();
5214
- const wallet = (0, import_viem8.createWalletClient)({
5496
+ const wallet = (0, import_viem9.createWalletClient)({
5215
5497
  account: state.walletAddress,
5216
5498
  chain: VIEM_CHAIN2[chainId],
5217
- transport: (0, import_viem8.custom)(provider)
5499
+ transport: (0, import_viem9.custom)(provider)
5218
5500
  });
5219
5501
  const hash = await wallet.writeContract({
5220
5502
  address: token,
@@ -5224,9 +5506,9 @@ var OwneySDK = class {
5224
5506
  account: state.walletAddress,
5225
5507
  chain: VIEM_CHAIN2[chainId]
5226
5508
  });
5227
- const publicClient = (0, import_viem8.createPublicClient)({
5509
+ const publicClient = (0, import_viem9.createPublicClient)({
5228
5510
  chain: VIEM_CHAIN2[chainId],
5229
- transport: (0, import_viem8.custom)(provider)
5511
+ transport: (0, import_viem9.custom)(provider)
5230
5512
  });
5231
5513
  const receipt = await publicClient.waitForTransactionReceipt({
5232
5514
  hash,
@@ -5352,7 +5634,7 @@ var OwneySDK = class {
5352
5634
  };
5353
5635
 
5354
5636
  // src/agents/zyfai/zyfai.siwx.ts
5355
- var import_viem9 = require("viem");
5637
+ var import_viem10 = require("viem");
5356
5638
  var import_siwe = require("siwe");
5357
5639
  var import_sdk2 = require("@zyfai/sdk");
5358
5640
 
@@ -5479,7 +5761,7 @@ function buildSIWXConfig(deps) {
5479
5761
  issuedAt,
5480
5762
  toString() {
5481
5763
  return new import_siwe.SiweMessage({
5482
- address: (0, import_viem9.getAddress)(accountAddress),
5764
+ address: (0, import_viem10.getAddress)(accountAddress),
5483
5765
  chainId: numericChainId(chainId),
5484
5766
  domain,
5485
5767
  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;
@@ -110,6 +115,17 @@ type SwapQuote = {
110
115
  * calldata instead.
111
116
  */
112
117
  spender?: string;
118
+ /**
119
+ * Cross-chain only. True when the routing API will accept an EIP-2612 permit
120
+ * on the order, so the approval is performed by the resolver inside the fill
121
+ * and the user needs no native ETH. False or absent means send an approval
122
+ * transaction, which is what every swap did before ROUT-242.
123
+ *
124
+ * It has to arrive on the QUOTE rather than being tried and recovered from:
125
+ * the client decides whether to spend the user's gas before the order it
126
+ * would be attached to exists.
127
+ */
128
+ permitSupported?: boolean;
113
129
  /**
114
130
  * True only for a cross-chain swap FROM native ETH, which needs an on-chain
115
131
  * order creation carrying the full amount as msg.value. The user's funds
@@ -155,7 +171,7 @@ type SwapQuote = {
155
171
  * `refunding` is the window the returning-funds screen renders: the order has
156
172
  * failed and the money is on its way back, but is not back yet.
157
173
  */
158
- type SwapOrderStatus = "pending" | "executed" | "expired" | "cancelled" | "refunding" | "refunded" | "unpublished";
174
+ type SwapOrderStatus = "pending" | "executed" | "expired" | "cancelled" | "refunding" | "refunded" | "unpublished" | "filled" | "partially-filled" | "false-predicate" | "not-enough-balance-or-allowance" | "wrong-permit" | "invalid-signature";
159
175
  /**
160
176
  * Stage reported to the UI while a swap runs, in either direction.
161
177
  *
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;
@@ -110,6 +115,17 @@ type SwapQuote = {
110
115
  * calldata instead.
111
116
  */
112
117
  spender?: string;
118
+ /**
119
+ * Cross-chain only. True when the routing API will accept an EIP-2612 permit
120
+ * on the order, so the approval is performed by the resolver inside the fill
121
+ * and the user needs no native ETH. False or absent means send an approval
122
+ * transaction, which is what every swap did before ROUT-242.
123
+ *
124
+ * It has to arrive on the QUOTE rather than being tried and recovered from:
125
+ * the client decides whether to spend the user's gas before the order it
126
+ * would be attached to exists.
127
+ */
128
+ permitSupported?: boolean;
113
129
  /**
114
130
  * True only for a cross-chain swap FROM native ETH, which needs an on-chain
115
131
  * order creation carrying the full amount as msg.value. The user's funds
@@ -155,7 +171,7 @@ type SwapQuote = {
155
171
  * `refunding` is the window the returning-funds screen renders: the order has
156
172
  * failed and the money is on its way back, but is not back yet.
157
173
  */
158
- type SwapOrderStatus = "pending" | "executed" | "expired" | "cancelled" | "refunding" | "refunded" | "unpublished";
174
+ type SwapOrderStatus = "pending" | "executed" | "expired" | "cancelled" | "refunding" | "refunded" | "unpublished" | "filled" | "partially-filled" | "false-predicate" | "not-enough-balance-or-allowance" | "wrong-permit" | "invalid-signature";
159
175
  /**
160
176
  * Stage reported to the UI while a swap runs, in either direction.
161
177
  *
package/dist/index.js CHANGED
@@ -2174,7 +2174,21 @@ var CHAIN_NAMES = {
2174
2174
  function chainName(chainId) {
2175
2175
  return CHAIN_NAMES[chainId] ?? `chain ${chainId}`;
2176
2176
  }
2177
- async function ensureWalletOnChain(pub, wallet, expected) {
2177
+ var SETTLE_TIMEOUT_MS = 8e3;
2178
+ var POLL_INTERVAL_MS = 200;
2179
+ var wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2180
+ async function readChainId(pub) {
2181
+ try {
2182
+ return await pub.getChainId();
2183
+ } catch {
2184
+ return void 0;
2185
+ }
2186
+ }
2187
+ async function ensureWalletOnChain(pub, wallet, expected, options = {}) {
2188
+ const {
2189
+ settleTimeoutMs = SETTLE_TIMEOUT_MS,
2190
+ pollIntervalMs = POLL_INTERVAL_MS
2191
+ } = options;
2178
2192
  const actual = await pub.getChainId();
2179
2193
  if (actual === expected) return;
2180
2194
  try {
@@ -2190,14 +2204,18 @@ async function ensureWalletOnChain(pub, wallet, expected) {
2190
2204
  }
2191
2205
  );
2192
2206
  }
2193
- const after = await pub.getChainId();
2194
- if (after !== expected) {
2195
- throw new OwneyError(
2196
- "CHAIN_MISMATCH",
2197
- `Your wallet is still on ${chainName(after)}. Switch it to ${chainName(expected)} and try again.`,
2198
- { expectedChainId: expected, actualChainId: after }
2199
- );
2200
- }
2207
+ const deadline = Date.now() + settleTimeoutMs;
2208
+ let after = await readChainId(pub);
2209
+ while (after !== expected && Date.now() < deadline) {
2210
+ await wait(pollIntervalMs);
2211
+ after = await readChainId(pub);
2212
+ }
2213
+ if (after === expected) return;
2214
+ throw new OwneyError(
2215
+ "CHAIN_MISMATCH",
2216
+ `Your wallet is still on ${chainName(after ?? actual)}. Switch it to ${chainName(expected)} and try again.`,
2217
+ { expectedChainId: expected, actualChainId: after ?? actual }
2218
+ );
2201
2219
  }
2202
2220
 
2203
2221
  // src/lib/swap/swap-api.ts
@@ -2314,9 +2332,10 @@ function createSwapApi(baseUrl, apiKey) {
2314
2332
  dstSymbol: params.to.symbol,
2315
2333
  amount: params.from.amount,
2316
2334
  walletAddress: params.walletAddress,
2317
- secretHashes: params.secretHashes,
2335
+ ...params.secretHashes ? { secretHashes: params.secretHashes } : {},
2318
2336
  ...params.direction ? { direction: params.direction } : {},
2319
- ...params.receiver ? { receiver: params.receiver } : {}
2337
+ ...params.receiver ? { receiver: params.receiver } : {},
2338
+ ...params.permit ? { permit: params.permit } : {}
2320
2339
  }
2321
2340
  }),
2322
2341
  submitOrder: (body) => request(baseUrl, apiKey, "/order", { method: "POST", body }),
@@ -2329,7 +2348,16 @@ function createSwapApi(baseUrl, apiKey) {
2329
2348
  method: "POST",
2330
2349
  body: { orderHash, secret }
2331
2350
  }),
2332
- orderStatus: (orderHash) => request(baseUrl, apiKey, `/order/${orderHash}`),
2351
+ /**
2352
+ * @param chainId present only for a SAME-CHAIN order. That product keys its
2353
+ * orders per chain and the Fusion+ endpoint does not know them, so asking
2354
+ * without it answers 404 — which reads as an order that vanished.
2355
+ */
2356
+ orderStatus: (orderHash, chainId) => request(
2357
+ baseUrl,
2358
+ apiKey,
2359
+ chainId === void 0 ? `/order/${orderHash}` : `/order/${orderHash}?chainId=${chainId}`
2360
+ ),
2333
2361
  readyForSecrets: (orderHash) => request(
2334
2362
  baseUrl,
2335
2363
  apiKey,
@@ -2472,7 +2500,12 @@ var SWAP_TERMINAL_STATUSES = [
2472
2500
  "executed",
2473
2501
  "expired",
2474
2502
  "cancelled",
2475
- "refunded"
2503
+ "refunded",
2504
+ "filled",
2505
+ "false-predicate",
2506
+ "not-enough-balance-or-allowance",
2507
+ "wrong-permit",
2508
+ "invalid-signature"
2476
2509
  ];
2477
2510
  var isSwapTerminal = (status) => SWAP_TERMINAL_STATUSES.includes(status);
2478
2511
 
@@ -2550,6 +2583,71 @@ async function runFusionOrder(deps, options) {
2550
2583
  }
2551
2584
  }
2552
2585
 
2586
+ // src/lib/swap/swap.same-chain-runner.ts
2587
+ var DEFAULT_POLL_MS2 = 5e3;
2588
+ var MAX_BACKOFF_MS2 = 3e4;
2589
+ var backoffFor2 = (failures, base3) => Math.min(base3 * 2 ** Math.min(failures, 5), MAX_BACKOFF_MS2);
2590
+ var DEFAULT_TIMEOUT_MS2 = 3 * 60 * 1e3;
2591
+ function explain(status) {
2592
+ switch (status) {
2593
+ case "expired":
2594
+ return "No one filled the swap in time, so nothing was exchanged. Your funds never left your wallet.";
2595
+ case "cancelled":
2596
+ return "The swap was cancelled before anyone filled it. Your funds never left your wallet.";
2597
+ case "not-enough-balance-or-allowance":
2598
+ return "The swap could not be filled because the balance or approval had changed since it was signed. Nothing was exchanged.";
2599
+ case "wrong-permit":
2600
+ 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.";
2601
+ case "invalid-signature":
2602
+ return "The swap signature was rejected. Nothing was exchanged.";
2603
+ case "false-predicate":
2604
+ return "The swap's conditions no longer held when it was filled. Nothing was exchanged.";
2605
+ default:
2606
+ return "The swap did not complete. Nothing was exchanged.";
2607
+ }
2608
+ }
2609
+ async function runSameChainOrder(deps, options) {
2610
+ const {
2611
+ orderHash,
2612
+ onStage,
2613
+ pollIntervalMs = DEFAULT_POLL_MS2,
2614
+ timeoutMs = DEFAULT_TIMEOUT_MS2
2615
+ } = options;
2616
+ const deadline = deps.now() + timeoutMs;
2617
+ let failures = 0;
2618
+ onStage?.("swapping");
2619
+ for (; ; ) {
2620
+ if (deps.now() >= deadline) {
2621
+ throw new OwneyError(
2622
+ "SWAP_ORDER_EXPIRED",
2623
+ "No one filled the swap in time. Nothing was exchanged and your funds are still in your wallet.",
2624
+ { orderHash }
2625
+ );
2626
+ }
2627
+ let status;
2628
+ try {
2629
+ ({ status } = await deps.orderStatus(orderHash));
2630
+ failures = 0;
2631
+ } catch {
2632
+ failures += 1;
2633
+ await deps.sleep(backoffFor2(failures, pollIntervalMs));
2634
+ continue;
2635
+ }
2636
+ if (isSwapTerminal(status)) {
2637
+ if (status === "filled" || status === "executed") {
2638
+ onStage?.("swapped");
2639
+ return { status, filled: true };
2640
+ }
2641
+ throw new OwneyError(
2642
+ status === "cancelled" ? "SWAP_ORDER_CANCELLED" : "SWAP_ORDER_EXPIRED",
2643
+ explain(status),
2644
+ { orderHash, status }
2645
+ );
2646
+ }
2647
+ await deps.sleep(pollIntervalMs);
2648
+ }
2649
+ }
2650
+
2553
2651
  // src/lib/swap/swap.secret-store.ts
2554
2652
  var KEY_PREFIX2 = "owney.swap.order";
2555
2653
  var MAX_AGE_MS = 24 * 60 * 60 * 1e3;
@@ -2633,7 +2731,7 @@ async function executeSwap(deps, options) {
2633
2731
  });
2634
2732
  const before = await deps.readTargetBalance();
2635
2733
  debugLog("owney-sdk", "swap: target balance before", before.toString());
2636
- const result = quote.rail === "classic" ? await runClassic(deps, options) : await runFusion(deps, options, walletAddress);
2734
+ const result = quote.rail === "classic" ? await runClassic(deps, options) : quote.rail === "fusion" ? await runSameChainFusion(deps, options, walletAddress) : await runFusion(deps, options, walletAddress);
2637
2735
  const after = await deps.readTargetBalance();
2638
2736
  const received = after - before;
2639
2737
  debugLog("owney-sdk", "swap: target balance after", {
@@ -2704,25 +2802,75 @@ async function runClassic(deps, options) {
2704
2802
  onStage?.("swapped");
2705
2803
  return { txHash };
2706
2804
  }
2707
- async function runFusion(deps, options, walletAddress) {
2805
+ async function runSameChainFusion(deps, options, walletAddress) {
2708
2806
  const { quote, direction, onStage } = options;
2709
2807
  const isNativeSource = quote.src.address.toLowerCase().startsWith("0xeeee");
2710
2808
  const amount = isNativeSource ? BigInt(quote.src.amount) : await affordableAmount(deps, BigInt(quote.src.amount));
2711
- if (quote.spender && !isNativeSource) {
2712
- const needed = amount;
2713
- const current = await deps.readAllowance(quote.spender);
2714
- debugLog("owney-sdk", "swap: fusion allowance", {
2715
- spender: quote.spender,
2716
- current: current.toString(),
2717
- needed: needed.toString()
2809
+ const permit = await authoriseSpend(deps, options, amount, isNativeSource);
2810
+ onStage?.("quoting");
2811
+ debugLog("owney-sdk", "swap: building same-chain fusion order");
2812
+ const built = await deps.api.buildOrder({
2813
+ from: {
2814
+ chainId: quote.src.chainId,
2815
+ symbol: quote.src.symbol,
2816
+ amount: amount.toString()
2817
+ },
2818
+ to: { chainId: quote.dst.chainId, symbol: quote.dst.symbol },
2819
+ walletAddress,
2820
+ ...direction ? { direction } : {},
2821
+ ...permit ? { permit } : {}
2822
+ });
2823
+ debugLog("owney-sdk", "swap: same-chain order built", {
2824
+ orderHash: built.orderHash
2825
+ });
2826
+ onStage?.("signing");
2827
+ await deps.ensureChain(quote.src.chainId);
2828
+ const signature = await deps.signTypedData(built.typedData);
2829
+ debugLog("owney-sdk", "swap: signed, submitting same-chain order");
2830
+ await deps.api.submitOrder({
2831
+ // Without this the routing API hands the order to the Fusion+ relayer,
2832
+ // which does not know it and answers with an error that names nothing.
2833
+ rail: "fusion",
2834
+ srcChainId: quote.src.chainId,
2835
+ order: built.order,
2836
+ signature,
2837
+ quoteId: built.quoteId,
2838
+ ...built.extension ? { extension: built.extension } : {}
2839
+ });
2840
+ await runSameChainOrder(deps.sameChainRunner ?? deps.runner, {
2841
+ orderHash: built.orderHash,
2842
+ ...onStage ? { onStage } : {}
2843
+ });
2844
+ return { orderHash: built.orderHash };
2845
+ }
2846
+ async function authoriseSpend(deps, options, amount, isNativeSource) {
2847
+ const { quote, onStage } = options;
2848
+ if (!quote.spender || isNativeSource) return void 0;
2849
+ const current = await deps.readAllowance(quote.spender);
2850
+ debugLog("owney-sdk", "swap: fusion allowance", {
2851
+ spender: quote.spender,
2852
+ current: current.toString(),
2853
+ needed: amount.toString()
2854
+ });
2855
+ if (current >= amount) return void 0;
2856
+ onStage?.("approving");
2857
+ await deps.ensureChain(quote.src.chainId);
2858
+ const permit = quote.permitSupported ? await deps.buildPermit?.(quote.spender, MAX_UINT256) ?? void 0 : void 0;
2859
+ if (permit) {
2860
+ debugLog("owney-sdk", "swap: permitting limit order protocol", {
2861
+ bytes: (permit.length - 2) / 2
2718
2862
  });
2719
- if (current < needed) {
2720
- onStage?.("approving");
2721
- await deps.ensureChain(quote.src.chainId);
2722
- await deps.approve(quote.spender, MAX_UINT256);
2723
- debugLog("owney-sdk", "swap: approved limit order protocol");
2724
- }
2863
+ return permit;
2725
2864
  }
2865
+ await deps.approve(quote.spender, MAX_UINT256);
2866
+ debugLog("owney-sdk", "swap: approved limit order protocol");
2867
+ return void 0;
2868
+ }
2869
+ async function runFusion(deps, options, walletAddress) {
2870
+ const { quote, direction, onStage } = options;
2871
+ const isNativeSource = quote.src.address.toLowerCase().startsWith("0xeeee");
2872
+ const amount = isNativeSource ? BigInt(quote.src.amount) : await affordableAmount(deps, BigInt(quote.src.amount));
2873
+ const permit = await authoriseSpend(deps, options, amount, isNativeSource);
2726
2874
  const { secrets, secretHashes } = mintSecrets(quote.secretsCount ?? 1);
2727
2875
  onStage?.("quoting");
2728
2876
  debugLog("owney-sdk", "swap: building fusion order", {
@@ -2738,7 +2886,8 @@ async function runFusion(deps, options, walletAddress) {
2738
2886
  to: { chainId: quote.dst.chainId, symbol: quote.dst.symbol },
2739
2887
  walletAddress,
2740
2888
  secretHashes,
2741
- ...direction ? { direction } : {}
2889
+ ...direction ? { direction } : {},
2890
+ ...permit ? { permit } : {}
2742
2891
  });
2743
2892
  saveOrder({
2744
2893
  orderHash: built.orderHash,
@@ -2789,16 +2938,119 @@ async function runFusion(deps, options, walletAddress) {
2789
2938
  return { orderHash: built.orderHash };
2790
2939
  }
2791
2940
 
2941
+ // src/lib/swap/swap.permit.ts
2942
+ import {
2943
+ domainSeparator,
2944
+ encodeAbiParameters,
2945
+ parseAbi as parseAbi2,
2946
+ parseSignature
2947
+ } from "viem";
2948
+ var PERMIT_ABI = parseAbi2([
2949
+ "function nonces(address owner) view returns (uint256)",
2950
+ "function name() view returns (string)",
2951
+ "function version() view returns (string)",
2952
+ "function DOMAIN_SEPARATOR() view returns (bytes32)"
2953
+ ]);
2954
+ var PERMIT_TYPES = {
2955
+ Permit: [
2956
+ { name: "owner", type: "address" },
2957
+ { name: "spender", type: "address" },
2958
+ { name: "value", type: "uint256" },
2959
+ { name: "nonce", type: "uint256" },
2960
+ { name: "deadline", type: "uint256" }
2961
+ ]
2962
+ };
2963
+ var VERSION_CANDIDATES = ["1", "2"];
2964
+ var PERMIT_TTL_SECONDS = 3600;
2965
+ async function tryRead(read) {
2966
+ try {
2967
+ return await read();
2968
+ } catch {
2969
+ return null;
2970
+ }
2971
+ }
2972
+ function matchDomain(name, versions, chainId, token, onChainSeparator) {
2973
+ for (const version of versions) {
2974
+ const domain = { name, version, chainId, verifyingContract: token };
2975
+ if (domainSeparator({ domain }) === onChainSeparator) return domain;
2976
+ }
2977
+ return null;
2978
+ }
2979
+ async function buildErc2612Permit(reads, input) {
2980
+ const { token, owner, spender, value, chainId } = input;
2981
+ const call = (functionName, args) => reads.readContract({
2982
+ address: token,
2983
+ abi: PERMIT_ABI,
2984
+ functionName,
2985
+ ...args ? { args } : {}
2986
+ });
2987
+ const nonce = await tryRead(() => call("nonces", [owner]));
2988
+ if (nonce === null) {
2989
+ debugLog("owney-sdk", "permit: token has no nonces(), using approval", {
2990
+ token
2991
+ });
2992
+ return null;
2993
+ }
2994
+ const separator = await tryRead(
2995
+ () => call("DOMAIN_SEPARATOR")
2996
+ );
2997
+ const name = await tryRead(() => call("name"));
2998
+ if (!separator || !name) {
2999
+ debugLog("owney-sdk", "permit: domain unverifiable, using approval", {
3000
+ token,
3001
+ hasSeparator: Boolean(separator),
3002
+ hasName: Boolean(name)
3003
+ });
3004
+ return null;
3005
+ }
3006
+ const declared = await tryRead(() => call("version"));
3007
+ const domain = matchDomain(
3008
+ name,
3009
+ declared ? [declared, ...VERSION_CANDIDATES] : VERSION_CANDIDATES,
3010
+ chainId,
3011
+ token,
3012
+ separator
3013
+ );
3014
+ if (!domain) {
3015
+ debugLog("owney-sdk", "permit: no domain matched, using approval", {
3016
+ token,
3017
+ declared
3018
+ });
3019
+ return null;
3020
+ }
3021
+ const deadline = BigInt(Math.floor((input.now?.() ?? Date.now()) / 1e3)) + BigInt(PERMIT_TTL_SECONDS);
3022
+ const signature = await input.signTypedData({
3023
+ domain,
3024
+ types: PERMIT_TYPES,
3025
+ primaryType: "Permit",
3026
+ message: { owner, spender, value, nonce, deadline }
3027
+ });
3028
+ const { r, s, v, yParity } = parseSignature(signature);
3029
+ const recoveryV = v ?? BigInt(yParity + 27);
3030
+ return encodeAbiParameters(
3031
+ [
3032
+ { type: "address" },
3033
+ { type: "address" },
3034
+ { type: "uint256" },
3035
+ { type: "uint256" },
3036
+ { type: "uint8" },
3037
+ { type: "bytes32" },
3038
+ { type: "bytes32" }
3039
+ ],
3040
+ [owner, spender, value, deadline, Number(recoveryV), r, s]
3041
+ );
3042
+ }
3043
+
2792
3044
  // src/lib/swap/swap.arrival.ts
2793
- var DEFAULT_TIMEOUT_MS2 = 18e4;
2794
- var DEFAULT_POLL_MS2 = 4e3;
3045
+ var DEFAULT_TIMEOUT_MS3 = 18e4;
3046
+ var DEFAULT_POLL_MS3 = 4e3;
2795
3047
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
2796
3048
  async function awaitWithdrawalArrival(options) {
2797
3049
  const {
2798
3050
  readBalance,
2799
3051
  baseline,
2800
- timeoutMs = DEFAULT_TIMEOUT_MS2,
2801
- pollMs = DEFAULT_POLL_MS2
3052
+ timeoutMs = DEFAULT_TIMEOUT_MS3,
3053
+ pollMs = DEFAULT_POLL_MS3
2802
3054
  } = options;
2803
3055
  const deadline = Date.now() + timeoutMs;
2804
3056
  debugLog("owney-sdk", "withdraw: waiting for funds to land", {
@@ -4384,6 +4636,41 @@ var OwneySDK = class {
4384
4636
  return hash;
4385
4637
  },
4386
4638
  ensureChain: (chainId) => this.ensureSwapChain(chainId),
4639
+ /**
4640
+ * Sign the approval instead of paying for it. Null means the token
4641
+ * cannot be permitted (WETH has no `permit`) or its domain could not be
4642
+ * verified, and the executor sends an ordinary approval instead.
4643
+ *
4644
+ * Reads go through srcPublic like every other read here — the domain is
4645
+ * chain-scoped, and reading a nonce off whatever chain the wallet
4646
+ * happens to sit on would sign a permit the token rejects.
4647
+ */
4648
+ buildPermit: (spender, amount) => buildErc2612Permit(
4649
+ {
4650
+ readContract: (args) => srcPublic.readContract(args)
4651
+ },
4652
+ {
4653
+ token: quote.src.address,
4654
+ owner: state.walletAddress,
4655
+ spender,
4656
+ value: amount,
4657
+ chainId: quote.src.chainId,
4658
+ signTypedData: (payload) => wallet.signTypedData({
4659
+ account: state.walletAddress,
4660
+ ...payload
4661
+ })
4662
+ }
4663
+ ),
4664
+ /**
4665
+ * Same-chain polling, with the chain bound in. That product keys its
4666
+ * orders per chain; asking the Fusion+ endpoint for one answers 404,
4667
+ * which would read as an order that vanished mid-swap.
4668
+ */
4669
+ sameChainRunner: {
4670
+ orderStatus: (h) => this.swapApi().orderStatus(h, quote.src.chainId),
4671
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
4672
+ now: () => Date.now()
4673
+ },
4387
4674
  runner: {
4388
4675
  readyForSecrets: (h) => this.swapApi().readyForSecrets(h),
4389
4676
  submitSecret: (h, secret) => this.swapApi().submitSecret(h, secret),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owney/sdk",
3
- "version": "0.7.26-beta.1",
3
+ "version": "0.7.26-beta.3",
4
4
  "type": "module",
5
5
  "main": "dist/index.cjs",
6
6
  "module": "dist/index.js",