@agether/sdk 1.6.0 → 1.6.2

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.
@@ -0,0 +1,951 @@
1
+ /**
2
+ * MorphoClient — Direct Morpho Blue lending via AgentAccount.executeBatch
3
+ *
4
+ * Architecture (no intermediary contract):
5
+ * EOA → AgentAccount.executeBatch → Morpho Blue (direct)
6
+ *
7
+ * Batched operations:
8
+ * - depositAndBorrow: [ERC20.approve, Morpho.supplyCollateral, Morpho.borrow]
9
+ * - repay: [ERC20.approve, Morpho.repay]
10
+ * - supplyCollateral: [ERC20.approve, Morpho.supplyCollateral]
11
+ *
12
+ * Market discovery via Morpho GraphQL API (https://api.morpho.org/graphql)
13
+ */
14
+ import { ethers, Contract } from 'ethers';
15
+ import axios from 'axios';
16
+ import { AgetherError, ChainId, } from '../types';
17
+ import { ACCOUNT_FACTORY_ABI, AGENT_ACCOUNT_ABI, AGENT_REPUTATION_ABI, IDENTITY_REGISTRY_ABI, MORPHO_BLUE_ABI, ERC20_ABI, } from '../utils/abis';
18
+ import { getDefaultConfig } from '../utils/config';
19
+ // ── Well-known collateral tokens on Base ──
20
+ const BASE_COLLATERALS = {
21
+ WETH: { address: '0x4200000000000000000000000000000000000006', symbol: 'WETH', decimals: 18 },
22
+ wstETH: { address: '0xc1CBa3fCea344f92D9239c08C0568f6F2F0ee452', symbol: 'wstETH', decimals: 18 },
23
+ cbETH: { address: '0x2Ae3F1Ec7F1F5012CFEab0185bfc7aa3cf0DEc22', symbol: 'cbETH', decimals: 18 },
24
+ };
25
+ const MORPHO_API_URL = 'https://api.morpho.org/graphql';
26
+ // ── Morpho ABI interfaces for encoding ──
27
+ const morphoIface = new ethers.Interface(MORPHO_BLUE_ABI);
28
+ const erc20Iface = new ethers.Interface(ERC20_ABI);
29
+ // ── Client ──
30
+ export class MorphoClient {
31
+ constructor(config) {
32
+ this._marketCache = new Map();
33
+ this._discoveredAt = 0;
34
+ const chainId = config.chainId ?? ChainId.Base;
35
+ const defaultCfg = getDefaultConfig(chainId);
36
+ this.config = defaultCfg;
37
+ this.agentId = config.agentId;
38
+ this.provider = new ethers.JsonRpcProvider(config.rpcUrl || defaultCfg.rpcUrl);
39
+ this.wallet = new ethers.Wallet(config.privateKey, this.provider);
40
+ const addrs = { ...defaultCfg.contracts, ...config.contracts };
41
+ this.accountFactory = new Contract(addrs.accountFactory, ACCOUNT_FACTORY_ABI, this.wallet);
42
+ this.morphoBlue = new Contract(addrs.morphoBlue, MORPHO_BLUE_ABI, this.provider);
43
+ this.agentReputation = new Contract(addrs.agentReputation, AGENT_REPUTATION_ABI, this.wallet);
44
+ this.identityRegistry = new Contract(addrs.identityRegistry, IDENTITY_REGISTRY_ABI, this.wallet);
45
+ }
46
+ // ════════════════════════════════════════════════════════
47
+ // Account Management
48
+ // ════════════════════════════════════════════════════════
49
+ /** Resolve the AgentAccount address (cached). */
50
+ async getAccountAddress() {
51
+ if (this._accountAddress)
52
+ return this._accountAddress;
53
+ if (!this.agentId)
54
+ throw new AgetherError('agentId not set', 'NO_AGENT_ID');
55
+ const addr = await this.accountFactory.getAccount(BigInt(this.agentId));
56
+ if (addr === ethers.ZeroAddress) {
57
+ throw new AgetherError('No AgentAccount found. Call register() first.', 'NO_ACCOUNT');
58
+ }
59
+ this._accountAddress = addr;
60
+ return addr;
61
+ }
62
+ getAgentId() {
63
+ if (!this.agentId)
64
+ throw new AgetherError('agentId not set', 'NO_AGENT_ID');
65
+ return this.agentId;
66
+ }
67
+ getWalletAddress() {
68
+ return this.wallet.address;
69
+ }
70
+ /** Mint a new ERC-8004 identity and return the agentId. */
71
+ async _mintNewIdentity() {
72
+ const regTx = await this.identityRegistry.register();
73
+ const regReceipt = await regTx.wait();
74
+ let agentId = 0n;
75
+ for (const log of regReceipt.logs) {
76
+ try {
77
+ const parsed = this.identityRegistry.interface.parseLog({ topics: log.topics, data: log.data });
78
+ if (parsed?.name === 'Transfer') {
79
+ agentId = parsed.args[2];
80
+ break;
81
+ }
82
+ }
83
+ catch {
84
+ continue;
85
+ }
86
+ }
87
+ if (agentId === 0n)
88
+ throw new AgetherError('Failed to parse agentId from registration', 'PARSE_ERROR');
89
+ return agentId;
90
+ }
91
+ /**
92
+ * Register: create ERC-8004 identity + AgentAccount in one flow.
93
+ * If already registered, returns existing state.
94
+ */
95
+ async register(_name) {
96
+ const eoaAddr = this.wallet.address;
97
+ // Check if we already have an agentId
98
+ if (this.agentId) {
99
+ const exists = await this.accountFactory.accountExists(BigInt(this.agentId));
100
+ if (exists) {
101
+ const acct = await this.accountFactory.getAccount(BigInt(this.agentId));
102
+ this._accountAddress = acct;
103
+ return { agentId: this.agentId, address: eoaAddr, agentAccount: acct, alreadyRegistered: true };
104
+ }
105
+ }
106
+ // Check if wallet already has an identity AND we know the agentId
107
+ let agentId;
108
+ if (this.agentId) {
109
+ // We have an agentId in config — reuse it
110
+ const balance = await this.identityRegistry.balanceOf(eoaAddr);
111
+ if (balance > 0n) {
112
+ agentId = BigInt(this.agentId);
113
+ }
114
+ else {
115
+ // agentId in config but no on-chain identity — register fresh
116
+ agentId = await this._mintNewIdentity();
117
+ }
118
+ }
119
+ else {
120
+ // No agentId — always register a new identity (wallets can have multiple ERC-8004 tokens)
121
+ agentId = await this._mintNewIdentity();
122
+ }
123
+ this.agentId = agentId.toString();
124
+ // Create AgentAccount if needed
125
+ const acctExists = await this.accountFactory.accountExists(agentId);
126
+ let txHash;
127
+ if (!acctExists) {
128
+ const tx = await this.accountFactory.createAccount(agentId);
129
+ const receipt = await tx.wait();
130
+ txHash = receipt.hash;
131
+ }
132
+ const acctAddr = await this.accountFactory.getAccount(agentId);
133
+ this._accountAddress = acctAddr;
134
+ return {
135
+ agentId: this.agentId,
136
+ address: eoaAddr,
137
+ agentAccount: acctAddr,
138
+ alreadyRegistered: acctExists,
139
+ tx: txHash,
140
+ };
141
+ }
142
+ /** Get ETH / USDC / collateral balances for EOA and AgentAccount. */
143
+ async getBalances() {
144
+ const eoaAddr = this.wallet.address;
145
+ const usdc = new Contract(this.config.contracts.usdc, ERC20_ABI, this.provider);
146
+ const ethBal = await this.provider.getBalance(eoaAddr);
147
+ const usdcBal = await usdc.balanceOf(eoaAddr);
148
+ // Fetch collateral token balances for EOA
149
+ const eoaCollateral = {};
150
+ for (const [symbol, info] of Object.entries(BASE_COLLATERALS)) {
151
+ try {
152
+ const token = new Contract(info.address, ERC20_ABI, this.provider);
153
+ const bal = await token.balanceOf(eoaAddr);
154
+ eoaCollateral[symbol] = ethers.formatUnits(bal, info.decimals);
155
+ }
156
+ catch {
157
+ eoaCollateral[symbol] = '0';
158
+ }
159
+ }
160
+ const result = {
161
+ agentId: this.agentId || '?',
162
+ address: eoaAddr,
163
+ eth: ethers.formatEther(ethBal),
164
+ usdc: ethers.formatUnits(usdcBal, 6),
165
+ collateral: eoaCollateral,
166
+ };
167
+ try {
168
+ const acctAddr = await this.getAccountAddress();
169
+ const acctEth = await this.provider.getBalance(acctAddr);
170
+ const acctUsdc = await usdc.balanceOf(acctAddr);
171
+ // Fetch collateral token balances for AgentAccount
172
+ const acctCollateral = {};
173
+ for (const [symbol, info] of Object.entries(BASE_COLLATERALS)) {
174
+ try {
175
+ const token = new Contract(info.address, ERC20_ABI, this.provider);
176
+ const bal = await token.balanceOf(acctAddr);
177
+ acctCollateral[symbol] = ethers.formatUnits(bal, info.decimals);
178
+ }
179
+ catch {
180
+ acctCollateral[symbol] = '0';
181
+ }
182
+ }
183
+ result.agentAccount = {
184
+ address: acctAddr,
185
+ eth: ethers.formatEther(acctEth),
186
+ usdc: ethers.formatUnits(acctUsdc, 6),
187
+ collateral: acctCollateral,
188
+ };
189
+ }
190
+ catch { /* no account yet */ }
191
+ return result;
192
+ }
193
+ /** Transfer USDC from EOA to AgentAccount. */
194
+ async fundAccount(usdcAmount) {
195
+ const acctAddr = await this.getAccountAddress();
196
+ const usdc = new Contract(this.config.contracts.usdc, ERC20_ABI, this.wallet);
197
+ const amount = ethers.parseUnits(usdcAmount, 6);
198
+ const tx = await usdc.transfer(acctAddr, amount);
199
+ const receipt = await tx.wait();
200
+ return { tx: receipt.hash, amount: usdcAmount, agentAccount: acctAddr };
201
+ }
202
+ // ════════════════════════════════════════════════════════
203
+ // Market Discovery (Morpho GraphQL API)
204
+ // ════════════════════════════════════════════════════════
205
+ /**
206
+ * Fetch USDC borrow markets on Base from Morpho API.
207
+ * Caches results for 5 minutes.
208
+ */
209
+ async getMarkets(forceRefresh = false) {
210
+ if (!forceRefresh && this._discoveredMarkets && Date.now() - this._discoveredAt < 300000) {
211
+ return this._discoveredMarkets;
212
+ }
213
+ const chainId = this.config.chainId;
214
+ const usdcAddr = this.config.contracts.usdc.toLowerCase();
215
+ const query = `{
216
+ markets(
217
+ first: 50
218
+ orderBy: SupplyAssetsUsd
219
+ orderDirection: Desc
220
+ where: { chainId_in: [${chainId}], loanAssetAddress_in: ["${usdcAddr}"] }
221
+ ) {
222
+ items {
223
+ uniqueKey
224
+ lltv
225
+ oracleAddress
226
+ irmAddress
227
+ loanAsset { address symbol decimals }
228
+ collateralAsset { address symbol decimals }
229
+ state {
230
+ borrowAssets
231
+ supplyAssets
232
+ utilization
233
+ }
234
+ }
235
+ }
236
+ }`;
237
+ try {
238
+ const resp = await axios.post(MORPHO_API_URL, { query }, { timeout: 10000 });
239
+ const items = resp.data?.data?.markets?.items ?? [];
240
+ this._discoveredMarkets = items.map((m) => ({
241
+ uniqueKey: m.uniqueKey,
242
+ loanAsset: m.loanAsset,
243
+ collateralAsset: m.collateralAsset ?? { address: ethers.ZeroAddress, symbol: 'N/A', decimals: 0 },
244
+ oracle: m.oracleAddress,
245
+ irm: m.irmAddress,
246
+ lltv: BigInt(m.lltv),
247
+ totalSupplyAssets: BigInt(m.state?.supplyAssets ?? '0'),
248
+ totalBorrowAssets: BigInt(m.state?.borrowAssets ?? '0'),
249
+ utilization: m.state?.utilization ? Number(m.state.utilization) : 0,
250
+ }));
251
+ this._discoveredAt = Date.now();
252
+ // Warm the params cache
253
+ for (const mi of this._discoveredMarkets) {
254
+ if (mi.collateralAsset.address !== ethers.ZeroAddress) {
255
+ this._marketCache.set(mi.collateralAsset.address.toLowerCase(), {
256
+ loanToken: mi.loanAsset.address,
257
+ collateralToken: mi.collateralAsset.address,
258
+ oracle: mi.oracle,
259
+ irm: mi.irm,
260
+ lltv: mi.lltv,
261
+ });
262
+ }
263
+ }
264
+ return this._discoveredMarkets;
265
+ }
266
+ catch {
267
+ // Fallback: return cached or empty
268
+ return this._discoveredMarkets ?? [];
269
+ }
270
+ }
271
+ /**
272
+ * Get MarketParams for a collateral token.
273
+ * Tries cache → API → on-chain idToMarketParams.
274
+ */
275
+ async findMarketForCollateral(collateralSymbolOrAddress) {
276
+ // Resolve symbol → address
277
+ const colInfo = BASE_COLLATERALS[collateralSymbolOrAddress];
278
+ const colAddr = (colInfo?.address ?? collateralSymbolOrAddress).toLowerCase();
279
+ // Check cache
280
+ const cached = this._marketCache.get(colAddr);
281
+ if (cached)
282
+ return cached;
283
+ // Try API discovery
284
+ await this.getMarkets();
285
+ const fromApi = this._marketCache.get(colAddr);
286
+ if (fromApi)
287
+ return fromApi;
288
+ throw new AgetherError(`No Morpho market found for collateral ${collateralSymbolOrAddress}`, 'MARKET_NOT_FOUND');
289
+ }
290
+ /** Read MarketParams on-chain by market ID (bytes32). */
291
+ async getMarketParams(marketId) {
292
+ const result = await this.morphoBlue.idToMarketParams(marketId);
293
+ return {
294
+ loanToken: result.loanToken,
295
+ collateralToken: result.collateralToken,
296
+ oracle: result.oracle,
297
+ irm: result.irm,
298
+ lltv: result.lltv,
299
+ };
300
+ }
301
+ // ════════════════════════════════════════════════════════
302
+ // Position Reads
303
+ // ════════════════════════════════════════════════════════
304
+ /** Read on-chain position for a specific market. */
305
+ async getPosition(marketId) {
306
+ const acctAddr = await this.getAccountAddress();
307
+ const pos = await this.morphoBlue.position(marketId, acctAddr);
308
+ return {
309
+ supplyShares: pos.supplyShares,
310
+ borrowShares: pos.borrowShares,
311
+ collateral: pos.collateral,
312
+ };
313
+ }
314
+ /**
315
+ * Full status: positions across all discovered markets.
316
+ */
317
+ async getStatus() {
318
+ const acctAddr = await this.getAccountAddress();
319
+ const markets = await this.getMarkets();
320
+ const positions = [];
321
+ let totalDebt = 0n;
322
+ for (const m of markets) {
323
+ if (!m.collateralAsset || m.collateralAsset.address === ethers.ZeroAddress)
324
+ continue;
325
+ try {
326
+ const pos = await this.morphoBlue.position(m.uniqueKey, acctAddr);
327
+ if (pos.collateral === 0n && pos.borrowShares === 0n && pos.supplyShares === 0n)
328
+ continue;
329
+ // Estimate debt from shares
330
+ let debt = 0n;
331
+ if (pos.borrowShares > 0n) {
332
+ try {
333
+ const mkt = await this.morphoBlue.market(m.uniqueKey);
334
+ const totalBorrowShares = BigInt(mkt.totalBorrowShares);
335
+ const totalBorrowAssets = BigInt(mkt.totalBorrowAssets);
336
+ debt = totalBorrowShares > 0n
337
+ ? (BigInt(pos.borrowShares) * totalBorrowAssets) / totalBorrowShares
338
+ : 0n;
339
+ totalDebt += debt;
340
+ }
341
+ catch { /* skip */ }
342
+ }
343
+ positions.push({
344
+ marketId: m.uniqueKey,
345
+ collateralToken: m.collateralAsset.symbol,
346
+ collateral: ethers.formatUnits(pos.collateral, m.collateralAsset.decimals),
347
+ borrowShares: pos.borrowShares.toString(),
348
+ supplyShares: pos.supplyShares.toString(),
349
+ debt: ethers.formatUnits(debt, 6),
350
+ });
351
+ }
352
+ catch {
353
+ continue;
354
+ }
355
+ }
356
+ return {
357
+ agentId: this.agentId || '?',
358
+ agentAccount: acctAddr,
359
+ totalDebt: ethers.formatUnits(totalDebt, 6),
360
+ positions,
361
+ };
362
+ }
363
+ // ════════════════════════════════════════════════════════
364
+ // Balance & Borrowing Capacity
365
+ // ════════════════════════════════════════════════════════
366
+ /**
367
+ * Get the USDC balance of the AgentAccount.
368
+ * @returns USDC balance in raw units (6 decimals)
369
+ */
370
+ async getUsdcBalance() {
371
+ const acctAddr = await this.getAccountAddress();
372
+ const usdc = new Contract(this.config.contracts.usdc, ERC20_ABI, this.provider);
373
+ return usdc.balanceOf(acctAddr);
374
+ }
375
+ /**
376
+ * Calculate the maximum additional USDC that can be borrowed
377
+ * given the agent's current collateral and debt across all markets.
378
+ *
379
+ * For each market with collateral deposited:
380
+ * maxBorrow = (collateralValue * LLTV) - currentDebt
381
+ *
382
+ * Uses the Morpho oracle to price collateral → loan token.
383
+ *
384
+ * @returns Maximum additional USDC borrowable (6 decimals)
385
+ */
386
+ async getMaxBorrowable() {
387
+ const acctAddr = await this.getAccountAddress();
388
+ const markets = await this.getMarkets();
389
+ let totalAdditional = 0n;
390
+ const byMarket = [];
391
+ for (const m of markets) {
392
+ if (!m.collateralAsset || m.collateralAsset.address === ethers.ZeroAddress)
393
+ continue;
394
+ try {
395
+ const pos = await this.morphoBlue.position(m.uniqueKey, acctAddr);
396
+ if (pos.collateral === 0n)
397
+ continue;
398
+ // Get market state for debt calculation
399
+ const mktState = await this.morphoBlue.market(m.uniqueKey);
400
+ const totalBorrowShares = BigInt(mktState.totalBorrowShares);
401
+ const totalBorrowAssets = BigInt(mktState.totalBorrowAssets);
402
+ const currentDebt = totalBorrowShares > 0n
403
+ ? (BigInt(pos.borrowShares) * totalBorrowAssets) / totalBorrowShares
404
+ : 0n;
405
+ // Get oracle price for collateral → USDC conversion
406
+ // Morpho oracle returns price scaled to 36 + loanDecimals - collateralDecimals
407
+ // price = collateral * oraclePrice / 10^36
408
+ let collateralValueInLoan;
409
+ try {
410
+ const oracleContract = new Contract(m.oracle, [
411
+ 'function price() view returns (uint256)',
412
+ ], this.provider);
413
+ const oraclePrice = await oracleContract.price();
414
+ const ORACLE_PRICE_SCALE = 10n ** 36n;
415
+ collateralValueInLoan = (BigInt(pos.collateral) * oraclePrice) / ORACLE_PRICE_SCALE;
416
+ }
417
+ catch {
418
+ continue;
419
+ }
420
+ // maxBorrow = collateralValue * LLTV / 1e18 - currentDebt
421
+ const maxBorrowTotal = (collateralValueInLoan * m.lltv) / (10n ** 18n);
422
+ const maxAdditional = maxBorrowTotal > currentDebt ? maxBorrowTotal - currentDebt : 0n;
423
+ totalAdditional += maxAdditional;
424
+ byMarket.push({
425
+ collateralToken: m.collateralAsset.symbol,
426
+ maxAdditional,
427
+ currentDebt,
428
+ collateralValue: collateralValueInLoan,
429
+ });
430
+ }
431
+ catch {
432
+ continue;
433
+ }
434
+ }
435
+ return { total: totalAdditional, byMarket };
436
+ }
437
+ // ════════════════════════════════════════════════════════
438
+ // Market Rates & Yield Estimation
439
+ // ════════════════════════════════════════════════════════
440
+ /**
441
+ * Fetch current supply/borrow APY for a collateral market from Morpho GraphQL API.
442
+ *
443
+ * Note: On Morpho Blue, collateral does NOT earn yield directly. Supply APY
444
+ * is what lenders earn; borrow APY is what borrowers pay.
445
+ */
446
+ async getMarketRates(collateralSymbolOrAddress) {
447
+ const chainId = this.config.chainId;
448
+ const usdcAddr = this.config.contracts.usdc.toLowerCase();
449
+ // Optionally filter by collateral
450
+ let collateralFilter = '';
451
+ if (collateralSymbolOrAddress) {
452
+ const colInfo = BASE_COLLATERALS[collateralSymbolOrAddress];
453
+ const colAddr = (colInfo?.address ?? collateralSymbolOrAddress).toLowerCase();
454
+ collateralFilter = `, collateralAssetAddress_in: ["${colAddr}"]`;
455
+ }
456
+ const query = `{
457
+ markets(
458
+ first: 50
459
+ orderBy: SupplyAssetsUsd
460
+ orderDirection: Desc
461
+ where: { chainId_in: [${chainId}], loanAssetAddress_in: ["${usdcAddr}"]${collateralFilter} }
462
+ ) {
463
+ items {
464
+ uniqueKey
465
+ lltv
466
+ loanAsset { address symbol decimals }
467
+ collateralAsset { address symbol decimals }
468
+ state {
469
+ borrowAssets
470
+ supplyAssets
471
+ utilization
472
+ supplyApy
473
+ borrowApy
474
+ }
475
+ }
476
+ }
477
+ }`;
478
+ try {
479
+ const resp = await axios.post(MORPHO_API_URL, { query }, { timeout: 10000 });
480
+ const items = resp.data?.data?.markets?.items ?? [];
481
+ return items
482
+ .filter((m) => m.collateralAsset?.address && m.collateralAsset.address !== ethers.ZeroAddress)
483
+ .map((m) => ({
484
+ collateralToken: m.collateralAsset.symbol,
485
+ loanToken: m.loanAsset.symbol,
486
+ supplyApy: m.state?.supplyApy ? Number(m.state.supplyApy) : 0,
487
+ borrowApy: m.state?.borrowApy ? Number(m.state.borrowApy) : 0,
488
+ utilization: m.state?.utilization ? Number(m.state.utilization) : 0,
489
+ totalSupplyUsd: m.state?.supplyAssets ? Number(m.state.supplyAssets) / 1e6 : 0,
490
+ totalBorrowUsd: m.state?.borrowAssets ? Number(m.state.borrowAssets) / 1e6 : 0,
491
+ lltv: `${(Number(m.lltv) / 1e16).toFixed(0)}%`,
492
+ marketId: m.uniqueKey,
493
+ }));
494
+ }
495
+ catch {
496
+ return [];
497
+ }
498
+ }
499
+ /**
500
+ * Estimate theoretical yield for a given collateral amount over a period.
501
+ *
502
+ * ⚠️ IMPORTANT: On Morpho Blue, collateral does NOT earn yield directly.
503
+ * This estimates what the collateral WOULD earn if it were instead supplied
504
+ * as a lender (not used as collateral). This is a theoretical upper bound
505
+ * useful for setting spending caps.
506
+ *
507
+ * @param collateralSymbol - e.g. 'WETH'
508
+ * @param amount - collateral amount in human-readable (e.g. '1.5')
509
+ * @param periodDays - estimation period in days (default: 1)
510
+ * @param ethPriceUsd - ETH price in USD for value conversion (if not provided, uses oracle)
511
+ * @returns Estimated yield in USD for the period
512
+ */
513
+ async getYieldEstimate(collateralSymbol, amount, periodDays = 1, ethPriceUsd) {
514
+ const colInfo = BASE_COLLATERALS[collateralSymbol];
515
+ if (!colInfo)
516
+ throw new AgetherError(`Unknown collateral: ${collateralSymbol}`, 'UNKNOWN_COLLATERAL');
517
+ // Get market rates for this collateral
518
+ const rates = await this.getMarketRates(collateralSymbol);
519
+ if (rates.length === 0) {
520
+ throw new AgetherError(`No market found for ${collateralSymbol}`, 'MARKET_NOT_FOUND');
521
+ }
522
+ // Use the most liquid market (first one, sorted by supply)
523
+ const market = rates[0];
524
+ const supplyApy = market.supplyApy;
525
+ // Determine collateral value in USD
526
+ let collateralValueUsd;
527
+ if (ethPriceUsd) {
528
+ collateralValueUsd = parseFloat(amount) * ethPriceUsd;
529
+ }
530
+ else {
531
+ try {
532
+ const params = await this.findMarketForCollateral(collateralSymbol);
533
+ const oracleContract = new Contract(params.oracle, [
534
+ 'function price() view returns (uint256)',
535
+ ], this.provider);
536
+ const oraclePrice = await oracleContract.price();
537
+ const ORACLE_PRICE_SCALE = 10n ** 36n;
538
+ const amountWei = ethers.parseUnits(amount, colInfo.decimals);
539
+ const valueInUsdc = (amountWei * oraclePrice) / ORACLE_PRICE_SCALE;
540
+ collateralValueUsd = Number(valueInUsdc) / 1e6;
541
+ }
542
+ catch {
543
+ throw new AgetherError('Cannot determine collateral value. Provide ethPriceUsd.', 'PRICE_UNAVAILABLE');
544
+ }
545
+ }
546
+ // Calculate yield: value * APY * (days / 365)
547
+ const estimatedYieldUsd = collateralValueUsd * supplyApy * (periodDays / 365);
548
+ return {
549
+ collateralToken: collateralSymbol,
550
+ amount,
551
+ periodDays,
552
+ theoreticalSupplyApy: supplyApy,
553
+ estimatedYieldUsd,
554
+ collateralValueUsd,
555
+ 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.',
556
+ };
557
+ }
558
+ // ════════════════════════════════════════════════════════
559
+ // Lending Operations (all via AgentAccount.executeBatch)
560
+ // ════════════════════════════════════════════════════════
561
+ /**
562
+ * Deposit collateral into Morpho Blue.
563
+ *
564
+ * Flow:
565
+ * 1. EOA transfers collateral to AgentAccount
566
+ * 2. AgentAccount.executeBatch:
567
+ * [collateral.approve(MorphoBlue), Morpho.supplyCollateral(params)]
568
+ */
569
+ async supplyCollateral(tokenSymbol, amount, marketParams) {
570
+ const acctAddr = await this.getAccountAddress();
571
+ const colInfo = BASE_COLLATERALS[tokenSymbol];
572
+ if (!colInfo)
573
+ throw new AgetherError(`Unknown collateral: ${tokenSymbol}`, 'UNKNOWN_COLLATERAL');
574
+ const params = marketParams ?? await this.findMarketForCollateral(tokenSymbol);
575
+ const weiAmount = ethers.parseUnits(amount, colInfo.decimals);
576
+ const morphoAddr = this.config.contracts.morphoBlue;
577
+ // Step 1: Transfer collateral from EOA → AgentAccount
578
+ const colToken = new Contract(colInfo.address, ERC20_ABI, this.wallet);
579
+ const transferTx = await colToken.transfer(acctAddr, weiAmount);
580
+ await transferTx.wait();
581
+ // Step 2: AgentAccount batch: approve + supplyCollateral
582
+ const targets = [colInfo.address, morphoAddr];
583
+ const values = [0n, 0n];
584
+ const datas = [
585
+ erc20Iface.encodeFunctionData('approve', [morphoAddr, weiAmount]),
586
+ morphoIface.encodeFunctionData('supplyCollateral', [
587
+ this._toTuple(params), weiAmount, acctAddr, '0x',
588
+ ]),
589
+ ];
590
+ const receipt = await this.batch(targets, values, datas);
591
+ return {
592
+ tx: receipt.hash,
593
+ collateralToken: tokenSymbol,
594
+ amount,
595
+ agentAccount: acctAddr,
596
+ };
597
+ }
598
+ /**
599
+ * Borrow USDC against existing collateral.
600
+ *
601
+ * AgentAccount.execute: Morpho.borrow(params, amount, 0, account, account)
602
+ *
603
+ * @param usdcAmount - USDC amount (e.g. '100')
604
+ * @param tokenSymbol - collateral symbol to identify which market (default: first with collateral)
605
+ */
606
+ async borrow(usdcAmount, tokenSymbol, marketParams) {
607
+ const acctAddr = await this.getAccountAddress();
608
+ const amount = ethers.parseUnits(usdcAmount, 6);
609
+ const morphoAddr = this.config.contracts.morphoBlue;
610
+ // Find market
611
+ let params;
612
+ let usedToken = tokenSymbol || 'WETH';
613
+ if (marketParams) {
614
+ params = marketParams;
615
+ }
616
+ else if (tokenSymbol) {
617
+ params = await this.findMarketForCollateral(tokenSymbol);
618
+ }
619
+ else {
620
+ // Auto-detect: find first market with collateral
621
+ const { params: p, symbol } = await this._findActiveMarket();
622
+ params = p;
623
+ usedToken = symbol;
624
+ }
625
+ const data = morphoIface.encodeFunctionData('borrow', [
626
+ this._toTuple(params), amount, 0n, acctAddr, acctAddr,
627
+ ]);
628
+ const receipt = await this.exec(morphoAddr, data);
629
+ return {
630
+ tx: receipt.hash,
631
+ amount: usdcAmount,
632
+ collateralToken: usedToken,
633
+ agentAccount: acctAddr,
634
+ };
635
+ }
636
+ /**
637
+ * Deposit collateral AND borrow USDC in one batched transaction.
638
+ *
639
+ * AgentAccount.executeBatch:
640
+ * [collateral.approve, Morpho.supplyCollateral, Morpho.borrow]
641
+ *
642
+ * The collateral must be transferred to AgentAccount first.
643
+ */
644
+ async depositAndBorrow(tokenSymbol, collateralAmount, borrowUsdcAmount, marketParams) {
645
+ const acctAddr = await this.getAccountAddress();
646
+ const colInfo = BASE_COLLATERALS[tokenSymbol];
647
+ if (!colInfo)
648
+ throw new AgetherError(`Unknown collateral: ${tokenSymbol}`, 'UNKNOWN_COLLATERAL');
649
+ const params = marketParams ?? await this.findMarketForCollateral(tokenSymbol);
650
+ const colWei = ethers.parseUnits(collateralAmount, colInfo.decimals);
651
+ const borrowWei = ethers.parseUnits(borrowUsdcAmount, 6);
652
+ const morphoAddr = this.config.contracts.morphoBlue;
653
+ // Step 1: Transfer collateral from EOA → AgentAccount
654
+ const colToken = new Contract(colInfo.address, ERC20_ABI, this.wallet);
655
+ const transferTx = await colToken.transfer(acctAddr, colWei);
656
+ await transferTx.wait();
657
+ // Step 2: Batched — approve + supplyCollateral + borrow
658
+ const targets = [colInfo.address, morphoAddr, morphoAddr];
659
+ const values = [0n, 0n, 0n];
660
+ const datas = [
661
+ erc20Iface.encodeFunctionData('approve', [morphoAddr, colWei]),
662
+ morphoIface.encodeFunctionData('supplyCollateral', [
663
+ this._toTuple(params), colWei, acctAddr, '0x',
664
+ ]),
665
+ morphoIface.encodeFunctionData('borrow', [
666
+ this._toTuple(params), borrowWei, 0n, acctAddr, acctAddr,
667
+ ]),
668
+ ];
669
+ const receipt = await this.batch(targets, values, datas);
670
+ return {
671
+ tx: receipt.hash,
672
+ collateralToken: tokenSymbol,
673
+ collateralAmount,
674
+ borrowAmount: borrowUsdcAmount,
675
+ agentAccount: acctAddr,
676
+ };
677
+ }
678
+ /**
679
+ * Repay borrowed USDC from AgentAccount.
680
+ *
681
+ * AgentAccount.executeBatch:
682
+ * [USDC.approve(MorphoBlue), Morpho.repay(params)]
683
+ */
684
+ async repay(usdcAmount, tokenSymbol, marketParams) {
685
+ const acctAddr = await this.getAccountAddress();
686
+ const morphoAddr = this.config.contracts.morphoBlue;
687
+ const usdcAddr = this.config.contracts.usdc;
688
+ let params;
689
+ if (marketParams) {
690
+ params = marketParams;
691
+ }
692
+ else if (tokenSymbol) {
693
+ params = await this.findMarketForCollateral(tokenSymbol);
694
+ }
695
+ else {
696
+ const { params: p } = await this._findActiveMarket();
697
+ params = p;
698
+ }
699
+ // Determine whether to repay by shares (full repay) or by assets (partial).
700
+ // Using shares-based repay prevents dust borrow shares from remaining.
701
+ let repayAssets;
702
+ let repayShares;
703
+ let approveAmount;
704
+ if (usdcAmount === 'all') {
705
+ // Full repay: use shares to ensure no dust remains
706
+ const markets = await this.getMarkets();
707
+ const mkt = markets.find((m) => m.collateralAsset?.address.toLowerCase() === params.collateralToken.toLowerCase());
708
+ if (mkt) {
709
+ const pos = await this.morphoBlue.position(mkt.uniqueKey, acctAddr);
710
+ repayShares = BigInt(pos.borrowShares);
711
+ repayAssets = 0n;
712
+ // Read on-chain market state for accurate share→asset conversion
713
+ const onChainMkt = await this.morphoBlue.market(mkt.uniqueKey);
714
+ const totalBorrowAssets = BigInt(onChainMkt.totalBorrowAssets);
715
+ const totalBorrowShares = BigInt(onChainMkt.totalBorrowShares);
716
+ const estimated = totalBorrowShares > 0n
717
+ ? (repayShares * totalBorrowAssets) / totalBorrowShares + 10n
718
+ : 0n;
719
+ approveAmount = estimated > 0n ? estimated : ethers.parseUnits('1', 6);
720
+ }
721
+ else {
722
+ // Fallback: large asset repay
723
+ repayAssets = ethers.parseUnits('999999', 6);
724
+ repayShares = 0n;
725
+ approveAmount = repayAssets;
726
+ }
727
+ }
728
+ else {
729
+ repayAssets = ethers.parseUnits(usdcAmount, 6);
730
+ repayShares = 0n;
731
+ approveAmount = repayAssets;
732
+ // Check if this covers the full debt — if so, switch to shares-based
733
+ try {
734
+ const markets = await this.getMarkets();
735
+ const mkt = markets.find((m) => m.collateralAsset?.address.toLowerCase() === params.collateralToken.toLowerCase());
736
+ if (mkt) {
737
+ const pos = await this.morphoBlue.position(mkt.uniqueKey, acctAddr);
738
+ const onChainMkt = await this.morphoBlue.market(mkt.uniqueKey);
739
+ const totalBorrowAssets = BigInt(onChainMkt.totalBorrowAssets);
740
+ const totalBorrowShares = BigInt(onChainMkt.totalBorrowShares);
741
+ const currentDebt = totalBorrowShares > 0n
742
+ ? (BigInt(pos.borrowShares) * totalBorrowAssets) / totalBorrowShares
743
+ : 0n;
744
+ // If repaying >= debt, use shares to clear dust
745
+ if (repayAssets >= currentDebt && BigInt(pos.borrowShares) > 0n) {
746
+ repayShares = BigInt(pos.borrowShares);
747
+ repayAssets = 0n;
748
+ approveAmount = currentDebt + 10n; // small buffer for rounding
749
+ }
750
+ }
751
+ }
752
+ catch { /* fall through to asset-based repay */ }
753
+ }
754
+ // Batch: approve + repay
755
+ const targets = [usdcAddr, morphoAddr];
756
+ const values = [0n, 0n];
757
+ const datas = [
758
+ erc20Iface.encodeFunctionData('approve', [morphoAddr, approveAmount]),
759
+ morphoIface.encodeFunctionData('repay', [
760
+ this._toTuple(params), repayAssets, repayShares, acctAddr, '0x',
761
+ ]),
762
+ ];
763
+ const receipt = await this.batch(targets, values, datas);
764
+ // Read remaining debt
765
+ let remainingDebt = '0';
766
+ try {
767
+ const status = await this.getStatus();
768
+ remainingDebt = status.totalDebt;
769
+ }
770
+ catch { /* ignore */ }
771
+ return { tx: receipt.hash, amount: usdcAmount, remainingDebt };
772
+ }
773
+ /**
774
+ * Withdraw collateral from Morpho Blue.
775
+ *
776
+ * AgentAccount.execute: Morpho.withdrawCollateral(params, amount, account, receiver)
777
+ *
778
+ * @param receiver - defaults to EOA wallet
779
+ */
780
+ async withdrawCollateral(tokenSymbol, amount, marketParams, receiver) {
781
+ const acctAddr = await this.getAccountAddress();
782
+ const colInfo = BASE_COLLATERALS[tokenSymbol];
783
+ if (!colInfo)
784
+ throw new AgetherError(`Unknown collateral: ${tokenSymbol}`, 'UNKNOWN_COLLATERAL');
785
+ const params = marketParams ?? await this.findMarketForCollateral(tokenSymbol);
786
+ const morphoAddr = this.config.contracts.morphoBlue;
787
+ const dest = receiver || this.wallet.address;
788
+ // Handle 'all' — withdraw full collateral
789
+ let weiAmount;
790
+ if (amount === 'all') {
791
+ const markets = await this.getMarkets();
792
+ const market = markets.find((m) => m.collateralAsset.address.toLowerCase() === colInfo.address.toLowerCase());
793
+ if (!market)
794
+ throw new AgetherError('Market not found', 'MARKET_NOT_FOUND');
795
+ const pos = await this.morphoBlue.position(market.uniqueKey, acctAddr);
796
+ weiAmount = pos.collateral;
797
+ if (weiAmount === 0n)
798
+ throw new AgetherError('No collateral to withdraw', 'NO_COLLATERAL');
799
+ }
800
+ else {
801
+ weiAmount = ethers.parseUnits(amount, colInfo.decimals);
802
+ }
803
+ const data = morphoIface.encodeFunctionData('withdrawCollateral', [
804
+ this._toTuple(params), weiAmount, acctAddr, dest,
805
+ ]);
806
+ const receipt = await this.exec(morphoAddr, data);
807
+ // Read remaining collateral
808
+ let remainingCollateral = '0';
809
+ try {
810
+ const markets = await this.getMarkets();
811
+ const market = markets.find((m) => m.collateralAsset.address.toLowerCase() === colInfo.address.toLowerCase());
812
+ if (market) {
813
+ const pos = await this.morphoBlue.position(market.uniqueKey, acctAddr);
814
+ remainingCollateral = ethers.formatUnits(pos.collateral, colInfo.decimals);
815
+ }
816
+ }
817
+ catch { /* ignore */ }
818
+ return {
819
+ tx: receipt.hash,
820
+ token: tokenSymbol,
821
+ amount: amount === 'all' ? ethers.formatUnits(weiAmount, colInfo.decimals) : amount,
822
+ remainingCollateral,
823
+ destination: dest,
824
+ };
825
+ }
826
+ /**
827
+ * Sponsor: transfer collateral to another agent's AgentAccount.
828
+ * (The agent must then supplyCollateral themselves via their own account.)
829
+ */
830
+ async sponsor(target, tokenSymbol, amount) {
831
+ const colInfo = BASE_COLLATERALS[tokenSymbol];
832
+ if (!colInfo)
833
+ throw new AgetherError(`Unknown collateral: ${tokenSymbol}`, 'UNKNOWN_COLLATERAL');
834
+ let targetAddr;
835
+ if (target.address) {
836
+ targetAddr = target.address;
837
+ }
838
+ else if (target.agentId) {
839
+ targetAddr = await this.accountFactory.getAccount(BigInt(target.agentId));
840
+ if (targetAddr === ethers.ZeroAddress)
841
+ throw new AgetherError('Target agent has no account', 'NO_ACCOUNT');
842
+ }
843
+ else {
844
+ throw new AgetherError('Provide agentId or address', 'INVALID_TARGET');
845
+ }
846
+ const weiAmount = ethers.parseUnits(amount, colInfo.decimals);
847
+ const colToken = new Contract(colInfo.address, ERC20_ABI, this.wallet);
848
+ const tx = await colToken.transfer(targetAddr, weiAmount);
849
+ const receipt = await tx.wait();
850
+ return { tx: receipt.hash, targetAccount: targetAddr, targetAgentId: target.agentId };
851
+ }
852
+ // ════════════════════════════════════════════════════════
853
+ // Reputation (AgentReputation contract)
854
+ // ════════════════════════════════════════════════════════
855
+ async getCreditScore() {
856
+ if (!this.agentId)
857
+ throw new AgetherError('agentId not set', 'NO_AGENT_ID');
858
+ return this.agentReputation.getCreditScore(BigInt(this.agentId));
859
+ }
860
+ async getAttestation() {
861
+ if (!this.agentId)
862
+ throw new AgetherError('agentId not set', 'NO_AGENT_ID');
863
+ const att = await this.agentReputation.getAttestation(BigInt(this.agentId));
864
+ return { score: att.score, timestamp: att.timestamp, signer: att.signer };
865
+ }
866
+ async isEligible(minScore = 500n) {
867
+ if (!this.agentId)
868
+ throw new AgetherError('agentId not set', 'NO_AGENT_ID');
869
+ const [eligible, currentScore] = await this.agentReputation.isEligible(BigInt(this.agentId), minScore);
870
+ return { eligible, currentScore };
871
+ }
872
+ async isScoreFresh() {
873
+ if (!this.agentId)
874
+ throw new AgetherError('agentId not set', 'NO_AGENT_ID');
875
+ const [fresh, age] = await this.agentReputation.isScoreFresh(BigInt(this.agentId));
876
+ return { fresh, age };
877
+ }
878
+ // ════════════════════════════════════════════════════════
879
+ // Internal Helpers
880
+ // ════════════════════════════════════════════════════════
881
+ /**
882
+ * Execute a single call via AgentAccount.execute.
883
+ */
884
+ async exec(target, data, value = 0n) {
885
+ const acctAddr = await this.getAccountAddress();
886
+ const account = new Contract(acctAddr, AGENT_ACCOUNT_ABI, this.wallet);
887
+ // Estimate gas with buffer
888
+ let gasLimit;
889
+ try {
890
+ const estimate = await account.execute.estimateGas(target, value, data);
891
+ gasLimit = (estimate * 130n) / 100n; // 30% buffer
892
+ }
893
+ catch {
894
+ gasLimit = 500000n;
895
+ }
896
+ const tx = await account.execute(target, value, data, { gasLimit });
897
+ return tx.wait();
898
+ }
899
+ /**
900
+ * Execute multiple calls via AgentAccount.executeBatch.
901
+ */
902
+ async batch(targets, values, datas) {
903
+ const acctAddr = await this.getAccountAddress();
904
+ const account = new Contract(acctAddr, AGENT_ACCOUNT_ABI, this.wallet);
905
+ // Estimate gas with buffer
906
+ let gasLimit;
907
+ try {
908
+ const estimate = await account.executeBatch.estimateGas(targets, values, datas);
909
+ gasLimit = (estimate * 130n) / 100n;
910
+ }
911
+ catch {
912
+ gasLimit = 800000n;
913
+ }
914
+ const tx = await account.executeBatch(targets, values, datas, { gasLimit });
915
+ return tx.wait();
916
+ }
917
+ /** Convert MorphoMarketParams to Solidity tuple. */
918
+ _toTuple(p) {
919
+ return [p.loanToken, p.collateralToken, p.oracle, p.irm, p.lltv];
920
+ }
921
+ /** Find the first market where the agent has collateral deposited. */
922
+ async _findActiveMarket() {
923
+ const acctAddr = await this.getAccountAddress();
924
+ const markets = await this.getMarkets();
925
+ for (const m of markets) {
926
+ if (!m.collateralAsset || m.collateralAsset.address === ethers.ZeroAddress)
927
+ continue;
928
+ try {
929
+ const pos = await this.morphoBlue.position(m.uniqueKey, acctAddr);
930
+ if (pos.collateral > 0n) {
931
+ return {
932
+ params: {
933
+ loanToken: m.loanAsset.address,
934
+ collateralToken: m.collateralAsset.address,
935
+ oracle: m.oracle,
936
+ irm: m.irm,
937
+ lltv: m.lltv,
938
+ },
939
+ symbol: m.collateralAsset.symbol,
940
+ };
941
+ }
942
+ }
943
+ catch {
944
+ continue;
945
+ }
946
+ }
947
+ // Default to WETH
948
+ const params = await this.findMarketForCollateral('WETH');
949
+ return { params, symbol: 'WETH' };
950
+ }
951
+ }