@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.
- package/dist/cli.js +134 -8
- package/dist/index.d.mts +35 -5
- package/dist/index.d.ts +35 -5
- package/dist/index.js +134 -11
- package/dist/index.mjs +132 -10
- package/package.json +1 -1
- package/dist/cli.d.ts +0 -29
- package/dist/cli.d.ts.map +0 -1
- package/dist/clients/AgentIdentityClient.d.ts +0 -200
- package/dist/clients/AgentIdentityClient.d.ts.map +0 -1
- package/dist/clients/AgentIdentityClient.js +0 -351
- package/dist/clients/AgetherClient.d.ts +0 -242
- package/dist/clients/AgetherClient.d.ts.map +0 -1
- package/dist/clients/AgetherClient.js +0 -736
- package/dist/clients/MorphoClient.d.ts +0 -572
- package/dist/clients/MorphoClient.d.ts.map +0 -1
- package/dist/clients/MorphoClient.js +0 -1974
- package/dist/clients/ScoringClient.d.ts +0 -103
- package/dist/clients/ScoringClient.d.ts.map +0 -1
- package/dist/clients/ScoringClient.js +0 -112
- package/dist/clients/X402Client.d.ts +0 -198
- package/dist/clients/X402Client.d.ts.map +0 -1
- package/dist/clients/X402Client.js +0 -438
- package/dist/index.d.ts.map +0 -1
- package/dist/types/index.d.ts +0 -132
- package/dist/types/index.d.ts.map +0 -1
- package/dist/types/index.js +0 -46
- package/dist/utils/abis.d.ts +0 -29
- package/dist/utils/abis.d.ts.map +0 -1
- package/dist/utils/abis.js +0 -138
- package/dist/utils/config.d.ts +0 -36
- package/dist/utils/config.d.ts.map +0 -1
- package/dist/utils/config.js +0 -168
- package/dist/utils/format.d.ts +0 -44
- package/dist/utils/format.d.ts.map +0 -1
- package/dist/utils/format.js +0 -75
|
@@ -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
|
-
}
|