@agether/sdk 2.15.1 → 2.16.1

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.
@@ -1,1974 +0,0 @@
1
- /**
2
- * MorphoClient — Morpho Blue lending only (supply, borrow, repay, withdraw collateral)
3
- *
4
- * For registration, identity, balances, withdrawals, and sponsorship, use AgetherClient.
5
- *
6
- * Architecture (v2 — Safe + Safe7579):
7
- * EOA signs UserOp → EntryPoint.handleOps() → Safe → Safe7579 → execute → Morpho Blue
8
- *
9
- * ERC-7579 execution modes (same encoding, submitted via UserOp):
10
- * - Single: execute(MODE_SINGLE, abi.encode(target, value, callData))
11
- * - Batch: execute(MODE_BATCH, abi.encode(Execution[]))
12
- *
13
- * The Safe account has a sentinel owner (no execTransaction).
14
- * All state-changing operations go through ERC-4337 EntryPoint v0.7.
15
- *
16
- * Market discovery via Morpho GraphQL API (https://api.morpho.org/graphql)
17
- *
18
- * Supports two signing modes:
19
- * - `privateKey`: SDK manages wallet lifecycle (existing behavior)
20
- * - `signer`: external signer (Bankr, Privy, Turnkey, MetaMask, etc.)
21
- */
22
- import { ethers, Contract } from 'ethers';
23
- import axios from 'axios';
24
- import { AgetherError, ChainId, } from '../types';
25
- import { ACCOUNT_FACTORY_ABI, SAFE7579_ACCOUNT_ABI, MORPHO_BLUE_ABI, ERC20_ABI, ENTRYPOINT_V07_ABI, } from '../utils/abis';
26
- import { getDefaultConfig } from '../utils/config';
27
- const MORPHO_API_URL = 'https://api.morpho.org/graphql';
28
- // ── ERC-7579 Execution Mode Constants ──
29
- /** Single call: callType=0x00, execType=0x00, rest zero-padded to 32 bytes */
30
- const MODE_SINGLE = '0x0000000000000000000000000000000000000000000000000000000000000000';
31
- /** Batch call: callType=0x01, execType=0x00, rest zero-padded to 32 bytes */
32
- const MODE_BATCH = '0x0100000000000000000000000000000000000000000000000000000000000000';
33
- // ── Morpho ABI interfaces for encoding ──
34
- const morphoIface = new ethers.Interface(MORPHO_BLUE_ABI);
35
- const erc20Iface = new ethers.Interface(ERC20_ABI);
36
- // ── Client ──
37
- export class MorphoClient {
38
- constructor(config) {
39
- /** Market params cache: keyed by market uniqueKey (bytes32 hash) */
40
- this._marketCache = new Map();
41
- /** Dynamic token registry: symbol (uppercase) or address (lowercase) → { address, symbol, decimals } */
42
- this._tokenCache = new Map();
43
- this._discoveredAt = 0;
44
- if (!config.agentId) {
45
- throw new AgetherError('agentId is required. Use AgetherClient.register() first to get an agentId.', 'NO_AGENT_ID');
46
- }
47
- const chainId = config.chainId ?? ChainId.Ethereum;
48
- const defaultCfg = getDefaultConfig(chainId);
49
- this.config = defaultCfg;
50
- this.agentId = config.agentId;
51
- this._rpcUrl = config.rpcUrl || defaultCfg.rpcUrl;
52
- if ('signer' in config && config.signer) {
53
- // ── External signer path ──
54
- this._useExternalSigner = true;
55
- const signerProvider = config.signer.provider;
56
- if (signerProvider) {
57
- // Signer already has a provider — use it
58
- this.provider = signerProvider;
59
- this._signer = config.signer;
60
- }
61
- else {
62
- // Signer has no provider — create one from rpcUrl and connect
63
- this.provider = new ethers.JsonRpcProvider(this._rpcUrl);
64
- this._signer = config.signer.connect(this.provider);
65
- }
66
- // Try to cache address synchronously (works for ethers.Wallet and similar)
67
- if ('address' in config.signer && typeof config.signer.address === 'string') {
68
- this._eoaAddress = config.signer.address;
69
- }
70
- }
71
- else {
72
- // ── Private key path (existing behavior) ──
73
- this._privateKey = config.privateKey;
74
- this._useExternalSigner = false;
75
- this.provider = new ethers.JsonRpcProvider(this._rpcUrl);
76
- const wallet = new ethers.Wallet(this._privateKey, this.provider);
77
- this._signer = wallet;
78
- this._eoaAddress = wallet.address;
79
- }
80
- const addrs = { ...defaultCfg.contracts, ...config.contracts };
81
- this.agether4337Factory = new Contract(addrs.agether4337Factory, ACCOUNT_FACTORY_ABI, this._signer);
82
- this.morphoBlue = new Contract(addrs.morphoBlue, MORPHO_BLUE_ABI, this.provider);
83
- this.entryPoint = new Contract(addrs.entryPoint, ENTRYPOINT_V07_ABI, this._signer);
84
- }
85
- // ════════════════════════════════════════════════════════
86
- // Account Management
87
- // ════════════════════════════════════════════════════════
88
- /** Resolve the AgentAccount address (cached, with retry for flaky RPCs). */
89
- async getAccountAddress() {
90
- if (this._accountAddress)
91
- return this._accountAddress;
92
- const MAX_RETRIES = 3;
93
- let lastErr;
94
- for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
95
- try {
96
- const addr = await this.agether4337Factory.getAccount(BigInt(this.agentId));
97
- if (addr === ethers.ZeroAddress) {
98
- throw new AgetherError('No AgentAccount found. Call register() first.', 'NO_ACCOUNT');
99
- }
100
- this._accountAddress = addr;
101
- return addr;
102
- }
103
- catch (err) {
104
- // Don't retry application-level errors (NO_ACCOUNT)
105
- if (err instanceof AgetherError)
106
- throw err;
107
- lastErr = err;
108
- if (attempt < MAX_RETRIES) {
109
- await new Promise((r) => setTimeout(r, 500 * attempt)); // backoff
110
- }
111
- }
112
- }
113
- throw lastErr;
114
- }
115
- getAgentId() {
116
- return this.agentId;
117
- }
118
- /**
119
- * Get the EOA wallet address (synchronous, best-effort).
120
- *
121
- * For the `privateKey` path this always works. For the `signer` path
122
- * it works if the signer exposes `.address` synchronously (e.g. ethers.Wallet).
123
- * If the address has not been resolved yet, throws — call `getSignerAddress()` first.
124
- */
125
- getWalletAddress() {
126
- if (this._eoaAddress)
127
- return this._eoaAddress;
128
- // Try sync access for Wallet-like signers
129
- const signer = this._signer;
130
- if (signer.address && typeof signer.address === 'string') {
131
- const addr = signer.address;
132
- this._eoaAddress = addr;
133
- return addr;
134
- }
135
- throw new AgetherError('EOA address not yet resolved. Call getSignerAddress() (async) first, or use a signer that exposes .address synchronously.', 'ADDRESS_NOT_RESOLVED');
136
- }
137
- /**
138
- * Resolve the EOA signer address (async, works with all signer types).
139
- * Result is cached after the first call.
140
- */
141
- async getSignerAddress() {
142
- if (!this._eoaAddress) {
143
- this._eoaAddress = await this._signer.getAddress();
144
- }
145
- return this._eoaAddress;
146
- }
147
- // ════════════════════════════════════════════════════════
148
- // Market Discovery (Morpho GraphQL API)
149
- // ════════════════════════════════════════════════════════
150
- /**
151
- * Fetch available markets on the current chain from Morpho API.
152
- * Caches results for 5 minutes. Supports all loan tokens (not just USDC).
153
- *
154
- * @param forceRefresh - bypass cache TTL
155
- * @param filter - optional filter by loan token and/or collateral token
156
- */
157
- async getMarkets(forceRefresh = false, filter) {
158
- if (!forceRefresh && this._discoveredMarkets && Date.now() - this._discoveredAt < 300000) {
159
- return filter ? this._applyMarketFilter(this._discoveredMarkets, filter) : this._discoveredMarkets;
160
- }
161
- const chainId = this.config.chainId;
162
- const query = `{
163
- markets(
164
- first: 500
165
- orderBy: SupplyAssetsUsd
166
- orderDirection: Desc
167
- where: { chainId_in: [${chainId}] }
168
- ) {
169
- items {
170
- uniqueKey
171
- lltv
172
- oracleAddress
173
- irmAddress
174
- loanAsset { address symbol decimals }
175
- collateralAsset { address symbol decimals }
176
- state {
177
- borrowAssets
178
- supplyAssets
179
- utilization
180
- }
181
- }
182
- }
183
- }`;
184
- try {
185
- const resp = await axios.post(MORPHO_API_URL, { query }, { timeout: 10000 });
186
- const items = resp.data?.data?.markets?.items ?? [];
187
- this._discoveredMarkets = items.map((m) => ({
188
- uniqueKey: m.uniqueKey,
189
- loanAsset: m.loanAsset,
190
- collateralAsset: m.collateralAsset ?? { address: ethers.ZeroAddress, symbol: 'N/A', decimals: 0 },
191
- oracle: m.oracleAddress,
192
- irm: m.irmAddress,
193
- lltv: BigInt(m.lltv),
194
- totalSupplyAssets: BigInt(m.state?.supplyAssets ?? '0'),
195
- totalBorrowAssets: BigInt(m.state?.borrowAssets ?? '0'),
196
- utilization: m.state?.utilization ? Number(m.state.utilization) : 0,
197
- }));
198
- this._discoveredAt = Date.now();
199
- // Warm the params cache (keyed by uniqueKey) and token cache
200
- for (const mi of this._discoveredMarkets) {
201
- // Cache market params by uniqueKey
202
- this._marketCache.set(mi.uniqueKey.toLowerCase(), {
203
- loanToken: mi.loanAsset.address,
204
- collateralToken: mi.collateralAsset.address,
205
- oracle: mi.oracle,
206
- irm: mi.irm,
207
- lltv: mi.lltv,
208
- });
209
- if (mi.collateralAsset.address !== ethers.ZeroAddress) {
210
- // Register collateral token (keyed by uppercase symbol)
211
- this._tokenCache.set(mi.collateralAsset.symbol.toUpperCase(), {
212
- address: mi.collateralAsset.address,
213
- symbol: mi.collateralAsset.symbol,
214
- decimals: mi.collateralAsset.decimals,
215
- });
216
- // Also register by lowercase address for reverse lookups
217
- this._tokenCache.set(mi.collateralAsset.address.toLowerCase(), {
218
- address: mi.collateralAsset.address,
219
- symbol: mi.collateralAsset.symbol,
220
- decimals: mi.collateralAsset.decimals,
221
- });
222
- }
223
- // Register loan token too
224
- if (mi.loanAsset.address !== ethers.ZeroAddress) {
225
- this._tokenCache.set(mi.loanAsset.symbol.toUpperCase(), {
226
- address: mi.loanAsset.address,
227
- symbol: mi.loanAsset.symbol,
228
- decimals: mi.loanAsset.decimals,
229
- });
230
- this._tokenCache.set(mi.loanAsset.address.toLowerCase(), {
231
- address: mi.loanAsset.address,
232
- symbol: mi.loanAsset.symbol,
233
- decimals: mi.loanAsset.decimals,
234
- });
235
- }
236
- }
237
- return filter ? this._applyMarketFilter(this._discoveredMarkets, filter) : this._discoveredMarkets;
238
- }
239
- catch (e) {
240
- console.warn('[agether] getMarkets failed, using cache:', e instanceof Error ? e.message : e);
241
- // Fallback: return cached or empty
242
- const cached = this._discoveredMarkets ?? [];
243
- return filter ? this._applyMarketFilter(cached, filter) : cached;
244
- }
245
- }
246
- /**
247
- * Get MarketParams for a collateral token (and optionally a specific loan token).
248
- * Tries cache → API discovery.
249
- *
250
- * When `loanTokenSymbolOrAddress` is omitted, returns the most liquid market
251
- * for that collateral (sorted by supply, typically the USDC market).
252
- *
253
- * @param collateralSymbolOrAddress - e.g. 'WETH', 'wstETH', or '0x4200...'
254
- * @param loanTokenSymbolOrAddress - e.g. 'USDC', 'WETH', or '0x833589...' (optional)
255
- */
256
- async findMarketForCollateral(collateralSymbolOrAddress, loanTokenSymbolOrAddress) {
257
- // Resolve collateral symbol → address
258
- let colAddr;
259
- if (collateralSymbolOrAddress.startsWith('0x')) {
260
- colAddr = collateralSymbolOrAddress.toLowerCase();
261
- }
262
- else {
263
- try {
264
- const resolved = await this._resolveToken(collateralSymbolOrAddress);
265
- colAddr = resolved.address.toLowerCase();
266
- }
267
- catch {
268
- colAddr = collateralSymbolOrAddress.toLowerCase();
269
- }
270
- }
271
- // Resolve loan token symbol → address (if provided)
272
- let loanAddr;
273
- if (loanTokenSymbolOrAddress) {
274
- if (loanTokenSymbolOrAddress.startsWith('0x')) {
275
- loanAddr = loanTokenSymbolOrAddress.toLowerCase();
276
- }
277
- else {
278
- try {
279
- const resolved = await this._resolveToken(loanTokenSymbolOrAddress);
280
- loanAddr = resolved.address.toLowerCase();
281
- }
282
- catch {
283
- loanAddr = loanTokenSymbolOrAddress.toLowerCase();
284
- }
285
- }
286
- }
287
- // Ensure markets are loaded
288
- if (!this._discoveredMarkets)
289
- await this.getMarkets();
290
- // Search through discovered markets (already sorted by supply descending)
291
- for (const m of this._discoveredMarkets ?? []) {
292
- if (m.collateralAsset.address.toLowerCase() !== colAddr)
293
- continue;
294
- if (loanAddr && m.loanAsset.address.toLowerCase() !== loanAddr)
295
- continue;
296
- return {
297
- loanToken: m.loanAsset.address,
298
- collateralToken: m.collateralAsset.address,
299
- oracle: m.oracle,
300
- irm: m.irm,
301
- lltv: m.lltv,
302
- };
303
- }
304
- // Fallback: use Morpho search API to find the market (handles tokens
305
- // outside the top-500 cache). Resolves the full MarketParams onchain.
306
- if (!collateralSymbolOrAddress.startsWith('0x')) {
307
- const searched = await this.searchMarkets(collateralSymbolOrAddress, { asCollateral: true });
308
- for (const m of searched) {
309
- if (loanAddr && m.loanAddress.toLowerCase() !== loanAddr)
310
- continue;
311
- if (loanTokenSymbolOrAddress && !loanTokenSymbolOrAddress.startsWith('0x')
312
- && m.loanToken.toUpperCase() !== loanTokenSymbolOrAddress.toUpperCase())
313
- continue;
314
- // Resolve full params onchain
315
- return this.getMarketParams(m.marketId);
316
- }
317
- }
318
- throw new AgetherError(`No Morpho market found for collateral ${collateralSymbolOrAddress}` +
319
- (loanTokenSymbolOrAddress ? ` with loan token ${loanTokenSymbolOrAddress}` : ''), 'MARKET_NOT_FOUND');
320
- }
321
- /** Read MarketParams onchain by market ID (bytes32). */
322
- async getMarketParams(marketId) {
323
- const result = await this.morphoBlue.idToMarketParams(marketId);
324
- return {
325
- loanToken: result.loanToken,
326
- collateralToken: result.collateralToken,
327
- oracle: result.oracle,
328
- irm: result.irm,
329
- lltv: result.lltv,
330
- };
331
- }
332
- // ════════════════════════════════════════════════════════
333
- // Position Reads
334
- // ════════════════════════════════════════════════════════
335
- /** Read onchain position for a specific market. */
336
- async getPosition(marketId) {
337
- const acctAddr = await this.getAccountAddress();
338
- const pos = await this.morphoBlue.position(marketId, acctAddr);
339
- return {
340
- supplyShares: pos.supplyShares,
341
- borrowShares: pos.borrowShares,
342
- collateral: pos.collateral,
343
- };
344
- }
345
- /**
346
- * Full status: positions across all discovered markets.
347
- */
348
- async getStatus() {
349
- const acctAddr = await this.getAccountAddress();
350
- const markets = await this.getMarkets();
351
- const positions = [];
352
- let totalDebtFloat = 0;
353
- for (const m of markets) {
354
- if (!m.collateralAsset || m.collateralAsset.address === ethers.ZeroAddress)
355
- continue;
356
- try {
357
- const pos = await this.morphoBlue.position(m.uniqueKey, acctAddr);
358
- if (pos.collateral === 0n && pos.borrowShares === 0n && pos.supplyShares === 0n)
359
- continue;
360
- const loanDecimals = m.loanAsset.decimals;
361
- // Estimate debt from shares (ceil division — shows actual amount owed, never hides dust)
362
- let debt = 0n;
363
- if (pos.borrowShares > 0n) {
364
- try {
365
- const mkt = await this.morphoBlue.market(m.uniqueKey);
366
- const totalBorrowShares = BigInt(mkt.totalBorrowShares);
367
- const totalBorrowAssets = BigInt(mkt.totalBorrowAssets);
368
- debt = totalBorrowShares > 0n
369
- ? (BigInt(pos.borrowShares) * totalBorrowAssets + totalBorrowShares - 1n) / totalBorrowShares
370
- : 0n;
371
- totalDebtFloat += parseFloat(ethers.formatUnits(debt, loanDecimals));
372
- }
373
- catch (e) {
374
- console.warn(`[agether] debt calc failed for market ${m.uniqueKey}:`, e instanceof Error ? e.message : e);
375
- }
376
- }
377
- positions.push({
378
- marketId: m.uniqueKey,
379
- collateralToken: m.collateralAsset.symbol,
380
- loanToken: m.loanAsset.symbol,
381
- collateral: ethers.formatUnits(pos.collateral, m.collateralAsset.decimals),
382
- borrowShares: pos.borrowShares.toString(),
383
- supplyShares: pos.supplyShares.toString(),
384
- debt: ethers.formatUnits(debt, loanDecimals),
385
- });
386
- }
387
- catch (e) {
388
- console.warn(`[agether] position read failed for market:`, e instanceof Error ? e.message : e);
389
- continue;
390
- }
391
- }
392
- return {
393
- agentId: this.agentId,
394
- agentAccount: acctAddr,
395
- totalDebt: totalDebtFloat.toFixed(6),
396
- positions,
397
- };
398
- }
399
- // ════════════════════════════════════════════════════════
400
- // Balance & Borrowing Capacity
401
- // ════════════════════════════════════════════════════════
402
- /**
403
- * Get the balance of any ERC-20 token in the AgentAccount.
404
- * @param symbolOrAddress - token symbol (e.g. 'USDC', 'WETH') or address
405
- * @returns balance in raw units
406
- */
407
- async getTokenBalance(symbolOrAddress) {
408
- const acctAddr = await this.getAccountAddress();
409
- const tokenInfo = await this._resolveToken(symbolOrAddress);
410
- const token = new Contract(tokenInfo.address, ERC20_ABI, this.provider);
411
- return token.balanceOf(acctAddr);
412
- }
413
- /**
414
- * Get the USDC balance of the AgentAccount.
415
- * @returns USDC balance in raw units (6 decimals)
416
- * @deprecated Use `getTokenBalance('USDC')` instead.
417
- */
418
- async getUsdcBalance() {
419
- const acctAddr = await this.getAccountAddress();
420
- const usdc = new Contract(this.config.contracts.usdc, ERC20_ABI, this.provider);
421
- return usdc.balanceOf(acctAddr);
422
- }
423
- /**
424
- * Calculate the maximum additional loan token that can be borrowed
425
- * given the agent's current collateral and debt across all markets.
426
- *
427
- * For each market with collateral deposited:
428
- * maxBorrow = (collateralValue * LLTV) - currentDebt
429
- *
430
- * Uses the Morpho oracle to price collateral → loan token.
431
- *
432
- * @returns Maximum additional borrowable per market (raw units in each market's loan token)
433
- */
434
- async getMaxBorrowable() {
435
- const acctAddr = await this.getAccountAddress();
436
- const markets = await this.getMarkets();
437
- let totalAdditional = 0n;
438
- const byMarket = [];
439
- for (const m of markets) {
440
- if (!m.collateralAsset || m.collateralAsset.address === ethers.ZeroAddress)
441
- continue;
442
- try {
443
- const pos = await this.morphoBlue.position(m.uniqueKey, acctAddr);
444
- if (pos.collateral === 0n)
445
- continue;
446
- // Get market state for debt calculation (ceil division for accurate debt)
447
- const mktState = await this.morphoBlue.market(m.uniqueKey);
448
- const totalBorrowShares = BigInt(mktState.totalBorrowShares);
449
- const totalBorrowAssets = BigInt(mktState.totalBorrowAssets);
450
- const currentDebt = totalBorrowShares > 0n
451
- ? (BigInt(pos.borrowShares) * totalBorrowAssets + totalBorrowShares - 1n) / totalBorrowShares
452
- : 0n;
453
- // Get oracle price for collateral → loan token conversion
454
- // Morpho oracle returns price scaled to 36 + loanDecimals - collateralDecimals
455
- // price = collateral * oraclePrice / 10^36
456
- let collateralValueInLoan;
457
- try {
458
- const oracleContract = new Contract(m.oracle, [
459
- 'function price() view returns (uint256)',
460
- ], this.provider);
461
- const oraclePrice = await oracleContract.price();
462
- const ORACLE_PRICE_SCALE = 10n ** 36n;
463
- collateralValueInLoan = (BigInt(pos.collateral) * oraclePrice) / ORACLE_PRICE_SCALE;
464
- }
465
- catch (e) {
466
- console.warn(`[agether] oracle price fetch failed:`, e instanceof Error ? e.message : e);
467
- continue;
468
- }
469
- // maxBorrow = collateralValue * LLTV / 1e18 - currentDebt
470
- const maxBorrowTotal = (collateralValueInLoan * m.lltv) / (10n ** 18n);
471
- const maxAdditional = maxBorrowTotal > currentDebt ? maxBorrowTotal - currentDebt : 0n;
472
- totalAdditional += maxAdditional;
473
- byMarket.push({
474
- collateralToken: m.collateralAsset.symbol,
475
- loanToken: m.loanAsset.symbol,
476
- loanDecimals: m.loanAsset.decimals,
477
- maxAdditional,
478
- currentDebt,
479
- collateralValue: collateralValueInLoan,
480
- });
481
- }
482
- catch (e) {
483
- console.warn(`[agether] maxBorrow calc failed:`, e instanceof Error ? e.message : e);
484
- continue;
485
- }
486
- }
487
- return { total: totalAdditional, byMarket };
488
- }
489
- // ════════════════════════════════════════════════════════
490
- // Market Rates & Yield Estimation
491
- // ════════════════════════════════════════════════════════
492
- /**
493
- * Fetch current supply/borrow APY for markets from Morpho GraphQL API.
494
- *
495
- * Note: On Morpho Blue, collateral does NOT earn yield directly. Supply APY
496
- * is what lenders earn; borrow APY is what borrowers pay.
497
- *
498
- * @param collateralSymbolOrAddress - filter by collateral token (optional)
499
- * @param loanTokenSymbolOrAddress - filter by loan token (optional). Omit for all loan tokens.
500
- */
501
- async getMarketRates(collateralSymbolOrAddress, loanTokenSymbolOrAddress) {
502
- const chainId = this.config.chainId;
503
- // Resolve address filters. For symbols, try _resolveToken first, then
504
- // fall back to using Morpho GraphQL `search` field (fuzzy match on name).
505
- let collateralFilter = '';
506
- let loanFilter = '';
507
- let searchTerm = '';
508
- if (collateralSymbolOrAddress) {
509
- if (collateralSymbolOrAddress.startsWith('0x')) {
510
- collateralFilter = `, collateralAssetAddress_in: ["${collateralSymbolOrAddress.toLowerCase()}"]`;
511
- }
512
- else {
513
- // Try token cache first
514
- const cached = this._tokenCache.get(collateralSymbolOrAddress.toUpperCase());
515
- if (cached) {
516
- collateralFilter = `, collateralAssetAddress_in: ["${cached.address.toLowerCase()}"]`;
517
- }
518
- else {
519
- // Use Morpho's built-in search — no hardcoded addresses needed
520
- searchTerm = collateralSymbolOrAddress;
521
- }
522
- }
523
- }
524
- if (loanTokenSymbolOrAddress) {
525
- if (loanTokenSymbolOrAddress.startsWith('0x')) {
526
- loanFilter = `, loanAssetAddress_in: ["${loanTokenSymbolOrAddress.toLowerCase()}"]`;
527
- }
528
- else {
529
- const cached = this._tokenCache.get(loanTokenSymbolOrAddress.toUpperCase());
530
- if (cached) {
531
- loanFilter = `, loanAssetAddress_in: ["${cached.address.toLowerCase()}"]`;
532
- }
533
- else {
534
- searchTerm = searchTerm || loanTokenSymbolOrAddress;
535
- }
536
- }
537
- }
538
- const searchClause = searchTerm ? `, search: "${searchTerm}"` : '';
539
- const query = `{
540
- markets(
541
- first: 100
542
- orderBy: SupplyAssetsUsd
543
- orderDirection: Desc
544
- where: { chainId_in: [${chainId}]${loanFilter}${collateralFilter}${searchClause} }
545
- ) {
546
- items {
547
- uniqueKey
548
- lltv
549
- loanAsset { address symbol decimals }
550
- collateralAsset { address symbol decimals }
551
- state {
552
- borrowAssets
553
- supplyAssets
554
- utilization
555
- supplyApy
556
- borrowApy
557
- }
558
- }
559
- }
560
- }`;
561
- try {
562
- const resp = await axios.post(MORPHO_API_URL, { query }, { timeout: 10000 });
563
- let items = resp.data?.data?.markets?.items ?? [];
564
- // Post-filter: when we used `search` (fuzzy), verify the token actually
565
- // matches the requested position (collateral vs loan).
566
- if (searchTerm && collateralSymbolOrAddress && !collateralSymbolOrAddress.startsWith('0x')) {
567
- const sym = collateralSymbolOrAddress.toUpperCase();
568
- items = items.filter((m) => m.collateralAsset?.symbol?.toUpperCase() === sym);
569
- }
570
- if (searchTerm && loanTokenSymbolOrAddress && !loanTokenSymbolOrAddress.startsWith('0x')) {
571
- const sym = loanTokenSymbolOrAddress.toUpperCase();
572
- items = items.filter((m) => m.loanAsset?.symbol?.toUpperCase() === sym);
573
- }
574
- return items
575
- .filter((m) => m.collateralAsset?.address && m.collateralAsset.address !== ethers.ZeroAddress)
576
- .map((m) => {
577
- const loanDecimals = m.loanAsset?.decimals ?? 18;
578
- return {
579
- collateralToken: m.collateralAsset.symbol,
580
- loanToken: m.loanAsset.symbol,
581
- loanDecimals,
582
- supplyApy: m.state?.supplyApy ? Number(m.state.supplyApy) : 0,
583
- borrowApy: m.state?.borrowApy ? Number(m.state.borrowApy) : 0,
584
- utilization: m.state?.utilization ? Number(m.state.utilization) : 0,
585
- totalSupplyUsd: m.state?.supplyAssets ? Number(m.state.supplyAssets) / (10 ** loanDecimals) : 0,
586
- totalBorrowUsd: m.state?.borrowAssets ? Number(m.state.borrowAssets) / (10 ** loanDecimals) : 0,
587
- lltv: `${(Number(m.lltv) / 1e16).toFixed(0)}%`,
588
- marketId: m.uniqueKey,
589
- };
590
- });
591
- }
592
- catch (e) {
593
- console.warn('[agether] getMarketRates failed:', e instanceof Error ? e.message : e);
594
- return [];
595
- }
596
- }
597
- // ════════════════════════════════════════════════════════
598
- // Market Search & Wallet Discovery
599
- // ════════════════════════════════════════════════════════
600
- /**
601
- * Search Morpho markets by token name using the Morpho GraphQL API `search` field.
602
- * No hardcoded token lists — uses Morpho's built-in fuzzy search.
603
- *
604
- * @param search - token name or symbol (e.g. 'WETH', 'staked ETH', 'ezETH')
605
- * @param options.asCollateral - only return markets where the searched token is collateral
606
- * @param options.asLoanToken - only return markets where the searched token is the loan asset
607
- */
608
- async searchMarkets(search, options) {
609
- const chainId = this.config.chainId;
610
- const query = `{
611
- markets(
612
- first: 100
613
- orderBy: SupplyAssetsUsd
614
- orderDirection: Desc
615
- where: { chainId_in: [${chainId}], search: "${search}" }
616
- ) {
617
- items {
618
- uniqueKey
619
- lltv
620
- loanAsset { address symbol decimals }
621
- collateralAsset { address symbol decimals }
622
- state {
623
- borrowAssets
624
- supplyAssets
625
- utilization
626
- supplyApy
627
- borrowApy
628
- }
629
- }
630
- }
631
- }`;
632
- try {
633
- const resp = await axios.post(MORPHO_API_URL, { query }, { timeout: 10000 });
634
- let items = resp.data?.data?.markets?.items ?? [];
635
- // Filter out markets with no collateral (supply-only idle markets)
636
- items = items.filter((m) => m.collateralAsset?.address && m.collateralAsset.address !== ethers.ZeroAddress);
637
- // Post-filter by position if requested
638
- const searchUpper = search.toUpperCase();
639
- if (options?.asCollateral) {
640
- items = items.filter((m) => m.collateralAsset?.symbol?.toUpperCase() === searchUpper);
641
- }
642
- if (options?.asLoanToken) {
643
- items = items.filter((m) => m.loanAsset?.symbol?.toUpperCase() === searchUpper);
644
- }
645
- return items.map((m) => {
646
- const loanDecimals = m.loanAsset?.decimals ?? 18;
647
- const collateralDecimals = m.collateralAsset?.decimals ?? 18;
648
- return {
649
- collateralToken: m.collateralAsset.symbol,
650
- loanToken: m.loanAsset.symbol,
651
- loanDecimals,
652
- collateralDecimals,
653
- supplyApy: m.state?.supplyApy ? Number(m.state.supplyApy) : 0,
654
- borrowApy: m.state?.borrowApy ? Number(m.state.borrowApy) : 0,
655
- utilization: m.state?.utilization ? Number(m.state.utilization) : 0,
656
- totalSupplyUsd: m.state?.supplyAssets ? Number(m.state.supplyAssets) / (10 ** loanDecimals) : 0,
657
- totalBorrowUsd: m.state?.borrowAssets ? Number(m.state.borrowAssets) / (10 ** loanDecimals) : 0,
658
- lltv: `${(Number(m.lltv) / 1e16).toFixed(0)}%`,
659
- marketId: m.uniqueKey,
660
- collateralAddress: m.collateralAsset.address,
661
- loanAddress: m.loanAsset.address,
662
- };
663
- });
664
- }
665
- catch (e) {
666
- console.warn('[agether] searchMarkets failed:', e instanceof Error ? e.message : e);
667
- return [];
668
- }
669
- }
670
- /**
671
- * Scan the AgentAccount wallet for all ERC-20 tokens that appear in Morpho
672
- * markets on the current chain. Returns tokens where balance > 0.
673
- *
674
- * Uses the full market list (500 markets) to discover all relevant tokens,
675
- * then checks on-chain balance for each unique token address.
676
- *
677
- * @returns Array of tokens with non-zero balance, sorted by balance descending.
678
- */
679
- async getWalletTokenBalances() {
680
- const acctAddr = await this.getAccountAddress();
681
- // Ensure full market set is loaded (populates _tokenCache with all tokens)
682
- await this.getMarkets();
683
- // Collect unique token addresses from cache
684
- const uniqueTokens = new Map();
685
- for (const [key, info] of this._tokenCache.entries()) {
686
- if (key.startsWith('0x') && !uniqueTokens.has(key)) {
687
- uniqueTokens.set(key, info);
688
- }
689
- }
690
- // Also always check ETH balance
691
- const results = [];
692
- // Check ETH
693
- try {
694
- const ethBalance = await this.provider.getBalance(acctAddr);
695
- if (ethBalance > 0n) {
696
- results.push({
697
- symbol: 'ETH',
698
- address: ethers.ZeroAddress,
699
- decimals: 18,
700
- balance: ethBalance,
701
- balanceFormatted: ethers.formatEther(ethBalance),
702
- });
703
- }
704
- }
705
- catch { /* ignore */ }
706
- // Batch-check ERC-20 balances (parallel, 20 at a time to avoid rate limits)
707
- const tokenEntries = Array.from(uniqueTokens.values());
708
- const batchSize = 20;
709
- for (let i = 0; i < tokenEntries.length; i += batchSize) {
710
- const batch = tokenEntries.slice(i, i + batchSize);
711
- const checks = batch.map(async (info) => {
712
- try {
713
- const token = new Contract(info.address, ERC20_ABI, this.provider);
714
- const balance = await token.balanceOf(acctAddr);
715
- if (balance > 0n) {
716
- return {
717
- symbol: info.symbol,
718
- address: info.address,
719
- decimals: info.decimals,
720
- balance,
721
- balanceFormatted: ethers.formatUnits(balance, info.decimals),
722
- };
723
- }
724
- }
725
- catch { /* token call failed — skip */ }
726
- return null;
727
- });
728
- const batchResults = await Promise.all(checks);
729
- for (const r of batchResults) {
730
- if (r)
731
- results.push(r);
732
- }
733
- }
734
- // Sort by balance descending (normalized to avoid cross-decimal comparison issues)
735
- results.sort((a, b) => (b.balance > a.balance ? 1 : b.balance < a.balance ? -1 : 0));
736
- return results;
737
- }
738
- /**
739
- * Find borrowing opportunities for the agent.
740
- *
741
- * - If `collateralSymbol` is provided: find all markets where that token is collateral.
742
- * - If omitted: scan wallet balances and find markets for each token the agent holds.
743
- *
744
- * Returns markets grouped by collateral token with APY, liquidity, and balance info.
745
- *
746
- * @param collateralSymbol - optional, e.g. 'WETH'. If omitted, scans wallet.
747
- */
748
- async findBorrowingOptions(collateralSymbol) {
749
- let tokensToCheck;
750
- if (collateralSymbol) {
751
- // Single token mode — use search to find markets
752
- tokensToCheck = [{ symbol: collateralSymbol, balanceFormatted: 'N/A' }];
753
- // Try to get actual balance if possible
754
- try {
755
- const balance = await this.getTokenBalance(collateralSymbol);
756
- const info = await this._resolveToken(collateralSymbol);
757
- tokensToCheck = [{ symbol: collateralSymbol, balanceFormatted: ethers.formatUnits(balance, info.decimals) }];
758
- }
759
- catch { /* use N/A */ }
760
- }
761
- else {
762
- // Wallet scan mode — find all tokens with balance > 0
763
- const walletTokens = await this.getWalletTokenBalances();
764
- tokensToCheck = walletTokens
765
- .filter(t => t.symbol !== 'ETH') // ETH can't be used directly as collateral (need WETH)
766
- .map(t => ({ symbol: t.symbol, balanceFormatted: t.balanceFormatted }));
767
- if (tokensToCheck.length === 0) {
768
- return [];
769
- }
770
- }
771
- const results = [];
772
- for (const token of tokensToCheck) {
773
- // Use searchMarkets to find markets where this token is collateral
774
- const markets = await this.searchMarkets(token.symbol, { asCollateral: true });
775
- if (markets.length === 0)
776
- continue;
777
- results.push({
778
- collateralToken: token.symbol,
779
- collateralBalance: token.balanceFormatted,
780
- markets: markets.map(m => ({
781
- loanToken: m.loanToken,
782
- borrowApy: `${(m.borrowApy * 100).toFixed(2)}%`,
783
- supplyApy: `${(m.supplyApy * 100).toFixed(2)}%`,
784
- lltv: m.lltv,
785
- utilization: `${(m.utilization * 100).toFixed(1)}%`,
786
- availableLiquidity: `$${(m.totalSupplyUsd - m.totalBorrowUsd).toFixed(0)}`,
787
- marketId: m.marketId,
788
- })),
789
- });
790
- }
791
- return results;
792
- }
793
- /**
794
- * Find supply/lending opportunities for a specific loan token.
795
- *
796
- * "What can I supply to earn WETH?" → shows all markets where WETH is the loan token
797
- * (user supplies WETH to earn yield from borrowers).
798
- *
799
- * @param loanTokenSymbol - e.g. 'USDC', 'WETH'
800
- */
801
- async findSupplyOptions(loanTokenSymbol) {
802
- const markets = await this.searchMarkets(loanTokenSymbol, { asLoanToken: true });
803
- return markets.map(m => ({
804
- collateralToken: m.collateralToken,
805
- loanToken: m.loanToken,
806
- supplyApy: `${(m.supplyApy * 100).toFixed(2)}%`,
807
- borrowApy: `${(m.borrowApy * 100).toFixed(2)}%`,
808
- lltv: m.lltv,
809
- utilization: `${(m.utilization * 100).toFixed(1)}%`,
810
- totalSupply: `$${m.totalSupplyUsd.toFixed(0)}`,
811
- marketId: m.marketId,
812
- }));
813
- }
814
- /**
815
- * Estimate theoretical yield for a given collateral amount over a period.
816
- *
817
- * ⚠️ IMPORTANT: On Morpho Blue, collateral does NOT earn yield directly.
818
- * This estimates what the collateral WOULD earn if it were instead supplied
819
- * as a lender (not used as collateral). This is a theoretical upper bound
820
- * useful for setting spending caps.
821
- *
822
- * @param collateralSymbol - e.g. 'WETH'
823
- * @param amount - collateral amount in human-readable (e.g. '1.5')
824
- * @param periodDays - estimation period in days (default: 1)
825
- * @param ethPriceUsd - ETH price in USD for value conversion (if not provided, uses oracle)
826
- * @returns Estimated yield in USD for the period
827
- */
828
- async getYieldEstimate(collateralSymbol, amount, periodDays = 1, ethPriceUsd) {
829
- const colInfo = await this._resolveToken(collateralSymbol);
830
- // Get market rates for this collateral
831
- const rates = await this.getMarketRates(collateralSymbol);
832
- if (rates.length === 0) {
833
- throw new AgetherError(`No market found for ${collateralSymbol}`, 'MARKET_NOT_FOUND');
834
- }
835
- // Use the most liquid market (first one, sorted by supply)
836
- const market = rates[0];
837
- const supplyApy = market.supplyApy;
838
- // Determine collateral value in loan token units (then approximate as USD)
839
- let collateralValueUsd;
840
- if (ethPriceUsd) {
841
- collateralValueUsd = parseFloat(amount) * ethPriceUsd;
842
- }
843
- else {
844
- try {
845
- const params = await this.findMarketForCollateral(collateralSymbol);
846
- const loanDecimals = await this._getLoanTokenDecimals(params);
847
- const oracleContract = new Contract(params.oracle, [
848
- 'function price() view returns (uint256)',
849
- ], this.provider);
850
- const oraclePrice = await oracleContract.price();
851
- const ORACLE_PRICE_SCALE = 10n ** 36n;
852
- const amountWei = ethers.parseUnits(amount, colInfo.decimals);
853
- const valueInLoan = (amountWei * oraclePrice) / ORACLE_PRICE_SCALE;
854
- collateralValueUsd = Number(valueInLoan) / (10 ** loanDecimals);
855
- }
856
- catch (e) {
857
- console.warn('[agether] oracle price fetch for yield estimation failed:', e instanceof Error ? e.message : e);
858
- throw new AgetherError('Cannot determine collateral value. Provide ethPriceUsd.', 'PRICE_UNAVAILABLE');
859
- }
860
- }
861
- // Calculate yield: value * APY * (days / 365)
862
- const estimatedYieldUsd = collateralValueUsd * supplyApy * (periodDays / 365);
863
- return {
864
- collateralToken: collateralSymbol,
865
- amount,
866
- periodDays,
867
- theoreticalSupplyApy: supplyApy,
868
- estimatedYieldUsd,
869
- collateralValueUsd,
870
- disclaimer: 'Collateral on Morpho Blue does NOT earn yield directly. This estimates what it WOULD earn if supplied as a lender instead. Use as a theoretical spending cap.',
871
- };
872
- }
873
- // ════════════════════════════════════════════════════════
874
- // Supply-Side (Lending) — earn yield by supplying USDC
875
- // ════════════════════════════════════════════════════════
876
- /**
877
- * Supply loan token to a Morpho Blue market as a lender (earn yield).
878
- *
879
- * Unlike `supplyCollateral` (borrower-side), this is the **lender-side**:
880
- * you deposit the loanToken into the market's supply pool and earn
881
- * interest paid by borrowers.
882
- *
883
- * @param amount - Amount of loan token to supply (e.g. '500' for 500 USDC, '0.5' for 0.5 WETH)
884
- * @param collateralSymbol - Market collateral token to identify which market (e.g. 'WETH')
885
- * Optional — defaults to highest-APY market
886
- * @param loanTokenSymbol - Loan token to filter market (e.g. 'USDC', 'WETH'). Optional.
887
- */
888
- async supplyAsset(amount, collateralSymbol, loanTokenSymbol) {
889
- const acctAddr = await this.getAccountAddress();
890
- const morphoAddr = this.config.contracts.morphoBlue;
891
- // Find market
892
- let params;
893
- let usedCollateral;
894
- if (collateralSymbol) {
895
- params = await this.findMarketForCollateral(collateralSymbol, loanTokenSymbol);
896
- usedCollateral = collateralSymbol;
897
- }
898
- else {
899
- // Auto-pick highest APY market (optionally filtered by loan token)
900
- const rates = await this.getMarketRates(undefined, loanTokenSymbol);
901
- if (rates.length === 0)
902
- throw new AgetherError('No markets available', 'NO_MARKETS');
903
- const best = rates.reduce((a, b) => a.supplyApy > b.supplyApy ? a : b);
904
- params = await this.findMarketForCollateral(best.collateralToken, loanTokenSymbol);
905
- usedCollateral = best.collateralToken;
906
- }
907
- // Resolve loan token decimals
908
- const loanDecimals = await this._getLoanTokenDecimals(params);
909
- const loanTokenAddr = params.loanToken;
910
- const parsedAmount = ethers.parseUnits(amount, loanDecimals);
911
- // Compute marketId for result
912
- const marketId = ethers.keccak256(ethers.AbiCoder.defaultAbiCoder().encode(['address', 'address', 'address', 'address', 'uint256'], [params.loanToken, params.collateralToken, params.oracle, params.irm, params.lltv]));
913
- // Ensure AgentAccount has enough loan token (transfer shortfall from EOA)
914
- const loanContract = new Contract(loanTokenAddr, ERC20_ABI, this._signer);
915
- const acctBalance = await loanContract.balanceOf(acctAddr);
916
- if (acctBalance < parsedAmount) {
917
- const shortfall = parsedAmount - acctBalance;
918
- const eoaBalance = await loanContract.balanceOf(await this.getSignerAddress());
919
- if (eoaBalance < shortfall) {
920
- const loanInfo = this._tokenCache.get(loanTokenAddr.toLowerCase());
921
- const loanSymbol = loanInfo?.symbol ?? 'loan token';
922
- throw new AgetherError(`Insufficient ${loanSymbol}. Need ${amount}, AgentAccount has ${ethers.formatUnits(acctBalance, loanDecimals)}, EOA has ${ethers.formatUnits(eoaBalance, loanDecimals)}.`, 'INSUFFICIENT_BALANCE');
923
- }
924
- const transferTx = await loanContract.transfer(acctAddr, shortfall);
925
- await transferTx.wait();
926
- this._refreshSigner();
927
- }
928
- // Batch: approve loan token → supply(marketParams, assets, 0, onBehalf, "0x")
929
- const targets = [loanTokenAddr, morphoAddr];
930
- const values = [0n, 0n];
931
- const datas = [
932
- erc20Iface.encodeFunctionData('approve', [morphoAddr, parsedAmount]),
933
- morphoIface.encodeFunctionData('supply', [
934
- this._toTuple(params), parsedAmount, 0n, acctAddr, '0x',
935
- ]),
936
- ];
937
- const receipt = await this.batch(targets, values, datas);
938
- return {
939
- tx: receipt.hash,
940
- amount,
941
- marketId,
942
- collateralToken: usedCollateral,
943
- agentAccount: acctAddr,
944
- };
945
- }
946
- /**
947
- * Withdraw supplied USDC (+ earned interest) from a Morpho Blue market.
948
- *
949
- * @param usdcAmount - Amount to withdraw (e.g. '100' or 'all' for full position)
950
- * @param collateralSymbol - Market collateral to identify which market
951
- * @param receiver - Destination address (defaults to EOA)
952
- */
953
- /**
954
- * Withdraw supplied loan token (+ earned interest) from a Morpho Blue market.
955
- *
956
- * @param amount - Amount to withdraw (e.g. '100' or 'all' for full position)
957
- * @param collateralSymbol - Market collateral to identify which market
958
- * @param receiver - Destination address (defaults to EOA)
959
- * @param loanTokenSymbol - Loan token to filter market (optional)
960
- */
961
- async withdrawSupply(amount, collateralSymbol, receiver, loanTokenSymbol) {
962
- const acctAddr = await this.getAccountAddress();
963
- const morphoAddr = this.config.contracts.morphoBlue;
964
- const dest = receiver || await this.getSignerAddress();
965
- // Find market
966
- let params;
967
- if (collateralSymbol) {
968
- params = await this.findMarketForCollateral(collateralSymbol, loanTokenSymbol);
969
- }
970
- else {
971
- // Find first market with supply position
972
- const { params: p } = await this._findActiveSupplyMarket();
973
- params = p;
974
- }
975
- // Resolve loan token decimals
976
- const loanDecimals = await this._getLoanTokenDecimals(params);
977
- const marketId = ethers.keccak256(ethers.AbiCoder.defaultAbiCoder().encode(['address', 'address', 'address', 'address', 'uint256'], [params.loanToken, params.collateralToken, params.oracle, params.irm, params.lltv]));
978
- let withdrawAssets;
979
- let withdrawShares;
980
- if (amount === 'all') {
981
- // Shares-based withdraw to avoid dust
982
- const pos = await this.morphoBlue.position(marketId, acctAddr);
983
- withdrawShares = BigInt(pos.supplyShares);
984
- withdrawAssets = 0n;
985
- if (withdrawShares === 0n)
986
- throw new AgetherError('No supply position to withdraw', 'NO_SUPPLY');
987
- }
988
- else {
989
- withdrawAssets = ethers.parseUnits(amount, loanDecimals);
990
- withdrawShares = 0n;
991
- }
992
- // withdraw(marketParams, assets, shares, onBehalf, receiver)
993
- const data = morphoIface.encodeFunctionData('withdraw', [
994
- this._toTuple(params), withdrawAssets, withdrawShares, acctAddr, dest,
995
- ]);
996
- const receipt = await this.exec(morphoAddr, data);
997
- // Read remaining supply
998
- let remainingSupply = '0';
999
- try {
1000
- const pos = await this.morphoBlue.position(marketId, acctAddr);
1001
- const mkt = await this.morphoBlue.market(marketId);
1002
- const totalSupplyAssets = BigInt(mkt.totalSupplyAssets);
1003
- const totalSupplyShares = BigInt(mkt.totalSupplyShares);
1004
- const currentAssets = totalSupplyShares > 0n
1005
- ? (BigInt(pos.supplyShares) * totalSupplyAssets) / totalSupplyShares
1006
- : 0n;
1007
- remainingSupply = ethers.formatUnits(currentAssets, loanDecimals);
1008
- }
1009
- catch (e) {
1010
- console.warn('[agether] failed to read remaining supply:', e instanceof Error ? e.message : e);
1011
- }
1012
- return {
1013
- tx: receipt.hash,
1014
- amount,
1015
- remainingSupply,
1016
- destination: dest,
1017
- };
1018
- }
1019
- /**
1020
- * Get supply (lending) positions with yield tracking.
1021
- *
1022
- * Uses Morpho GraphQL API (no eth_getLogs / no DB / no indexer):
1023
- * 1. `userByAddress` → all market positions with current supplyAssets, supplyApy
1024
- * 2. `transactions` → all MarketSupply/MarketWithdraw history for net deposited
1025
- * 3. earnedYield = currentSupplyAssets − netDeposited
1026
- *
1027
- * @param collateralSymbol - Market collateral token (optional, returns all if omitted)
1028
- */
1029
- async getSupplyPositions(collateralSymbol) {
1030
- const acctAddr = (await this.getAccountAddress()).toLowerCase();
1031
- const chainId = this.config.chainId;
1032
- // ── 1. Fetch all positions from Morpho API ──
1033
- const positionsQuery = `{
1034
- userByAddress(address: "${acctAddr}", chainId: ${chainId}) {
1035
- marketPositions {
1036
- market {
1037
- uniqueKey
1038
- loanAsset { symbol address decimals }
1039
- collateralAsset { symbol address }
1040
- state { supplyApy }
1041
- }
1042
- state {
1043
- supplyShares
1044
- supplyAssets
1045
- supplyAssetsUsd
1046
- }
1047
- }
1048
- }
1049
- }`;
1050
- const posResp = await axios.post(MORPHO_API_URL, { query: positionsQuery }, { timeout: 15000 });
1051
- const user = posResp.data?.data?.userByAddress;
1052
- if (!user?.marketPositions)
1053
- return [];
1054
- const activePositions = user.marketPositions.filter((p) => p.state && BigInt(p.state.supplyShares ?? '0') > 0n);
1055
- if (activePositions.length === 0)
1056
- return [];
1057
- // Apply collateral filter if specified
1058
- const filtered = collateralSymbol
1059
- ? activePositions.filter((p) => {
1060
- const sym = p.market.collateralAsset?.symbol;
1061
- return sym && sym.toUpperCase() === collateralSymbol.toUpperCase();
1062
- })
1063
- : activePositions;
1064
- if (filtered.length === 0)
1065
- return [];
1066
- // ── 2. Fetch supply/withdraw transaction history from Morpho API ──
1067
- const netDepositedMap = await this._computeNetDepositedAll(acctAddr, chainId);
1068
- // ── 3. Build results ──
1069
- const results = [];
1070
- for (const p of filtered) {
1071
- const currentAssets = BigInt(p.state.supplyAssets ?? '0');
1072
- const marketKey = p.market.uniqueKey.toLowerCase();
1073
- const netDeposited = netDepositedMap.get(marketKey) ?? 0n;
1074
- const earnedYield = currentAssets > netDeposited ? currentAssets - netDeposited : 0n;
1075
- results.push({
1076
- marketId: p.market.uniqueKey,
1077
- loanToken: p.market.loanAsset.symbol,
1078
- collateralToken: p.market.collateralAsset?.symbol ?? 'none',
1079
- supplyShares: p.state.supplyShares,
1080
- suppliedAssets: ethers.formatUnits(currentAssets, p.market.loanAsset.decimals),
1081
- netDeposited: ethers.formatUnits(netDeposited, p.market.loanAsset.decimals),
1082
- earnedYield: ethers.formatUnits(earnedYield, p.market.loanAsset.decimals),
1083
- supplyApy: p.market.state?.supplyApy ?? 0,
1084
- });
1085
- }
1086
- return results;
1087
- }
1088
- /**
1089
- * Pay a recipient using ONLY earned yield from a supply position.
1090
- *
1091
- * Computes available yield, verifies the requested amount doesn't exceed it,
1092
- * then withdraws from the supply position and sends directly to the recipient.
1093
- *
1094
- * @param recipient - Address to receive the loan token
1095
- * @param amount - Amount to pay from yield (e.g. '5.50')
1096
- * @param collateralSymbol - Market collateral to identify which supply position
1097
- */
1098
- async payFromYield(recipient, amount, collateralSymbol) {
1099
- const acctAddr = await this.getAccountAddress();
1100
- const morphoAddr = this.config.contracts.morphoBlue;
1101
- // Get supply position and compute yield
1102
- const positions = await this.getSupplyPositions(collateralSymbol);
1103
- if (positions.length === 0) {
1104
- throw new AgetherError('No supply position found', 'NO_SUPPLY');
1105
- }
1106
- // Use the position with the most yield
1107
- const pos = positions.reduce((a, b) => parseFloat(a.earnedYield) > parseFloat(b.earnedYield) ? a : b);
1108
- // Find market params to get loan token decimals
1109
- const params = await this.findMarketForCollateral(pos.collateralToken, pos.loanToken);
1110
- const loanDecimals = await this._getLoanTokenDecimals(params);
1111
- const parsedAmount = ethers.parseUnits(amount, loanDecimals);
1112
- const availableYield = ethers.parseUnits(pos.earnedYield, loanDecimals);
1113
- if (parsedAmount > availableYield) {
1114
- const loanSymbol = pos.loanToken;
1115
- throw new AgetherError(`Requested ${amount} ${loanSymbol} exceeds available yield of ${pos.earnedYield} ${loanSymbol}. ` +
1116
- `Use withdrawSupply to withdraw principal.`, 'EXCEEDS_YIELD');
1117
- }
1118
- // Withdraw directly to recipient: withdraw(params, amount, 0, onBehalf, receiver)
1119
- const data = morphoIface.encodeFunctionData('withdraw', [
1120
- this._toTuple(params), parsedAmount, 0n, acctAddr, recipient,
1121
- ]);
1122
- const receipt = await this.exec(morphoAddr, data);
1123
- // Read remaining position
1124
- let remainingYield = '0';
1125
- let remainingSupply = '0';
1126
- try {
1127
- const updatedPositions = await this.getSupplyPositions(pos.collateralToken);
1128
- if (updatedPositions.length > 0) {
1129
- remainingYield = updatedPositions[0].earnedYield;
1130
- remainingSupply = updatedPositions[0].suppliedAssets;
1131
- }
1132
- }
1133
- catch (e) {
1134
- console.warn('[agether] failed to read remaining yield:', e instanceof Error ? e.message : e);
1135
- }
1136
- return {
1137
- tx: receipt.hash,
1138
- yieldWithdrawn: amount,
1139
- recipient,
1140
- remainingYield,
1141
- remainingSupply,
1142
- };
1143
- }
1144
- // ════════════════════════════════════════════════════════
1145
- // Collateral & Borrowing Operations (all via AgentAccount)
1146
- // ════════════════════════════════════════════════════════
1147
- /**
1148
- * Deposit collateral into Morpho Blue.
1149
- *
1150
- * Flow:
1151
- * 1. EOA transfers collateral to AgentAccount
1152
- * 2. AgentAccount.executeBatch:
1153
- * [collateral.approve(MorphoBlue), Morpho.supplyCollateral(params)]
1154
- */
1155
- async supplyCollateral(tokenSymbol, amount, marketParams) {
1156
- const acctAddr = await this.getAccountAddress();
1157
- const colInfo = await this._resolveToken(tokenSymbol);
1158
- const params = marketParams ?? await this.findMarketForCollateral(tokenSymbol);
1159
- const weiAmount = ethers.parseUnits(amount, colInfo.decimals);
1160
- const morphoAddr = this.config.contracts.morphoBlue;
1161
- // Step 1: Transfer collateral from EOA → AgentAccount (only the shortfall)
1162
- const colToken = new Contract(colInfo.address, ERC20_ABI, this._signer);
1163
- const acctBalance = await colToken.balanceOf(acctAddr);
1164
- if (acctBalance < weiAmount) {
1165
- const shortfall = weiAmount - acctBalance;
1166
- const eoaBalance = await colToken.balanceOf(await this.getSignerAddress());
1167
- if (eoaBalance < shortfall) {
1168
- throw new AgetherError(`Insufficient ${tokenSymbol}. Need ${amount}, AgentAccount has ${ethers.formatUnits(acctBalance, colInfo.decimals)}, EOA has ${ethers.formatUnits(eoaBalance, colInfo.decimals)}.`, 'INSUFFICIENT_BALANCE');
1169
- }
1170
- const transferTx = await colToken.transfer(acctAddr, shortfall);
1171
- await transferTx.wait();
1172
- this._refreshSigner();
1173
- }
1174
- // Step 2: AgentAccount batch: approve + supplyCollateral
1175
- const targets = [colInfo.address, morphoAddr];
1176
- const values = [0n, 0n];
1177
- const datas = [
1178
- erc20Iface.encodeFunctionData('approve', [morphoAddr, weiAmount]),
1179
- morphoIface.encodeFunctionData('supplyCollateral', [
1180
- this._toTuple(params), weiAmount, acctAddr, '0x',
1181
- ]),
1182
- ];
1183
- const receipt = await this.batch(targets, values, datas);
1184
- return {
1185
- tx: receipt.hash,
1186
- collateralToken: tokenSymbol,
1187
- amount,
1188
- agentAccount: acctAddr,
1189
- };
1190
- }
1191
- /**
1192
- * Borrow loan token against existing collateral.
1193
- *
1194
- * AgentAccount.execute: Morpho.borrow(params, amount, 0, account, account)
1195
- *
1196
- * @param amount - Loan token amount (e.g. '100' for 100 USDC, '0.5' for 0.5 WETH)
1197
- * @param tokenSymbol - collateral symbol to identify which market (default: first with collateral)
1198
- * @param marketParams - explicit market params (optional)
1199
- * @param loanTokenSymbol - loan token to filter market (optional, e.g. 'USDC', 'WETH')
1200
- */
1201
- async borrow(amount, tokenSymbol, marketParams, loanTokenSymbol) {
1202
- const acctAddr = await this.getAccountAddress();
1203
- const morphoAddr = this.config.contracts.morphoBlue;
1204
- // Find market
1205
- let params;
1206
- let usedToken = tokenSymbol || 'WETH';
1207
- if (marketParams) {
1208
- params = marketParams;
1209
- }
1210
- else if (tokenSymbol) {
1211
- params = await this.findMarketForCollateral(tokenSymbol, loanTokenSymbol);
1212
- }
1213
- else {
1214
- // Auto-detect: find first market with collateral
1215
- const { params: p, symbol } = await this._findActiveMarket();
1216
- params = p;
1217
- usedToken = symbol;
1218
- }
1219
- // Resolve loan token decimals
1220
- const loanDecimals = await this._getLoanTokenDecimals(params);
1221
- const parsedAmount = ethers.parseUnits(amount, loanDecimals);
1222
- // ── Pre-check: verify collateral is sufficient for the borrow amount ──
1223
- // Avoids wasting gas on a UserOp that will revert with "insufficient collateral".
1224
- try {
1225
- const marketId = ethers.keccak256(ethers.AbiCoder.defaultAbiCoder().encode(['address', 'address', 'address', 'address', 'uint256'], [params.loanToken, params.collateralToken, params.oracle, params.irm, params.lltv]));
1226
- const pos = await this.morphoBlue.position(marketId, acctAddr);
1227
- if (pos.collateral === 0n) {
1228
- throw new AgetherError(`No collateral deposited for ${usedToken}. Deposit collateral first.`, 'NO_COLLATERAL');
1229
- }
1230
- // Compute max borrowable: collateral × oraclePrice / 1e36 × LLTV / 1e18 − debt
1231
- const oracleContract = new Contract(params.oracle, ['function price() view returns (uint256)'], this.provider);
1232
- const oraclePrice = await oracleContract.price();
1233
- const collateralValueInLoan = (BigInt(pos.collateral) * oraclePrice) / (10n ** 36n);
1234
- const maxBorrowTotal = (collateralValueInLoan * params.lltv) / (10n ** 18n);
1235
- // Compute current debt from shares
1236
- const mktState = await this.morphoBlue.market(marketId);
1237
- const totalBorrowShares = BigInt(mktState.totalBorrowShares);
1238
- const totalBorrowAssets = BigInt(mktState.totalBorrowAssets);
1239
- const currentDebt = totalBorrowShares > 0n
1240
- ? (BigInt(pos.borrowShares) * totalBorrowAssets + totalBorrowShares - 1n) / totalBorrowShares
1241
- : 0n;
1242
- const maxAdditional = maxBorrowTotal > currentDebt ? maxBorrowTotal - currentDebt : 0n;
1243
- if (parsedAmount > maxAdditional) {
1244
- const loanInfo = this._tokenCache.get(params.loanToken.toLowerCase());
1245
- const loanSymbol = loanInfo?.symbol ?? 'loan token';
1246
- const colInfo = await this._resolveToken(usedToken);
1247
- const maxFormatted = ethers.formatUnits(maxAdditional, loanDecimals);
1248
- const colFormatted = ethers.formatUnits(pos.collateral, colInfo.decimals);
1249
- throw new AgetherError(`Borrow of ${amount} ${loanSymbol} exceeds max borrowable ${maxFormatted} ${loanSymbol} ` +
1250
- `(collateral: ${colFormatted} ${usedToken}, LLTV: ${Number(params.lltv) / 1e18 * 100}%). ` +
1251
- `Deposit more collateral or reduce borrow amount.`, 'EXCEEDS_MAX_LTV');
1252
- }
1253
- }
1254
- catch (e) {
1255
- if (e instanceof AgetherError)
1256
- throw e;
1257
- console.warn('[agether] borrow pre-check failed (proceeding anyway):', e instanceof Error ? e.message : e);
1258
- }
1259
- const data = morphoIface.encodeFunctionData('borrow', [
1260
- this._toTuple(params), parsedAmount, 0n, acctAddr, acctAddr,
1261
- ]);
1262
- const receipt = await this.exec(morphoAddr, data);
1263
- return {
1264
- tx: receipt.hash,
1265
- amount,
1266
- collateralToken: usedToken,
1267
- agentAccount: acctAddr,
1268
- };
1269
- }
1270
- /**
1271
- * Deposit collateral AND borrow USDC in one batched transaction.
1272
- *
1273
- /**
1274
- * Deposit collateral AND borrow loan token in one batched transaction.
1275
- *
1276
- * AgentAccount.executeBatch:
1277
- * [collateral.approve, Morpho.supplyCollateral, Morpho.borrow]
1278
- *
1279
- * The collateral must be transferred to AgentAccount first.
1280
- *
1281
- * @param tokenSymbol - collateral token symbol (e.g. 'WETH')
1282
- * @param collateralAmount - amount of collateral (e.g. '0.05')
1283
- * @param borrowAmount - amount of loan token to borrow (e.g. '100')
1284
- * @param marketParams - explicit market params (optional)
1285
- * @param loanTokenSymbol - loan token to filter market (optional)
1286
- */
1287
- async depositAndBorrow(tokenSymbol, collateralAmount, borrowAmount, marketParams, loanTokenSymbol) {
1288
- const acctAddr = await this.getAccountAddress();
1289
- const colInfo = await this._resolveToken(tokenSymbol);
1290
- const params = marketParams ?? await this.findMarketForCollateral(tokenSymbol, loanTokenSymbol);
1291
- const loanDecimals = await this._getLoanTokenDecimals(params);
1292
- const colWei = ethers.parseUnits(collateralAmount, colInfo.decimals);
1293
- const borrowWei = ethers.parseUnits(borrowAmount, loanDecimals);
1294
- const morphoAddr = this.config.contracts.morphoBlue;
1295
- // ── Pre-check: will the new total collateral support the borrow? ──
1296
- try {
1297
- const marketId = ethers.keccak256(ethers.AbiCoder.defaultAbiCoder().encode(['address', 'address', 'address', 'address', 'uint256'], [params.loanToken, params.collateralToken, params.oracle, params.irm, params.lltv]));
1298
- const pos = await this.morphoBlue.position(marketId, acctAddr);
1299
- const totalCollateral = BigInt(pos.collateral) + colWei; // existing + new deposit
1300
- const oracleContract = new Contract(params.oracle, ['function price() view returns (uint256)'], this.provider);
1301
- const oraclePrice = await oracleContract.price();
1302
- const collateralValueInLoan = (totalCollateral * oraclePrice) / (10n ** 36n);
1303
- const maxBorrowTotal = (collateralValueInLoan * params.lltv) / (10n ** 18n);
1304
- const mktState = await this.morphoBlue.market(marketId);
1305
- const totalBorrowShares = BigInt(mktState.totalBorrowShares);
1306
- const totalBorrowAssets = BigInt(mktState.totalBorrowAssets);
1307
- const currentDebt = totalBorrowShares > 0n
1308
- ? (BigInt(pos.borrowShares) * totalBorrowAssets + totalBorrowShares - 1n) / totalBorrowShares
1309
- : 0n;
1310
- const maxAdditional = maxBorrowTotal > currentDebt ? maxBorrowTotal - currentDebt : 0n;
1311
- if (borrowWei > maxAdditional) {
1312
- const loanInfo = this._tokenCache.get(params.loanToken.toLowerCase());
1313
- const loanSymbol = loanInfo?.symbol ?? 'loan token';
1314
- const maxFormatted = ethers.formatUnits(maxAdditional, loanDecimals);
1315
- throw new AgetherError(`Borrow of ${borrowAmount} ${loanSymbol} exceeds max borrowable ${maxFormatted} ${loanSymbol} ` +
1316
- `(total collateral: ${ethers.formatUnits(totalCollateral, colInfo.decimals)} ${tokenSymbol}, ` +
1317
- `LLTV: ${Number(params.lltv) / 1e18 * 100}%). Reduce borrow or increase collateral.`, 'EXCEEDS_MAX_LTV');
1318
- }
1319
- }
1320
- catch (e) {
1321
- if (e instanceof AgetherError)
1322
- throw e;
1323
- console.warn('[agether] depositAndBorrow pre-check failed (proceeding anyway):', e instanceof Error ? e.message : e);
1324
- }
1325
- // Step 1: Transfer collateral from EOA → AgentAccount (only the shortfall)
1326
- const colToken = new Contract(colInfo.address, ERC20_ABI, this._signer);
1327
- const acctBalance = await colToken.balanceOf(acctAddr);
1328
- if (acctBalance < colWei) {
1329
- const shortfall = colWei - acctBalance;
1330
- const eoaBalance = await colToken.balanceOf(await this.getSignerAddress());
1331
- if (eoaBalance < shortfall) {
1332
- throw new AgetherError(`Insufficient ${tokenSymbol}. Need ${collateralAmount}, AgentAccount has ${ethers.formatUnits(acctBalance, colInfo.decimals)}, EOA has ${ethers.formatUnits(eoaBalance, colInfo.decimals)}.`, 'INSUFFICIENT_BALANCE');
1333
- }
1334
- const transferTx = await colToken.transfer(acctAddr, shortfall);
1335
- await transferTx.wait();
1336
- this._refreshSigner();
1337
- }
1338
- // Step 2: Batched — approve + supplyCollateral + borrow
1339
- const targets = [colInfo.address, morphoAddr, morphoAddr];
1340
- const values = [0n, 0n, 0n];
1341
- const datas = [
1342
- erc20Iface.encodeFunctionData('approve', [morphoAddr, colWei]),
1343
- morphoIface.encodeFunctionData('supplyCollateral', [
1344
- this._toTuple(params), colWei, acctAddr, '0x',
1345
- ]),
1346
- morphoIface.encodeFunctionData('borrow', [
1347
- this._toTuple(params), borrowWei, 0n, acctAddr, acctAddr,
1348
- ]),
1349
- ];
1350
- const receipt = await this.batch(targets, values, datas);
1351
- return {
1352
- tx: receipt.hash,
1353
- collateralToken: tokenSymbol,
1354
- collateralAmount,
1355
- borrowAmount,
1356
- agentAccount: acctAddr,
1357
- };
1358
- }
1359
- /**
1360
- * Repay borrowed loan token from AgentAccount.
1361
- *
1362
- * AgentAccount.executeBatch:
1363
- * [loanToken.approve(MorphoBlue), Morpho.repay(params)]
1364
- *
1365
- * @param amount - loan token amount to repay (e.g. '50' or 'all' for full repayment)
1366
- * @param tokenSymbol - collateral symbol to identify which market (optional)
1367
- * @param marketParams - explicit market params (optional)
1368
- * @param loanTokenSymbol - loan token to filter market (optional)
1369
- */
1370
- async repay(amount, tokenSymbol, marketParams, loanTokenSymbol) {
1371
- const acctAddr = await this.getAccountAddress();
1372
- const morphoAddr = this.config.contracts.morphoBlue;
1373
- let params;
1374
- if (marketParams) {
1375
- params = marketParams;
1376
- }
1377
- else if (tokenSymbol) {
1378
- params = await this.findMarketForCollateral(tokenSymbol, loanTokenSymbol);
1379
- }
1380
- else {
1381
- const { params: p } = await this._findActiveMarket();
1382
- params = p;
1383
- }
1384
- // Resolve loan token address and decimals
1385
- const loanTokenAddr = params.loanToken;
1386
- const loanDecimals = await this._getLoanTokenDecimals(params);
1387
- // Determine whether to repay by shares (full repay) or by assets (partial).
1388
- // Using shares-based repay prevents dust borrow shares from remaining.
1389
- let repayAssets;
1390
- let repayShares;
1391
- let approveAmount;
1392
- if (amount === 'all') {
1393
- // Full repay: use shares to ensure no dust remains
1394
- const markets = await this.getMarkets();
1395
- const mkt = markets.find((m) => m.collateralAsset?.address.toLowerCase() === params.collateralToken.toLowerCase()
1396
- && m.loanAsset?.address.toLowerCase() === params.loanToken.toLowerCase());
1397
- if (mkt) {
1398
- const pos = await this.morphoBlue.position(mkt.uniqueKey, acctAddr);
1399
- repayShares = BigInt(pos.borrowShares);
1400
- repayAssets = 0n;
1401
- // Read onchain market state for accurate share→asset conversion
1402
- const onChainMkt = await this.morphoBlue.market(mkt.uniqueKey);
1403
- const totalBorrowAssets = BigInt(onChainMkt.totalBorrowAssets);
1404
- const totalBorrowShares = BigInt(onChainMkt.totalBorrowShares);
1405
- const estimated = totalBorrowShares > 0n
1406
- ? (repayShares * totalBorrowAssets) / totalBorrowShares + 10n
1407
- : 0n;
1408
- approveAmount = estimated > 0n ? estimated : ethers.parseUnits('1', loanDecimals);
1409
- }
1410
- else {
1411
- // Fallback: large asset repay
1412
- repayAssets = ethers.parseUnits('999999', loanDecimals);
1413
- repayShares = 0n;
1414
- approveAmount = repayAssets;
1415
- }
1416
- }
1417
- else {
1418
- repayAssets = ethers.parseUnits(amount, loanDecimals);
1419
- repayShares = 0n;
1420
- approveAmount = repayAssets;
1421
- // Specific amount: always use asset-based repay (no shares conversion).
1422
- // This avoids rounding issues where shares→assets needs slightly more
1423
- // than the AgentAccount holds. Use 'all' for full debt clearance.
1424
- }
1425
- // Ensure AgentAccount has enough loan token to cover the repay.
1426
- // If not, transfer the shortfall from EOA → AgentAccount first.
1427
- const loanContract = new Contract(loanTokenAddr, ERC20_ABI, this._signer);
1428
- const acctBalance = await loanContract.balanceOf(acctAddr);
1429
- if (acctBalance < approveAmount) {
1430
- const shortfall = approveAmount - acctBalance;
1431
- const eoaBalance = await loanContract.balanceOf(await this.getSignerAddress());
1432
- if (eoaBalance < shortfall) {
1433
- const loanInfo = this._tokenCache.get(loanTokenAddr.toLowerCase());
1434
- const loanSymbol = loanInfo?.symbol ?? 'loan token';
1435
- throw new AgetherError(`Insufficient ${loanSymbol} for repay. Need ${ethers.formatUnits(approveAmount, loanDecimals)}, ` +
1436
- `AgentAccount has ${ethers.formatUnits(acctBalance, loanDecimals)}, EOA has ${ethers.formatUnits(eoaBalance, loanDecimals)}.`, 'INSUFFICIENT_BALANCE');
1437
- }
1438
- const transferTx = await loanContract.transfer(acctAddr, shortfall);
1439
- await transferTx.wait();
1440
- this._refreshSigner();
1441
- }
1442
- // Batch: approve + repay
1443
- const targets = [loanTokenAddr, morphoAddr];
1444
- const values = [0n, 0n];
1445
- const datas = [
1446
- erc20Iface.encodeFunctionData('approve', [morphoAddr, approveAmount]),
1447
- morphoIface.encodeFunctionData('repay', [
1448
- this._toTuple(params), repayAssets, repayShares, acctAddr, '0x',
1449
- ]),
1450
- ];
1451
- const receipt = await this.batch(targets, values, datas);
1452
- // Read remaining debt
1453
- let remainingDebt = '0';
1454
- try {
1455
- const status = await this.getStatus();
1456
- remainingDebt = status.totalDebt;
1457
- }
1458
- catch (e) {
1459
- console.warn('[agether] failed to read remaining debt after repay:', e instanceof Error ? e.message : e);
1460
- }
1461
- return { tx: receipt.hash, amount, remainingDebt };
1462
- }
1463
- /**
1464
- * Withdraw collateral from Morpho Blue.
1465
- *
1466
- * AgentAccount.execute: Morpho.withdrawCollateral(params, amount, account, receiver)
1467
- *
1468
- * @param receiver - defaults to EOA wallet
1469
- */
1470
- async withdrawCollateral(tokenSymbol, amount, marketParams, receiver) {
1471
- const acctAddr = await this.getAccountAddress();
1472
- const colInfo = await this._resolveToken(tokenSymbol);
1473
- const params = marketParams ?? await this.findMarketForCollateral(tokenSymbol);
1474
- const morphoAddr = this.config.contracts.morphoBlue;
1475
- const loanTokenAddr = params.loanToken;
1476
- const loanDecimals = await this._getLoanTokenDecimals(params);
1477
- const dest = receiver || await this.getSignerAddress();
1478
- // Handle 'all' — withdraw full collateral
1479
- let weiAmount;
1480
- const markets = await this.getMarkets();
1481
- const market = markets.find((m) => m.collateralAsset?.address.toLowerCase() === colInfo.address.toLowerCase()
1482
- && m.loanAsset?.address.toLowerCase() === loanTokenAddr.toLowerCase());
1483
- if (amount === 'all') {
1484
- if (!market)
1485
- throw new AgetherError('Market not found', 'MARKET_NOT_FOUND');
1486
- const pos = await this.morphoBlue.position(market.uniqueKey, acctAddr);
1487
- weiAmount = pos.collateral;
1488
- if (weiAmount === 0n)
1489
- throw new AgetherError('No collateral to withdraw', 'NO_COLLATERAL');
1490
- }
1491
- else {
1492
- weiAmount = ethers.parseUnits(amount, colInfo.decimals);
1493
- }
1494
- // ── Check for dust borrow shares ──────────────────────
1495
- // If any borrow shares remain (even dust), Morpho will reject a full
1496
- // collateral withdrawal because LTV would become infinite.
1497
- // Auto-repay dust debt before withdrawing.
1498
- let hasDustDebt = false;
1499
- let dustBorrowShares = 0n;
1500
- let dustApproveAmount = 0n;
1501
- if (market) {
1502
- try {
1503
- const pos = await this.morphoBlue.position(market.uniqueKey, acctAddr);
1504
- dustBorrowShares = BigInt(pos.borrowShares);
1505
- if (dustBorrowShares > 0n) {
1506
- hasDustDebt = true;
1507
- // Convert shares → assets to know how much loan token to approve
1508
- const onChainMkt = await this.morphoBlue.market(market.uniqueKey);
1509
- const totalBorrowAssets = BigInt(onChainMkt.totalBorrowAssets);
1510
- const totalBorrowShares = BigInt(onChainMkt.totalBorrowShares);
1511
- const estimated = totalBorrowShares > 0n
1512
- ? (dustBorrowShares * totalBorrowAssets) / totalBorrowShares + 10n
1513
- : 0n;
1514
- dustApproveAmount = estimated > 0n ? estimated : ethers.parseUnits('1', loanDecimals);
1515
- const loanInfo = this._tokenCache.get(loanTokenAddr.toLowerCase());
1516
- const loanSymbol = loanInfo?.symbol ?? 'loan token';
1517
- console.log(`[agether] dust borrow shares detected: ${dustBorrowShares} shares ≈ ${ethers.formatUnits(dustApproveAmount, loanDecimals)} ${loanSymbol} — auto-repaying before withdraw`);
1518
- }
1519
- }
1520
- catch (e) {
1521
- console.warn('[agether] failed to check borrow shares before withdraw:', e instanceof Error ? e.message : e);
1522
- }
1523
- }
1524
- const withdrawData = morphoIface.encodeFunctionData('withdrawCollateral', [
1525
- this._toTuple(params), weiAmount, acctAddr, dest,
1526
- ]);
1527
- let receipt;
1528
- if (hasDustDebt) {
1529
- // Ensure AgentAccount has enough loan token to cover the dust repay
1530
- const loanContract = new Contract(loanTokenAddr, ERC20_ABI, this._signer);
1531
- const acctBalance = await loanContract.balanceOf(acctAddr);
1532
- if (acctBalance < dustApproveAmount) {
1533
- const shortfall = dustApproveAmount - acctBalance;
1534
- const eoaBalance = await loanContract.balanceOf(await this.getSignerAddress());
1535
- if (eoaBalance >= shortfall) {
1536
- const loanInfo = this._tokenCache.get(loanTokenAddr.toLowerCase());
1537
- const loanSymbol = loanInfo?.symbol ?? 'loan token';
1538
- console.log(`[agether] transferring ${ethers.formatUnits(shortfall, loanDecimals)} ${loanSymbol} from EOA → AgentAccount for dust repay`);
1539
- const transferTx = await loanContract.transfer(acctAddr, shortfall);
1540
- await transferTx.wait();
1541
- this._refreshSigner();
1542
- }
1543
- // If EOA doesn't have enough either, still try — the tx will revert with a clear error
1544
- }
1545
- // Batch: approve loan token → repay dust shares → withdraw collateral
1546
- const targets = [loanTokenAddr, morphoAddr, morphoAddr];
1547
- const values = [0n, 0n, 0n];
1548
- const datas = [
1549
- erc20Iface.encodeFunctionData('approve', [morphoAddr, dustApproveAmount]),
1550
- morphoIface.encodeFunctionData('repay', [
1551
- this._toTuple(params), 0n, dustBorrowShares, acctAddr, '0x',
1552
- ]),
1553
- withdrawData,
1554
- ];
1555
- receipt = await this.batch(targets, values, datas);
1556
- }
1557
- else {
1558
- receipt = await this.exec(morphoAddr, withdrawData);
1559
- }
1560
- // Read remaining collateral
1561
- let remainingCollateral = '0';
1562
- try {
1563
- if (market) {
1564
- const pos = await this.morphoBlue.position(market.uniqueKey, acctAddr);
1565
- remainingCollateral = ethers.formatUnits(pos.collateral, colInfo.decimals);
1566
- }
1567
- }
1568
- catch (e) {
1569
- console.warn('[agether] failed to read remaining collateral after withdraw:', e instanceof Error ? e.message : e);
1570
- }
1571
- return {
1572
- tx: receipt.hash,
1573
- token: tokenSymbol,
1574
- amount: amount === 'all' ? ethers.formatUnits(weiAmount, colInfo.decimals) : amount,
1575
- remainingCollateral,
1576
- destination: dest,
1577
- };
1578
- }
1579
- // ════════════════════════════════════════════════════════
1580
- // Internal Helpers
1581
- // ════════════════════════════════════════════════════════
1582
- /**
1583
- * Refresh the signer and re-bind contract instances.
1584
- *
1585
- * For the **privateKey** path: recreates provider + wallet so the next tx
1586
- * fetches a fresh nonce from chain. Anvil (and some RPC providers) return a
1587
- * stale `eth_getTransactionCount` right after a block is mined, causing
1588
- * "nonce too low" on the follow-up tx.
1589
- *
1590
- * For the **external signer** path: the signer is immutable and owned by the
1591
- * caller (e.g. custody provider). We only re-bind contract instances to
1592
- * ensure they reference the current signer. Nonce management is the caller's
1593
- * responsibility.
1594
- */
1595
- _refreshSigner() {
1596
- if (this._useExternalSigner) {
1597
- // External signer: re-bind contracts only (signer is immutable)
1598
- const addrs = this.config.contracts;
1599
- this.agether4337Factory = new Contract(addrs.agether4337Factory, ACCOUNT_FACTORY_ABI, this._signer);
1600
- this.entryPoint = new Contract(addrs.entryPoint, ENTRYPOINT_V07_ABI, this._signer);
1601
- }
1602
- else {
1603
- // Private key path: recreate provider + wallet for fresh nonce
1604
- this.provider = new ethers.JsonRpcProvider(this._rpcUrl);
1605
- const wallet = new ethers.Wallet(this._privateKey, this.provider);
1606
- this._signer = wallet;
1607
- this._eoaAddress = wallet.address;
1608
- // Re-bind contract instances that use the wallet as signer
1609
- const addrs = this.config.contracts;
1610
- this.agether4337Factory = new Contract(addrs.agether4337Factory, ACCOUNT_FACTORY_ABI, this._signer);
1611
- this.entryPoint = new Contract(addrs.entryPoint, ENTRYPOINT_V07_ABI, this._signer);
1612
- // morphoBlue stays on provider (read-only) — no need to refresh
1613
- }
1614
- }
1615
- // ────────────────────────────────────────────────────────────
1616
- // ERC-4337 UserOp helpers (Safe + Safe7579 + EntryPoint v0.7)
1617
- // ────────────────────────────────────────────────────────────
1618
- /**
1619
- * Pack two uint128 values into a single bytes32:
1620
- * bytes32 = (hi << 128) | lo
1621
- */
1622
- _packUint128(hi, lo) {
1623
- return ethers.zeroPadValue(ethers.toBeHex((hi << 128n) | lo), 32);
1624
- }
1625
- /**
1626
- * Build, sign and submit a PackedUserOperation through EntryPoint.handleOps.
1627
- *
1628
- * @param callData – the ABI-encoded calldata for the Safe7579 account
1629
- * (e.g. `execute(mode, executionCalldata)`)
1630
- * @returns the transaction receipt of the handleOps call
1631
- */
1632
- async _submitUserOp(callData) {
1633
- const sender = await this.getAccountAddress();
1634
- // ── Nonce key = validator module address ──
1635
- // Safe7579 extracts the validator from the nonce: `validator := shr(96, nonce)`.
1636
- // The EntryPoint's getNonce(sender, key) returns `(key << 64) | seq`.
1637
- // Safe7579's own getNonce computes the key as:
1638
- // uint192(bytes24(bytes20(address(validator)))) = address << 32
1639
- // so that shr(96, (addr<<32)<<64 | seq) = shr(96, addr<<96 | seq) = addr.
1640
- const validatorAddr = this.config.contracts.erc8004ValidationModule;
1641
- const nonceKey = BigInt(validatorAddr) << 32n;
1642
- const nonce = await this.entryPoint.getNonce(sender, nonceKey);
1643
- // Gas prices from the network
1644
- const feeData = await this.provider.getFeeData();
1645
- const maxFeePerGas = feeData.maxFeePerGas ?? ethers.parseUnits('0.5', 'gwei');
1646
- const maxPriorityFeePerGas = feeData.maxPriorityFeePerGas ?? ethers.parseUnits('0.1', 'gwei');
1647
- // Default gas limits — generous for DeFi interactions
1648
- const verificationGasLimit = 500000n;
1649
- const callGasLimit = 800000n;
1650
- const preVerificationGas = 100000n;
1651
- const accountGasLimits = this._packUint128(verificationGasLimit, callGasLimit);
1652
- const gasFees = this._packUint128(maxPriorityFeePerGas, maxFeePerGas);
1653
- // ── Auto-fund the Safe account for gas ──
1654
- // The account must pay `missingAccountFunds` to EntryPoint during validateUserOp.
1655
- // Required prefund ≈ (verificationGasLimit + callGasLimit + preVerificationGas) × maxFeePerGas.
1656
- // If the account has insufficient ETH, top it up from the EOA.
1657
- const requiredPrefund = (verificationGasLimit + callGasLimit + preVerificationGas) * maxFeePerGas;
1658
- const accountBalance = await this.provider.getBalance(sender);
1659
- if (accountBalance < requiredPrefund) {
1660
- const topUp = requiredPrefund - accountBalance;
1661
- // Add 20 % buffer to avoid marginal shortfalls
1662
- const topUpWithBuffer = (topUp * 120n) / 100n;
1663
- const fundTx = await this._signer.sendTransaction({
1664
- to: sender,
1665
- value: topUpWithBuffer,
1666
- });
1667
- await fundTx.wait();
1668
- this._refreshSigner();
1669
- }
1670
- // Build the PackedUserOperation (no paymaster, no initCode for existing accounts)
1671
- const userOp = {
1672
- sender,
1673
- nonce,
1674
- initCode: '0x',
1675
- callData,
1676
- accountGasLimits,
1677
- preVerificationGas,
1678
- gasFees,
1679
- paymasterAndData: '0x',
1680
- signature: '0x', // placeholder — replaced after signing
1681
- };
1682
- // Get the hash and sign it
1683
- const userOpHash = await this.entryPoint.getUserOpHash(userOp);
1684
- const signature = await this._signer.signMessage(ethers.getBytes(userOpHash));
1685
- userOp.signature = signature;
1686
- // Submit via handleOps — the EOA pays for gas as the bundler
1687
- const tx = await this.entryPoint.handleOps([userOp], await this.getSignerAddress());
1688
- const receipt = await tx.wait();
1689
- this._refreshSigner();
1690
- // ── Verify inner UserOp execution succeeded ──
1691
- // EntryPoint's outer tx always succeeds (status=1) even if the inner UserOp
1692
- // execution reverts. We MUST check the UserOperationEvent.success field.
1693
- const epIface = new ethers.Interface(ENTRYPOINT_V07_ABI);
1694
- for (const log of receipt.logs) {
1695
- try {
1696
- const parsed = epIface.parseLog({ topics: log.topics, data: log.data });
1697
- if (parsed?.name === 'UserOperationEvent' && !parsed.args.success) {
1698
- // Inner execution failed — try to extract the revert reason
1699
- let revertMsg = 'UserOp inner execution reverted';
1700
- for (const rLog of receipt.logs) {
1701
- try {
1702
- const rParsed = epIface.parseLog({ topics: rLog.topics, data: rLog.data });
1703
- if (rParsed?.name === 'UserOperationRevertReason') {
1704
- const reason = rParsed.args.revertReason;
1705
- try {
1706
- // Try to decode as Error(string) — selector 0x08c379a0
1707
- if (reason.length >= 10 && reason.slice(0, 10) === '0x08c379a0') {
1708
- const decoded = ethers.AbiCoder.defaultAbiCoder().decode(['string'], '0x' + reason.slice(10));
1709
- revertMsg = `UserOp reverted: ${decoded[0]}`;
1710
- }
1711
- else {
1712
- revertMsg = `UserOp reverted with data: ${reason}`;
1713
- }
1714
- }
1715
- catch {
1716
- revertMsg = `UserOp reverted with data: ${reason}`;
1717
- }
1718
- break;
1719
- }
1720
- }
1721
- catch {
1722
- continue;
1723
- }
1724
- }
1725
- throw new AgetherError(revertMsg, 'USEROP_EXECUTION_FAILED');
1726
- }
1727
- }
1728
- catch (e) {
1729
- if (e instanceof AgetherError)
1730
- throw e;
1731
- continue; // skip logs that don't match EntryPoint events
1732
- }
1733
- }
1734
- return receipt;
1735
- }
1736
- /**
1737
- * Execute a single call via Safe7579 account (ERC-7579 single mode)
1738
- * through an ERC-4337 UserOperation.
1739
- */
1740
- async exec(target, data, value = 0n) {
1741
- // ERC-7579 single execution uses PACKED encoding (not abi.encode!):
1742
- // bytes[0:20] = target address (20 bytes, no left-padding)
1743
- // bytes[20:52] = value (uint256, 32 bytes)
1744
- // bytes[52:] = callData (raw, no length prefix)
1745
- // This matches Safe7579's ExecutionLib.decodeSingle().
1746
- const valueHex = ethers.zeroPadValue(ethers.toBeHex(value), 32);
1747
- const executionCalldata = ethers.concat([target, valueHex, data]);
1748
- // Build the Safe7579 account calldata: execute(bytes32 mode, bytes executionCalldata)
1749
- const safe7579Iface = new ethers.Interface(SAFE7579_ACCOUNT_ABI);
1750
- const callData = safe7579Iface.encodeFunctionData('execute', [MODE_SINGLE, executionCalldata]);
1751
- return this._submitUserOp(callData);
1752
- }
1753
- /**
1754
- * Execute multiple calls via Safe7579 account (ERC-7579 batch mode)
1755
- * through an ERC-4337 UserOperation.
1756
- */
1757
- async batch(targets, values, datas) {
1758
- // ERC-7579: encode batch execution = abi.encode(Execution[])
1759
- // where Execution = (address target, uint256 value, bytes callData)
1760
- const executions = targets.map((t, i) => [t, values[i], datas[i]]);
1761
- const executionCalldata = ethers.AbiCoder.defaultAbiCoder().encode(['(address,uint256,bytes)[]'], [executions]);
1762
- // Build the Safe7579 account calldata: execute(bytes32 mode, bytes executionCalldata)
1763
- const safe7579Iface = new ethers.Interface(SAFE7579_ACCOUNT_ABI);
1764
- const callData = safe7579Iface.encodeFunctionData('execute', [MODE_BATCH, executionCalldata]);
1765
- return this._submitUserOp(callData);
1766
- }
1767
- /** Convert MorphoMarketParams to Solidity tuple. */
1768
- _toTuple(p) {
1769
- return [p.loanToken, p.collateralToken, p.oracle, p.irm, p.lltv];
1770
- }
1771
- /** Find the first market where the agent has collateral deposited. */
1772
- async _findActiveMarket() {
1773
- const acctAddr = await this.getAccountAddress();
1774
- const markets = await this.getMarkets();
1775
- for (const m of markets) {
1776
- if (!m.collateralAsset || m.collateralAsset.address === ethers.ZeroAddress)
1777
- continue;
1778
- try {
1779
- const pos = await this.morphoBlue.position(m.uniqueKey, acctAddr);
1780
- if (pos.collateral > 0n) {
1781
- return {
1782
- params: {
1783
- loanToken: m.loanAsset.address,
1784
- collateralToken: m.collateralAsset.address,
1785
- oracle: m.oracle,
1786
- irm: m.irm,
1787
- lltv: m.lltv,
1788
- },
1789
- symbol: m.collateralAsset.symbol,
1790
- };
1791
- }
1792
- }
1793
- catch (e) {
1794
- console.warn('[agether] _findActiveMarket position check failed:', e instanceof Error ? e.message : e);
1795
- continue;
1796
- }
1797
- }
1798
- // Default to WETH
1799
- const params = await this.findMarketForCollateral('WETH');
1800
- return { params, symbol: 'WETH' };
1801
- }
1802
- /** Find the first market where the agent has a supply (lending) position. */
1803
- async _findActiveSupplyMarket() {
1804
- const acctAddr = await this.getAccountAddress();
1805
- const markets = await this.getMarkets();
1806
- for (const m of markets) {
1807
- if (!m.collateralAsset || m.collateralAsset.address === ethers.ZeroAddress)
1808
- continue;
1809
- try {
1810
- const pos = await this.morphoBlue.position(m.uniqueKey, acctAddr);
1811
- if (BigInt(pos.supplyShares) > 0n) {
1812
- return {
1813
- params: {
1814
- loanToken: m.loanAsset.address,
1815
- collateralToken: m.collateralAsset.address,
1816
- oracle: m.oracle,
1817
- irm: m.irm,
1818
- lltv: m.lltv,
1819
- },
1820
- symbol: m.collateralAsset.symbol,
1821
- };
1822
- }
1823
- }
1824
- catch (e) {
1825
- console.warn('[agether] _findActiveSupplyMarket position check failed:', e instanceof Error ? e.message : e);
1826
- continue;
1827
- }
1828
- }
1829
- throw new AgetherError('No active supply position found', 'NO_SUPPLY');
1830
- }
1831
- /**
1832
- * Resolve loan token decimals from market params.
1833
- * Uses the `_tokenCache` populated by `getMarkets()`.
1834
- */
1835
- async _getLoanTokenDecimals(params) {
1836
- const tokenInfo = this._tokenCache.get(params.loanToken.toLowerCase());
1837
- if (tokenInfo)
1838
- return tokenInfo.decimals;
1839
- // If not cached, ensure markets are loaded
1840
- await this.getMarkets();
1841
- const fromApi = this._tokenCache.get(params.loanToken.toLowerCase());
1842
- return fromApi?.decimals ?? 18; // fallback to 18 (ETH-like)
1843
- }
1844
- /**
1845
- * Apply client-side filter to discovered markets.
1846
- */
1847
- _applyMarketFilter(markets, filter) {
1848
- return markets.filter((m) => {
1849
- if (filter.loanToken) {
1850
- const loanAddr = filter.loanToken.toLowerCase();
1851
- if (m.loanAsset.address.toLowerCase() !== loanAddr
1852
- && m.loanAsset.symbol.toUpperCase() !== filter.loanToken.toUpperCase()) {
1853
- return false;
1854
- }
1855
- }
1856
- if (filter.collateralToken) {
1857
- const colAddr = filter.collateralToken.toLowerCase();
1858
- if (m.collateralAsset.address.toLowerCase() !== colAddr
1859
- && m.collateralAsset.symbol.toUpperCase() !== filter.collateralToken.toUpperCase()) {
1860
- return false;
1861
- }
1862
- }
1863
- return true;
1864
- });
1865
- }
1866
- /**
1867
- * Resolve a token symbol or address to { address, symbol, decimals }.
1868
- *
1869
- * Uses the dynamic `_tokenCache` populated by `getMarkets()` from the
1870
- * Morpho GraphQL API — no hardcoded token list needed.
1871
- *
1872
- * @param symbolOrAddress - e.g. 'WETH', 'wstETH', or '0x4200...'
1873
- */
1874
- async _resolveToken(symbolOrAddress) {
1875
- // Try cache first (by uppercase symbol or lowercase address)
1876
- const key = symbolOrAddress.startsWith('0x')
1877
- ? symbolOrAddress.toLowerCase()
1878
- : symbolOrAddress.toUpperCase();
1879
- const cached = this._tokenCache.get(key);
1880
- if (cached)
1881
- return cached;
1882
- // Cache cold — warm it from Morpho API (fetches up to 500 markets)
1883
- await this.getMarkets();
1884
- const fromApi = this._tokenCache.get(key);
1885
- if (fromApi)
1886
- return fromApi;
1887
- // Last resort: use Morpho's `search` to find the token across all markets.
1888
- // This handles tokens that didn't appear in the top-500 by supply.
1889
- if (!symbolOrAddress.startsWith('0x')) {
1890
- const searchResults = await this.searchMarkets(symbolOrAddress);
1891
- const sym = symbolOrAddress.toUpperCase();
1892
- for (const m of searchResults) {
1893
- if (m.collateralToken.toUpperCase() === sym) {
1894
- const info = { address: m.collateralAddress, symbol: m.collateralToken, decimals: m.collateralDecimals };
1895
- this._tokenCache.set(sym, info);
1896
- this._tokenCache.set(m.collateralAddress.toLowerCase(), info);
1897
- return info;
1898
- }
1899
- if (m.loanToken.toUpperCase() === sym) {
1900
- const info = { address: m.loanAddress, symbol: m.loanToken, decimals: m.loanDecimals };
1901
- this._tokenCache.set(sym, info);
1902
- this._tokenCache.set(m.loanAddress.toLowerCase(), info);
1903
- return info;
1904
- }
1905
- }
1906
- }
1907
- throw new AgetherError(`Unknown token: ${symbolOrAddress}. No Morpho market found with this token.`, 'UNKNOWN_TOKEN');
1908
- }
1909
- /**
1910
- * Compute net deposited amounts per market using Morpho GraphQL API.
1911
- *
1912
- * Fetches all MarketSupply and MarketWithdraw transactions for the account,
1913
- * then computes: netDeposited[marketId] = Σ Supply.assets − Σ Withdraw.assets
1914
- *
1915
- * Returns a Map<marketId (lowercase), bigint>.
1916
- *
1917
- * Uses pagination (100 per page) for completeness, though agent accounts
1918
- * typically have single-digit transaction counts.
1919
- */
1920
- async _computeNetDepositedAll(accountAddr, chainId) {
1921
- const result = new Map();
1922
- let skip = 0;
1923
- const pageSize = 100;
1924
- let hasMore = true;
1925
- while (hasMore) {
1926
- const txQuery = `{
1927
- transactions(
1928
- first: ${pageSize}
1929
- skip: ${skip}
1930
- where: {
1931
- userAddress_in: ["${accountAddr}"]
1932
- type_in: [MarketSupply, MarketWithdraw]
1933
- chainId_in: [${chainId}]
1934
- }
1935
- ) {
1936
- pageInfo { count countTotal }
1937
- items {
1938
- type
1939
- data {
1940
- ... on MarketTransferTransactionData {
1941
- assets
1942
- market { uniqueKey }
1943
- }
1944
- }
1945
- }
1946
- }
1947
- }`;
1948
- const resp = await axios.post(MORPHO_API_URL, { query: txQuery }, { timeout: 15000 });
1949
- const txData = resp.data?.data?.transactions;
1950
- if (!txData?.items)
1951
- break;
1952
- for (const tx of txData.items) {
1953
- const marketKey = tx.data?.market?.uniqueKey?.toLowerCase();
1954
- if (!marketKey || !tx.data?.assets)
1955
- continue;
1956
- const assets = BigInt(tx.data.assets);
1957
- const current = result.get(marketKey) ?? 0n;
1958
- if (tx.type === 'MarketSupply') {
1959
- result.set(marketKey, current + assets);
1960
- }
1961
- else if (tx.type === 'MarketWithdraw') {
1962
- const newVal = current - assets;
1963
- result.set(marketKey, newVal > 0n ? newVal : 0n);
1964
- }
1965
- }
1966
- // Check if there are more pages
1967
- const fetched = skip + txData.items.length;
1968
- const total = txData.pageInfo?.countTotal ?? 0;
1969
- hasMore = fetched < total;
1970
- skip += pageSize;
1971
- }
1972
- return result;
1973
- }
1974
- }