@owney/sdk 0.7.5 → 0.7.7-beta.0

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
@@ -825,6 +825,9 @@ function debugLog(scope, message, data) {
825
825
  }
826
826
 
827
827
  // src/agents/zyfai/zyfai.agent.ts
828
+ var ERC7579_IS_MODULE_INSTALLED_ABI = (0, import_viem.parseAbi)([
829
+ "function isModuleInstalled(uint256 moduleTypeId, address module, bytes additionalContext) view returns (bool)"
830
+ ]);
828
831
  var DEFAULT_ZYFAI_RPC_URLS = {
829
832
  8453: "https://base-mainnet.g.alchemy.com/v2/ZWyVU-9XfS3z8Rn-xkq7V",
830
833
  42161: "https://arb-mainnet.g.alchemy.com/v2/ZWyVU-9XfS3z8Rn-xkq7V",
@@ -1142,7 +1145,12 @@ var ZyfaiAgent = class {
1142
1145
  const validChainId = isValidChainId(chainId);
1143
1146
  await Promise.all([
1144
1147
  this.ensureProtocolsSynced(validChainId, "USDC"),
1145
- this.ensureProtocolsSynced(validChainId, "WETH")
1148
+ this.ensureProtocolsSynced(validChainId, "WETH"),
1149
+ // Retrofit a session key for old wallets that are already deployed and
1150
+ // funded but never got one — otherwise their balance stays idle until
1151
+ // they happen to deposit again. Only touches Safes that are missing the
1152
+ // validator, so pool-wallet users see no prompt. Non-fatal.
1153
+ this.ensureSessionKeyIfDeployed(validChainId)
1146
1154
  ]);
1147
1155
  } catch (error) {
1148
1156
  throw error;
@@ -1340,6 +1348,65 @@ var ZyfaiAgent = class {
1340
1348
  return false;
1341
1349
  }
1342
1350
  }
1351
+ /**
1352
+ * Ensure Zyfai's automation can actually act on the user's Safe for this
1353
+ * chain, by making sure a session key exists.
1354
+ *
1355
+ * Old wallets (pre-0.2.44) are deployed WITHOUT the smart-sessions validator,
1356
+ * so Zyfai's rebalancer has no authorised path to move funds: deposits land
1357
+ * and then sit as raw tokens forever. Pre-deployed pool wallets ship with the
1358
+ * module already installed, which is why they never needed this.
1359
+ *
1360
+ * The gate is the on-chain module, not the wallet's class: it is the exact
1361
+ * condition that matters, it needs no guessing from API shapes, and it means
1362
+ * pool-wallet users are never prompted for a signature they don't need.
1363
+ *
1364
+ * Non-fatal by design. A failure here must not block a deposit — the funds
1365
+ * still arrive safely in the Safe, they just won't be deployed into a pool
1366
+ * until a session key exists.
1367
+ */
1368
+ /**
1369
+ * Activation-time variant: only acts when the Safe already has code on this
1370
+ * chain. Activation must never deploy anything — that is deposit's job, and it
1371
+ * would mean surprise on-chain work just for opening the app.
1372
+ */
1373
+ async ensureSessionKeyIfDeployed(chainId) {
1374
+ try {
1375
+ const ownerAddress = this.getAddress();
1376
+ const wallet = await this.sdk.getSmartWalletAddress(
1377
+ ownerAddress,
1378
+ chainId
1379
+ );
1380
+ if (!wallet.isDeployed) return;
1381
+ await this.ensureSessionKey(ownerAddress, chainId, wallet.address);
1382
+ } catch (error) {
1383
+ console.warn(
1384
+ "[zyfai] session-key check at activation failed (non-fatal):",
1385
+ error instanceof Error ? error.message : String(error)
1386
+ );
1387
+ }
1388
+ }
1389
+ async ensureSessionKey(ownerAddress, chainId, smartWallet) {
1390
+ try {
1391
+ const installed = await this.getPublicClient(chainId).readContract({
1392
+ address: smartWallet,
1393
+ abi: ERC7579_IS_MODULE_INSTALLED_ABI,
1394
+ functionName: "isModuleInstalled",
1395
+ // moduleTypeId 1 = validator (ERC-7579)
1396
+ args: [1n, import_sdk.SMART_SESSIONS_VALIDATOR, "0x"]
1397
+ });
1398
+ if (installed) return;
1399
+ console.warn(
1400
+ `[zyfai] Safe ${smartWallet} has no smart-sessions validator on chain ${chainId}; creating a session key so the agent can allocate.`
1401
+ );
1402
+ await this.sdk.createSessionKey(ownerAddress, chainId);
1403
+ } catch (error) {
1404
+ console.warn(
1405
+ "[zyfai] createSessionKey skipped (non-fatal):",
1406
+ error instanceof Error ? error.message : String(error)
1407
+ );
1408
+ }
1409
+ }
1343
1410
  /**
1344
1411
  * Guarantee the user's Safe has code on `chainId` before anything transfers
1345
1412
  * funds into it, and return its address.
@@ -1360,17 +1427,21 @@ var ZyfaiAgent = class {
1360
1427
  */
1361
1428
  async ensureWalletDeployed(ownerAddress, chainId) {
1362
1429
  const wallet = await this.sdk.getSmartWalletAddress(ownerAddress, chainId);
1363
- if (wallet.isDeployed) return wallet.address;
1430
+ if (wallet.isDeployed) {
1431
+ await this.ensureSessionKey(ownerAddress, chainId, wallet.address);
1432
+ return wallet.address;
1433
+ }
1364
1434
  const poolFirst = await this.isPredeployedPoolWallet(
1365
1435
  ownerAddress,
1366
1436
  wallet.address
1367
1437
  );
1368
1438
  const deployAsPool = () => this.sdk.deployOnChains([chainId]);
1369
1439
  const deployAsLegacy = () => (
1370
- // `createSessionKey: false` — session keys are backend-owned since the
1371
- // predeployed-pool rollout; asking for one here costs a signature prompt
1372
- // and is rejected anyway.
1373
- this.sdk.deploySafe(ownerAddress, chainId, "aggressive", false)
1440
+ // `createSessionKey: true` — per Zyfai, the old-wallet flow is
1441
+ // deploySafe(true) -> createSessionKey() -> deposit. Passing false (which
1442
+ // this did originally) deploys a Safe with no smart-sessions validator, so
1443
+ // deposits land and are never allocated into a pool.
1444
+ this.sdk.deploySafe(ownerAddress, chainId, "aggressive", true)
1374
1445
  );
1375
1446
  const [first, second] = poolFirst ? [deployAsPool, deployAsLegacy] : [deployAsLegacy, deployAsPool];
1376
1447
  try {
@@ -1394,6 +1465,7 @@ var ZyfaiAgent = class {
1394
1465
  this.id
1395
1466
  );
1396
1467
  }
1468
+ await this.ensureSessionKey(ownerAddress, chainId, redeployed.address);
1397
1469
  return redeployed.address;
1398
1470
  }
1399
1471
  async deposit(state, chainId, amount, asset, depositCallback) {
@@ -1840,9 +1912,12 @@ async function postSponsorTransferAuth(input) {
1840
1912
  {
1841
1913
  statusCode: res.status,
1842
1914
  responseBody: text.slice(0, 500),
1843
- // 503 SPONSOR_UNAVAILABLE = relayer out of gas; the tx was rejected
1844
- // before broadcast, so it is safe to fall back to a user-paid deposit.
1845
- safeToFallback: res.status === 503
1915
+ // A 4xx is an explicit refusal before execution (DTO validation,
1916
+ // signature rejection, simulation) — nothing was broadcast, so a
1917
+ // user-paid retry cannot double-spend. 503 SPONSOR_UNAVAILABLE means
1918
+ // the relayer was out of gas, also pre-broadcast. Any other 5xx stays
1919
+ // ambiguous: the transfer may already be in flight.
1920
+ safeToFallback: res.status >= 400 && res.status < 500 || res.status === 503
1846
1921
  }
1847
1922
  );
1848
1923
  }
@@ -2114,9 +2189,9 @@ function makeSponsoredDepositCallback(deps) {
2114
2189
  };
2115
2190
  }
2116
2191
 
2117
- // src/lib/sponsored-weth-deposit.ts
2192
+ // src/lib/sponsored-permit2-deposit.ts
2118
2193
  var PERMIT_WINDOW_SECONDS = 15 * 60;
2119
- function makeSponsoredWethCallback(deps) {
2194
+ function makeSponsoredPermit2Callback(deps) {
2120
2195
  const get = deps.httpGet ?? getSponsorRelayerAddress;
2121
2196
  const post = deps.httpPost ?? postSponsorPermit2Transfer;
2122
2197
  return async (smartWallet, chainId, amount) => {
@@ -2125,7 +2200,7 @@ function makeSponsoredWethCallback(deps) {
2125
2200
  if (!token) {
2126
2201
  throw new OwneyError(
2127
2202
  "CHAIN_UNSUPPORTED",
2128
- `No sponsored WETH configured for chain ${chainId}`
2203
+ `No sponsored ${deps.assetLabel} configured for chain ${chainId}`
2129
2204
  );
2130
2205
  }
2131
2206
  const amountWei = BigInt(amount);
@@ -2137,14 +2212,14 @@ function makeSponsoredWethCallback(deps) {
2137
2212
  if (balance < amountWei) {
2138
2213
  throw new OwneyError(
2139
2214
  "DEPOSIT_INSUFFICIENT_BALANCE",
2140
- "Insufficient WETH balance for this deposit.",
2215
+ `Insufficient ${deps.assetLabel} balance for this deposit.`,
2141
2216
  { token, chainId: cid, balance: balance.toString(), amount }
2142
2217
  );
2143
2218
  }
2144
2219
  } catch (err) {
2145
2220
  if (err instanceof OwneyError) throw err;
2146
2221
  console.warn(
2147
- "[owney-sdk] WETH balance pre-check failed (non-fatal):",
2222
+ `[owney-sdk] ${deps.assetLabel} balance pre-check failed (non-fatal):`,
2148
2223
  err instanceof Error ? err.message : String(err)
2149
2224
  );
2150
2225
  }
@@ -2152,7 +2227,7 @@ function makeSponsoredWethCallback(deps) {
2152
2227
  if (allowance < amountWei) {
2153
2228
  throw new OwneyError(
2154
2229
  "PERMIT2_APPROVAL_REQUIRED",
2155
- "WETH gasless deposits need a one-time Permit2 approval; call approvePermit2() first",
2230
+ `${deps.assetLabel} gasless deposits need a one-time Permit2 approval; call approvePermit2("${deps.assetLabel}") first`,
2156
2231
  { token, chainId: cid, allowance: allowance.toString(), amount }
2157
2232
  );
2158
2233
  }
@@ -2257,7 +2332,7 @@ var OwneySDK = class {
2257
2332
  routingApiBaseUrl;
2258
2333
  referralSource;
2259
2334
  cachedSponsoredCallback = null;
2260
- cachedWethSponsoredCallback = null;
2335
+ cachedPermit2Callbacks = /* @__PURE__ */ new Map();
2261
2336
  initializingAgentsPromise = null;
2262
2337
  constructor(config) {
2263
2338
  this.apiKey = config.apiKey;
@@ -2285,7 +2360,7 @@ var OwneySDK = class {
2285
2360
  }
2286
2361
  this.state = { provider, walletAddress, chainId: null };
2287
2362
  this.cachedSponsoredCallback = null;
2288
- this.cachedWethSponsoredCallback = null;
2363
+ this.cachedPermit2Callbacks.clear();
2289
2364
  }
2290
2365
  /**
2291
2366
  * Disconnect from all agents and clear connection state.
@@ -2299,7 +2374,7 @@ var OwneySDK = class {
2299
2374
  this.disabledAgents.clear();
2300
2375
  this.state = null;
2301
2376
  this.cachedSponsoredCallback = null;
2302
- this.cachedWethSponsoredCallback = null;
2377
+ this.cachedPermit2Callbacks.clear();
2303
2378
  }
2304
2379
  isAgentDisabled(agentId) {
2305
2380
  return this.disabledAgents.has(agentId);
@@ -2376,25 +2451,23 @@ var OwneySDK = class {
2376
2451
  return callback;
2377
2452
  }
2378
2453
  /**
2379
- * Lazily builds (and caches) the default Permit2 sponsored WETH deposit
2380
- * callback used when the caller omits `depositCallback` for a WETH
2381
- * deposit. Mirrors `getDefaultSponsoredCallback()` but signs a Permit2
2382
- * `PermitTransferFrom` instead of an EIP-3009 authorization.
2454
+ * Lazily builds (and caches) the Permit2 sponsored callback for one asset.
2455
+ * Used for WETH always, and for USDC when the connected owner is a smart
2456
+ * account — EIP-3009 verifies with ecrecover inside USDC and cannot accept a
2457
+ * contract signature, while Permit2 supports ERC-1271.
2383
2458
  */
2384
- getDefaultWethSponsoredCallback(onApproved) {
2385
- if (!onApproved && this.cachedWethSponsoredCallback)
2386
- return this.cachedWethSponsoredCallback;
2459
+ getDefaultPermit2Callback(asset, onApproved) {
2460
+ const cached = this.cachedPermit2Callbacks.get(asset);
2461
+ if (!onApproved && cached) return cached;
2387
2462
  const provider = this.requireConnectedProvider();
2388
2463
  const owner = this.state.walletAddress;
2389
- const callback = makeSponsoredWethCallback({
2464
+ const callback = makeSponsoredPermit2Callback({
2390
2465
  apiKey: this.apiKey,
2391
2466
  baseUrl: this.routingApiBaseUrl,
2392
2467
  ownerAddress: owner,
2468
+ assetLabel: asset,
2393
2469
  onApproved,
2394
- tokenAddressByChain: SPONSORED_WETH_BY_CHAIN,
2395
- // Casts work around viem's chain-narrowed Client vs the generic
2396
- // PublicClient/WalletClient param types — structurally identical at
2397
- // runtime, but the two share a name TS treats as unrelated.
2470
+ tokenAddressByChain: asset === "USDC" ? SPONSORED_USDC_BY_CHAIN : SPONSORED_WETH_BY_CHAIN,
2398
2471
  getPublicClient: (cid) => (0, import_viem5.createPublicClient)({
2399
2472
  chain: VIEM_CHAIN2[cid],
2400
2473
  transport: (0, import_viem5.custom)(provider)
@@ -2405,9 +2478,34 @@ var OwneySDK = class {
2405
2478
  transport: (0, import_viem5.custom)(provider)
2406
2479
  })
2407
2480
  });
2408
- if (!onApproved) this.cachedWethSponsoredCallback = callback;
2481
+ if (!onApproved) this.cachedPermit2Callbacks.set(asset, callback);
2409
2482
  return callback;
2410
2483
  }
2484
+ /**
2485
+ * True when the connected owner has bytecode — a deployed smart wallet, or an
2486
+ * EOA carrying an EIP-7702 delegation (the Base app's "Upgrade your wallet").
2487
+ * Such wallets sign via ERC-1271, which EIP-3009 cannot verify.
2488
+ *
2489
+ * Fails OPEN: an unreadable code check returns false so a working EOA deposit
2490
+ * is never blocked by a flaky provider.
2491
+ */
2492
+ async ownerHasCode() {
2493
+ try {
2494
+ const provider = this.requireConnectedProvider();
2495
+ const code = await provider.request({
2496
+ method: "eth_getCode",
2497
+ params: [this.state.walletAddress, "latest"]
2498
+ });
2499
+ return Boolean(code) && code !== "0x";
2500
+ } catch (error) {
2501
+ debugLog(
2502
+ "deposit",
2503
+ "owner code check failed; assuming EOA",
2504
+ error instanceof Error ? error.message : String(error)
2505
+ );
2506
+ return false;
2507
+ }
2508
+ }
2411
2509
  getAgent(agentId) {
2412
2510
  const agent = this.agents.get(agentId);
2413
2511
  if (!agent) {
@@ -2603,7 +2701,11 @@ var OwneySDK = class {
2603
2701
  }
2604
2702
  const state = this.requireState();
2605
2703
  const chainId = this.requireChainId();
2606
- const effectiveCallback = depositCallback ?? (asset === "WETH" ? this.getDefaultWethSponsoredCallback(onApproved) : this.getDefaultSponsoredCallback(onApproved));
2704
+ const ownerIsSmartAccount = depositCallback === void 0 && asset !== "WETH" ? await this.ownerHasCode() : false;
2705
+ const effectiveCallback = depositCallback ?? (asset === "WETH" || ownerIsSmartAccount ? this.getDefaultPermit2Callback(
2706
+ asset === "WETH" ? "WETH" : "USDC",
2707
+ onApproved
2708
+ ) : this.getDefaultSponsoredCallback(onApproved));
2607
2709
  if (agentId) {
2608
2710
  if (this.isAgentDisabled(agentId)) {
2609
2711
  throw new OwneyError(
@@ -2707,12 +2809,12 @@ var OwneySDK = class {
2707
2809
  try {
2708
2810
  return await attempt(effectiveCallback);
2709
2811
  } catch (error) {
2710
- if (!approvalAttempted && appCallback === void 0 && asset === "WETH" && error instanceof OwneyError && error.code === "PERMIT2_APPROVAL_REQUIRED") {
2812
+ if (!approvalAttempted && appCallback === void 0 && (asset === "WETH" || asset === "USDC") && error instanceof OwneyError && error.code === "PERMIT2_APPROVAL_REQUIRED") {
2711
2813
  approvalAttempted = true;
2712
2814
  console.warn(
2713
- "[owney-sdk] First WETH deposit: sending one-time Permit2 approval..."
2815
+ `[owney-sdk] First ${asset} gasless deposit: sending one-time Permit2 approval...`
2714
2816
  );
2715
- await this.approvePermit2();
2817
+ await this.approvePermit2(asset);
2716
2818
  continue;
2717
2819
  }
2718
2820
  if (!shouldFallbackToUserPaid(error, asset, appCallback)) throw error;
@@ -3226,23 +3328,32 @@ var OwneySDK = class {
3226
3328
  return agent.ensureAutoSelectProtocols(state, chainId, asset);
3227
3329
  }
3228
3330
  /**
3229
- * One-time, user-paid approval of Permit2 on the sponsored WETH token for
3230
- * the active chain. Required once per wallet per chain before gasless WETH
3231
- * deposits; afterwards deposit() is signature-only. Resolves only after the
3232
- * approval transaction is mined (1 confirmation), so a subsequent deposit()
3233
- * will see the new allowance; throws if the transaction reverted.
3331
+ * One-time, user-paid approval of Permit2 on the given sponsored token for
3332
+ * the active chain. Required once per wallet per chain per token before a
3333
+ * gasless deposit of that asset over Permit2; afterwards deposit() is
3334
+ * signature-only for it. This call itself is a real transaction the user
3335
+ * pays gas for. Resolves only after the approval transaction is mined (1
3336
+ * confirmation), so a subsequent deposit() will see the new allowance;
3337
+ * throws if the transaction reverted.
3234
3338
  * @returns the approval transaction hash.
3235
3339
  */
3236
3340
  async approvePermit2(asset = "WETH") {
3237
- void asset;
3238
3341
  const state = this.requireState();
3239
3342
  const chainId = this.requireChainId();
3240
- this.validateAssetSupport(this.getAgent("zyfai"), chainId, "WETH");
3241
- const token = SPONSORED_WETH_BY_CHAIN[chainId];
3343
+ let guard = this.agents.get("zyfai");
3344
+ if (!guard) {
3345
+ try {
3346
+ guard = this.getEligibleAgents(chainId, asset)[0];
3347
+ } catch {
3348
+ guard = void 0;
3349
+ }
3350
+ }
3351
+ if (guard) this.validateAssetSupport(guard, chainId, asset);
3352
+ const token = asset === "USDC" ? SPONSORED_USDC_BY_CHAIN[chainId] : SPONSORED_WETH_BY_CHAIN[chainId];
3242
3353
  if (!token) {
3243
3354
  throw new OwneyError(
3244
3355
  "CHAIN_UNSUPPORTED",
3245
- `No sponsored WETH on chain ${chainId}`
3356
+ `No sponsored ${asset} on chain ${chainId}`
3246
3357
  );
3247
3358
  }
3248
3359
  const provider = this.requireConnectedProvider();
package/dist/index.d.cts CHANGED
@@ -392,7 +392,7 @@ declare class OwneySDK {
392
392
  private routingApiBaseUrl?;
393
393
  private referralSource?;
394
394
  private cachedSponsoredCallback;
395
- private cachedWethSponsoredCallback;
395
+ private cachedPermit2Callbacks;
396
396
  private initializingAgentsPromise;
397
397
  constructor(config: OwneySDKConfig);
398
398
  /**
@@ -428,12 +428,21 @@ declare class OwneySDK {
428
428
  */
429
429
  private getDefaultSponsoredCallback;
430
430
  /**
431
- * Lazily builds (and caches) the default Permit2 sponsored WETH deposit
432
- * callback used when the caller omits `depositCallback` for a WETH
433
- * deposit. Mirrors `getDefaultSponsoredCallback()` but signs a Permit2
434
- * `PermitTransferFrom` instead of an EIP-3009 authorization.
431
+ * Lazily builds (and caches) the Permit2 sponsored callback for one asset.
432
+ * Used for WETH always, and for USDC when the connected owner is a smart
433
+ * account — EIP-3009 verifies with ecrecover inside USDC and cannot accept a
434
+ * contract signature, while Permit2 supports ERC-1271.
435
435
  */
436
- private getDefaultWethSponsoredCallback;
436
+ private getDefaultPermit2Callback;
437
+ /**
438
+ * True when the connected owner has bytecode — a deployed smart wallet, or an
439
+ * EOA carrying an EIP-7702 delegation (the Base app's "Upgrade your wallet").
440
+ * Such wallets sign via ERC-1271, which EIP-3009 cannot verify.
441
+ *
442
+ * Fails OPEN: an unreadable code check returns false so a working EOA deposit
443
+ * is never blocked by a flaky provider.
444
+ */
445
+ private ownerHasCode;
437
446
  private getAgent;
438
447
  private getActiveAgents;
439
448
  /**
@@ -578,14 +587,16 @@ declare class OwneySDK {
578
587
  */
579
588
  ensureAutoSelectProtocols(asset: "USDC" | "WETH", agentId?: AgentId): Promise<boolean>;
580
589
  /**
581
- * One-time, user-paid approval of Permit2 on the sponsored WETH token for
582
- * the active chain. Required once per wallet per chain before gasless WETH
583
- * deposits; afterwards deposit() is signature-only. Resolves only after the
584
- * approval transaction is mined (1 confirmation), so a subsequent deposit()
585
- * will see the new allowance; throws if the transaction reverted.
590
+ * One-time, user-paid approval of Permit2 on the given sponsored token for
591
+ * the active chain. Required once per wallet per chain per token before a
592
+ * gasless deposit of that asset over Permit2; afterwards deposit() is
593
+ * signature-only for it. This call itself is a real transaction the user
594
+ * pays gas for. Resolves only after the approval transaction is mined (1
595
+ * confirmation), so a subsequent deposit() will see the new allowance;
596
+ * throws if the transaction reverted.
586
597
  * @returns the approval transaction hash.
587
598
  */
588
- approvePermit2(asset?: "WETH"): Promise<`0x${string}`>;
599
+ approvePermit2(asset?: "WETH" | "USDC"): Promise<`0x${string}`>;
589
600
  /**
590
601
  * Get the agent's average APY performance over a time period. Does not require a wallet connection.
591
602
  * @param options - Contains agentId (optional) and days ("7D", "14D", or "30D")
package/dist/index.d.ts CHANGED
@@ -392,7 +392,7 @@ declare class OwneySDK {
392
392
  private routingApiBaseUrl?;
393
393
  private referralSource?;
394
394
  private cachedSponsoredCallback;
395
- private cachedWethSponsoredCallback;
395
+ private cachedPermit2Callbacks;
396
396
  private initializingAgentsPromise;
397
397
  constructor(config: OwneySDKConfig);
398
398
  /**
@@ -428,12 +428,21 @@ declare class OwneySDK {
428
428
  */
429
429
  private getDefaultSponsoredCallback;
430
430
  /**
431
- * Lazily builds (and caches) the default Permit2 sponsored WETH deposit
432
- * callback used when the caller omits `depositCallback` for a WETH
433
- * deposit. Mirrors `getDefaultSponsoredCallback()` but signs a Permit2
434
- * `PermitTransferFrom` instead of an EIP-3009 authorization.
431
+ * Lazily builds (and caches) the Permit2 sponsored callback for one asset.
432
+ * Used for WETH always, and for USDC when the connected owner is a smart
433
+ * account — EIP-3009 verifies with ecrecover inside USDC and cannot accept a
434
+ * contract signature, while Permit2 supports ERC-1271.
435
435
  */
436
- private getDefaultWethSponsoredCallback;
436
+ private getDefaultPermit2Callback;
437
+ /**
438
+ * True when the connected owner has bytecode — a deployed smart wallet, or an
439
+ * EOA carrying an EIP-7702 delegation (the Base app's "Upgrade your wallet").
440
+ * Such wallets sign via ERC-1271, which EIP-3009 cannot verify.
441
+ *
442
+ * Fails OPEN: an unreadable code check returns false so a working EOA deposit
443
+ * is never blocked by a flaky provider.
444
+ */
445
+ private ownerHasCode;
437
446
  private getAgent;
438
447
  private getActiveAgents;
439
448
  /**
@@ -578,14 +587,16 @@ declare class OwneySDK {
578
587
  */
579
588
  ensureAutoSelectProtocols(asset: "USDC" | "WETH", agentId?: AgentId): Promise<boolean>;
580
589
  /**
581
- * One-time, user-paid approval of Permit2 on the sponsored WETH token for
582
- * the active chain. Required once per wallet per chain before gasless WETH
583
- * deposits; afterwards deposit() is signature-only. Resolves only after the
584
- * approval transaction is mined (1 confirmation), so a subsequent deposit()
585
- * will see the new allowance; throws if the transaction reverted.
590
+ * One-time, user-paid approval of Permit2 on the given sponsored token for
591
+ * the active chain. Required once per wallet per chain per token before a
592
+ * gasless deposit of that asset over Permit2; afterwards deposit() is
593
+ * signature-only for it. This call itself is a real transaction the user
594
+ * pays gas for. Resolves only after the approval transaction is mined (1
595
+ * confirmation), so a subsequent deposit() will see the new allowance;
596
+ * throws if the transaction reverted.
586
597
  * @returns the approval transaction hash.
587
598
  */
588
- approvePermit2(asset?: "WETH"): Promise<`0x${string}`>;
599
+ approvePermit2(asset?: "WETH" | "USDC"): Promise<`0x${string}`>;
589
600
  /**
590
601
  * Get the agent's average APY performance over a time period. Does not require a wallet connection.
591
602
  * @param options - Contains agentId (optional) and days ("7D", "14D", or "30D")
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // src/agents/zyfai/zyfai.agent.ts
2
- import { ZyfaiSDK } from "@zyfai/sdk";
3
- import { createPublicClient, http } from "viem";
2
+ import { SMART_SESSIONS_VALIDATOR, ZyfaiSDK } from "@zyfai/sdk";
3
+ import { createPublicClient, http, parseAbi } from "viem";
4
4
  import { base, arbitrum, mainnet } from "viem/chains";
5
5
 
6
6
  // src/types/config.ts
@@ -792,6 +792,9 @@ function debugLog(scope, message, data) {
792
792
  }
793
793
 
794
794
  // src/agents/zyfai/zyfai.agent.ts
795
+ var ERC7579_IS_MODULE_INSTALLED_ABI = parseAbi([
796
+ "function isModuleInstalled(uint256 moduleTypeId, address module, bytes additionalContext) view returns (bool)"
797
+ ]);
795
798
  var DEFAULT_ZYFAI_RPC_URLS = {
796
799
  8453: "https://base-mainnet.g.alchemy.com/v2/ZWyVU-9XfS3z8Rn-xkq7V",
797
800
  42161: "https://arb-mainnet.g.alchemy.com/v2/ZWyVU-9XfS3z8Rn-xkq7V",
@@ -1109,7 +1112,12 @@ var ZyfaiAgent = class {
1109
1112
  const validChainId = isValidChainId(chainId);
1110
1113
  await Promise.all([
1111
1114
  this.ensureProtocolsSynced(validChainId, "USDC"),
1112
- this.ensureProtocolsSynced(validChainId, "WETH")
1115
+ this.ensureProtocolsSynced(validChainId, "WETH"),
1116
+ // Retrofit a session key for old wallets that are already deployed and
1117
+ // funded but never got one — otherwise their balance stays idle until
1118
+ // they happen to deposit again. Only touches Safes that are missing the
1119
+ // validator, so pool-wallet users see no prompt. Non-fatal.
1120
+ this.ensureSessionKeyIfDeployed(validChainId)
1113
1121
  ]);
1114
1122
  } catch (error) {
1115
1123
  throw error;
@@ -1307,6 +1315,65 @@ var ZyfaiAgent = class {
1307
1315
  return false;
1308
1316
  }
1309
1317
  }
1318
+ /**
1319
+ * Ensure Zyfai's automation can actually act on the user's Safe for this
1320
+ * chain, by making sure a session key exists.
1321
+ *
1322
+ * Old wallets (pre-0.2.44) are deployed WITHOUT the smart-sessions validator,
1323
+ * so Zyfai's rebalancer has no authorised path to move funds: deposits land
1324
+ * and then sit as raw tokens forever. Pre-deployed pool wallets ship with the
1325
+ * module already installed, which is why they never needed this.
1326
+ *
1327
+ * The gate is the on-chain module, not the wallet's class: it is the exact
1328
+ * condition that matters, it needs no guessing from API shapes, and it means
1329
+ * pool-wallet users are never prompted for a signature they don't need.
1330
+ *
1331
+ * Non-fatal by design. A failure here must not block a deposit — the funds
1332
+ * still arrive safely in the Safe, they just won't be deployed into a pool
1333
+ * until a session key exists.
1334
+ */
1335
+ /**
1336
+ * Activation-time variant: only acts when the Safe already has code on this
1337
+ * chain. Activation must never deploy anything — that is deposit's job, and it
1338
+ * would mean surprise on-chain work just for opening the app.
1339
+ */
1340
+ async ensureSessionKeyIfDeployed(chainId) {
1341
+ try {
1342
+ const ownerAddress = this.getAddress();
1343
+ const wallet = await this.sdk.getSmartWalletAddress(
1344
+ ownerAddress,
1345
+ chainId
1346
+ );
1347
+ if (!wallet.isDeployed) return;
1348
+ await this.ensureSessionKey(ownerAddress, chainId, wallet.address);
1349
+ } catch (error) {
1350
+ console.warn(
1351
+ "[zyfai] session-key check at activation failed (non-fatal):",
1352
+ error instanceof Error ? error.message : String(error)
1353
+ );
1354
+ }
1355
+ }
1356
+ async ensureSessionKey(ownerAddress, chainId, smartWallet) {
1357
+ try {
1358
+ const installed = await this.getPublicClient(chainId).readContract({
1359
+ address: smartWallet,
1360
+ abi: ERC7579_IS_MODULE_INSTALLED_ABI,
1361
+ functionName: "isModuleInstalled",
1362
+ // moduleTypeId 1 = validator (ERC-7579)
1363
+ args: [1n, SMART_SESSIONS_VALIDATOR, "0x"]
1364
+ });
1365
+ if (installed) return;
1366
+ console.warn(
1367
+ `[zyfai] Safe ${smartWallet} has no smart-sessions validator on chain ${chainId}; creating a session key so the agent can allocate.`
1368
+ );
1369
+ await this.sdk.createSessionKey(ownerAddress, chainId);
1370
+ } catch (error) {
1371
+ console.warn(
1372
+ "[zyfai] createSessionKey skipped (non-fatal):",
1373
+ error instanceof Error ? error.message : String(error)
1374
+ );
1375
+ }
1376
+ }
1310
1377
  /**
1311
1378
  * Guarantee the user's Safe has code on `chainId` before anything transfers
1312
1379
  * funds into it, and return its address.
@@ -1327,17 +1394,21 @@ var ZyfaiAgent = class {
1327
1394
  */
1328
1395
  async ensureWalletDeployed(ownerAddress, chainId) {
1329
1396
  const wallet = await this.sdk.getSmartWalletAddress(ownerAddress, chainId);
1330
- if (wallet.isDeployed) return wallet.address;
1397
+ if (wallet.isDeployed) {
1398
+ await this.ensureSessionKey(ownerAddress, chainId, wallet.address);
1399
+ return wallet.address;
1400
+ }
1331
1401
  const poolFirst = await this.isPredeployedPoolWallet(
1332
1402
  ownerAddress,
1333
1403
  wallet.address
1334
1404
  );
1335
1405
  const deployAsPool = () => this.sdk.deployOnChains([chainId]);
1336
1406
  const deployAsLegacy = () => (
1337
- // `createSessionKey: false` — session keys are backend-owned since the
1338
- // predeployed-pool rollout; asking for one here costs a signature prompt
1339
- // and is rejected anyway.
1340
- this.sdk.deploySafe(ownerAddress, chainId, "aggressive", false)
1407
+ // `createSessionKey: true` — per Zyfai, the old-wallet flow is
1408
+ // deploySafe(true) -> createSessionKey() -> deposit. Passing false (which
1409
+ // this did originally) deploys a Safe with no smart-sessions validator, so
1410
+ // deposits land and are never allocated into a pool.
1411
+ this.sdk.deploySafe(ownerAddress, chainId, "aggressive", true)
1341
1412
  );
1342
1413
  const [first, second] = poolFirst ? [deployAsPool, deployAsLegacy] : [deployAsLegacy, deployAsPool];
1343
1414
  try {
@@ -1361,6 +1432,7 @@ var ZyfaiAgent = class {
1361
1432
  this.id
1362
1433
  );
1363
1434
  }
1435
+ await this.ensureSessionKey(ownerAddress, chainId, redeployed.address);
1364
1436
  return redeployed.address;
1365
1437
  }
1366
1438
  async deposit(state, chainId, amount, asset, depositCallback) {
@@ -1811,9 +1883,12 @@ async function postSponsorTransferAuth(input) {
1811
1883
  {
1812
1884
  statusCode: res.status,
1813
1885
  responseBody: text.slice(0, 500),
1814
- // 503 SPONSOR_UNAVAILABLE = relayer out of gas; the tx was rejected
1815
- // before broadcast, so it is safe to fall back to a user-paid deposit.
1816
- safeToFallback: res.status === 503
1886
+ // A 4xx is an explicit refusal before execution (DTO validation,
1887
+ // signature rejection, simulation) — nothing was broadcast, so a
1888
+ // user-paid retry cannot double-spend. 503 SPONSOR_UNAVAILABLE means
1889
+ // the relayer was out of gas, also pre-broadcast. Any other 5xx stays
1890
+ // ambiguous: the transfer may already be in flight.
1891
+ safeToFallback: res.status >= 400 && res.status < 500 || res.status === 503
1817
1892
  }
1818
1893
  );
1819
1894
  }
@@ -2085,9 +2160,9 @@ function makeSponsoredDepositCallback(deps) {
2085
2160
  };
2086
2161
  }
2087
2162
 
2088
- // src/lib/sponsored-weth-deposit.ts
2163
+ // src/lib/sponsored-permit2-deposit.ts
2089
2164
  var PERMIT_WINDOW_SECONDS = 15 * 60;
2090
- function makeSponsoredWethCallback(deps) {
2165
+ function makeSponsoredPermit2Callback(deps) {
2091
2166
  const get = deps.httpGet ?? getSponsorRelayerAddress;
2092
2167
  const post = deps.httpPost ?? postSponsorPermit2Transfer;
2093
2168
  return async (smartWallet, chainId, amount) => {
@@ -2096,7 +2171,7 @@ function makeSponsoredWethCallback(deps) {
2096
2171
  if (!token) {
2097
2172
  throw new OwneyError(
2098
2173
  "CHAIN_UNSUPPORTED",
2099
- `No sponsored WETH configured for chain ${chainId}`
2174
+ `No sponsored ${deps.assetLabel} configured for chain ${chainId}`
2100
2175
  );
2101
2176
  }
2102
2177
  const amountWei = BigInt(amount);
@@ -2108,14 +2183,14 @@ function makeSponsoredWethCallback(deps) {
2108
2183
  if (balance < amountWei) {
2109
2184
  throw new OwneyError(
2110
2185
  "DEPOSIT_INSUFFICIENT_BALANCE",
2111
- "Insufficient WETH balance for this deposit.",
2186
+ `Insufficient ${deps.assetLabel} balance for this deposit.`,
2112
2187
  { token, chainId: cid, balance: balance.toString(), amount }
2113
2188
  );
2114
2189
  }
2115
2190
  } catch (err) {
2116
2191
  if (err instanceof OwneyError) throw err;
2117
2192
  console.warn(
2118
- "[owney-sdk] WETH balance pre-check failed (non-fatal):",
2193
+ `[owney-sdk] ${deps.assetLabel} balance pre-check failed (non-fatal):`,
2119
2194
  err instanceof Error ? err.message : String(err)
2120
2195
  );
2121
2196
  }
@@ -2123,7 +2198,7 @@ function makeSponsoredWethCallback(deps) {
2123
2198
  if (allowance < amountWei) {
2124
2199
  throw new OwneyError(
2125
2200
  "PERMIT2_APPROVAL_REQUIRED",
2126
- "WETH gasless deposits need a one-time Permit2 approval; call approvePermit2() first",
2201
+ `${deps.assetLabel} gasless deposits need a one-time Permit2 approval; call approvePermit2("${deps.assetLabel}") first`,
2127
2202
  { token, chainId: cid, allowance: allowance.toString(), amount }
2128
2203
  );
2129
2204
  }
@@ -2228,7 +2303,7 @@ var OwneySDK = class {
2228
2303
  routingApiBaseUrl;
2229
2304
  referralSource;
2230
2305
  cachedSponsoredCallback = null;
2231
- cachedWethSponsoredCallback = null;
2306
+ cachedPermit2Callbacks = /* @__PURE__ */ new Map();
2232
2307
  initializingAgentsPromise = null;
2233
2308
  constructor(config) {
2234
2309
  this.apiKey = config.apiKey;
@@ -2256,7 +2331,7 @@ var OwneySDK = class {
2256
2331
  }
2257
2332
  this.state = { provider, walletAddress, chainId: null };
2258
2333
  this.cachedSponsoredCallback = null;
2259
- this.cachedWethSponsoredCallback = null;
2334
+ this.cachedPermit2Callbacks.clear();
2260
2335
  }
2261
2336
  /**
2262
2337
  * Disconnect from all agents and clear connection state.
@@ -2270,7 +2345,7 @@ var OwneySDK = class {
2270
2345
  this.disabledAgents.clear();
2271
2346
  this.state = null;
2272
2347
  this.cachedSponsoredCallback = null;
2273
- this.cachedWethSponsoredCallback = null;
2348
+ this.cachedPermit2Callbacks.clear();
2274
2349
  }
2275
2350
  isAgentDisabled(agentId) {
2276
2351
  return this.disabledAgents.has(agentId);
@@ -2347,25 +2422,23 @@ var OwneySDK = class {
2347
2422
  return callback;
2348
2423
  }
2349
2424
  /**
2350
- * Lazily builds (and caches) the default Permit2 sponsored WETH deposit
2351
- * callback used when the caller omits `depositCallback` for a WETH
2352
- * deposit. Mirrors `getDefaultSponsoredCallback()` but signs a Permit2
2353
- * `PermitTransferFrom` instead of an EIP-3009 authorization.
2425
+ * Lazily builds (and caches) the Permit2 sponsored callback for one asset.
2426
+ * Used for WETH always, and for USDC when the connected owner is a smart
2427
+ * account — EIP-3009 verifies with ecrecover inside USDC and cannot accept a
2428
+ * contract signature, while Permit2 supports ERC-1271.
2354
2429
  */
2355
- getDefaultWethSponsoredCallback(onApproved) {
2356
- if (!onApproved && this.cachedWethSponsoredCallback)
2357
- return this.cachedWethSponsoredCallback;
2430
+ getDefaultPermit2Callback(asset, onApproved) {
2431
+ const cached = this.cachedPermit2Callbacks.get(asset);
2432
+ if (!onApproved && cached) return cached;
2358
2433
  const provider = this.requireConnectedProvider();
2359
2434
  const owner = this.state.walletAddress;
2360
- const callback = makeSponsoredWethCallback({
2435
+ const callback = makeSponsoredPermit2Callback({
2361
2436
  apiKey: this.apiKey,
2362
2437
  baseUrl: this.routingApiBaseUrl,
2363
2438
  ownerAddress: owner,
2439
+ assetLabel: asset,
2364
2440
  onApproved,
2365
- tokenAddressByChain: SPONSORED_WETH_BY_CHAIN,
2366
- // Casts work around viem's chain-narrowed Client vs the generic
2367
- // PublicClient/WalletClient param types — structurally identical at
2368
- // runtime, but the two share a name TS treats as unrelated.
2441
+ tokenAddressByChain: asset === "USDC" ? SPONSORED_USDC_BY_CHAIN : SPONSORED_WETH_BY_CHAIN,
2369
2442
  getPublicClient: (cid) => createPublicClient2({
2370
2443
  chain: VIEM_CHAIN2[cid],
2371
2444
  transport: custom(provider)
@@ -2376,9 +2449,34 @@ var OwneySDK = class {
2376
2449
  transport: custom(provider)
2377
2450
  })
2378
2451
  });
2379
- if (!onApproved) this.cachedWethSponsoredCallback = callback;
2452
+ if (!onApproved) this.cachedPermit2Callbacks.set(asset, callback);
2380
2453
  return callback;
2381
2454
  }
2455
+ /**
2456
+ * True when the connected owner has bytecode — a deployed smart wallet, or an
2457
+ * EOA carrying an EIP-7702 delegation (the Base app's "Upgrade your wallet").
2458
+ * Such wallets sign via ERC-1271, which EIP-3009 cannot verify.
2459
+ *
2460
+ * Fails OPEN: an unreadable code check returns false so a working EOA deposit
2461
+ * is never blocked by a flaky provider.
2462
+ */
2463
+ async ownerHasCode() {
2464
+ try {
2465
+ const provider = this.requireConnectedProvider();
2466
+ const code = await provider.request({
2467
+ method: "eth_getCode",
2468
+ params: [this.state.walletAddress, "latest"]
2469
+ });
2470
+ return Boolean(code) && code !== "0x";
2471
+ } catch (error) {
2472
+ debugLog(
2473
+ "deposit",
2474
+ "owner code check failed; assuming EOA",
2475
+ error instanceof Error ? error.message : String(error)
2476
+ );
2477
+ return false;
2478
+ }
2479
+ }
2382
2480
  getAgent(agentId) {
2383
2481
  const agent = this.agents.get(agentId);
2384
2482
  if (!agent) {
@@ -2574,7 +2672,11 @@ var OwneySDK = class {
2574
2672
  }
2575
2673
  const state = this.requireState();
2576
2674
  const chainId = this.requireChainId();
2577
- const effectiveCallback = depositCallback ?? (asset === "WETH" ? this.getDefaultWethSponsoredCallback(onApproved) : this.getDefaultSponsoredCallback(onApproved));
2675
+ const ownerIsSmartAccount = depositCallback === void 0 && asset !== "WETH" ? await this.ownerHasCode() : false;
2676
+ const effectiveCallback = depositCallback ?? (asset === "WETH" || ownerIsSmartAccount ? this.getDefaultPermit2Callback(
2677
+ asset === "WETH" ? "WETH" : "USDC",
2678
+ onApproved
2679
+ ) : this.getDefaultSponsoredCallback(onApproved));
2578
2680
  if (agentId) {
2579
2681
  if (this.isAgentDisabled(agentId)) {
2580
2682
  throw new OwneyError(
@@ -2678,12 +2780,12 @@ var OwneySDK = class {
2678
2780
  try {
2679
2781
  return await attempt(effectiveCallback);
2680
2782
  } catch (error) {
2681
- if (!approvalAttempted && appCallback === void 0 && asset === "WETH" && error instanceof OwneyError && error.code === "PERMIT2_APPROVAL_REQUIRED") {
2783
+ if (!approvalAttempted && appCallback === void 0 && (asset === "WETH" || asset === "USDC") && error instanceof OwneyError && error.code === "PERMIT2_APPROVAL_REQUIRED") {
2682
2784
  approvalAttempted = true;
2683
2785
  console.warn(
2684
- "[owney-sdk] First WETH deposit: sending one-time Permit2 approval..."
2786
+ `[owney-sdk] First ${asset} gasless deposit: sending one-time Permit2 approval...`
2685
2787
  );
2686
- await this.approvePermit2();
2788
+ await this.approvePermit2(asset);
2687
2789
  continue;
2688
2790
  }
2689
2791
  if (!shouldFallbackToUserPaid(error, asset, appCallback)) throw error;
@@ -3197,23 +3299,32 @@ var OwneySDK = class {
3197
3299
  return agent.ensureAutoSelectProtocols(state, chainId, asset);
3198
3300
  }
3199
3301
  /**
3200
- * One-time, user-paid approval of Permit2 on the sponsored WETH token for
3201
- * the active chain. Required once per wallet per chain before gasless WETH
3202
- * deposits; afterwards deposit() is signature-only. Resolves only after the
3203
- * approval transaction is mined (1 confirmation), so a subsequent deposit()
3204
- * will see the new allowance; throws if the transaction reverted.
3302
+ * One-time, user-paid approval of Permit2 on the given sponsored token for
3303
+ * the active chain. Required once per wallet per chain per token before a
3304
+ * gasless deposit of that asset over Permit2; afterwards deposit() is
3305
+ * signature-only for it. This call itself is a real transaction the user
3306
+ * pays gas for. Resolves only after the approval transaction is mined (1
3307
+ * confirmation), so a subsequent deposit() will see the new allowance;
3308
+ * throws if the transaction reverted.
3205
3309
  * @returns the approval transaction hash.
3206
3310
  */
3207
3311
  async approvePermit2(asset = "WETH") {
3208
- void asset;
3209
3312
  const state = this.requireState();
3210
3313
  const chainId = this.requireChainId();
3211
- this.validateAssetSupport(this.getAgent("zyfai"), chainId, "WETH");
3212
- const token = SPONSORED_WETH_BY_CHAIN[chainId];
3314
+ let guard = this.agents.get("zyfai");
3315
+ if (!guard) {
3316
+ try {
3317
+ guard = this.getEligibleAgents(chainId, asset)[0];
3318
+ } catch {
3319
+ guard = void 0;
3320
+ }
3321
+ }
3322
+ if (guard) this.validateAssetSupport(guard, chainId, asset);
3323
+ const token = asset === "USDC" ? SPONSORED_USDC_BY_CHAIN[chainId] : SPONSORED_WETH_BY_CHAIN[chainId];
3213
3324
  if (!token) {
3214
3325
  throw new OwneyError(
3215
3326
  "CHAIN_UNSUPPORTED",
3216
- `No sponsored WETH on chain ${chainId}`
3327
+ `No sponsored ${asset} on chain ${chainId}`
3217
3328
  );
3218
3329
  }
3219
3330
  const provider = this.requireConnectedProvider();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owney/sdk",
3
- "version": "0.7.5",
3
+ "version": "0.7.7-beta.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.cjs",
6
6
  "module": "dist/index.js",