nebula-ai-plugin-onchain 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +26 -0
  2. package/abis/erc20.json +78 -0
  3. package/abis/factory.json +236 -0
  4. package/abis/gimo-pool.json +53 -0
  5. package/abis/multicall3.json +76 -0
  6. package/abis/quoter.json +193 -0
  7. package/abis/stog.json +58 -0
  8. package/abis/swap-router.json +565 -0
  9. package/abis/weth9.json +65 -0
  10. package/data/tokens.json +94 -0
  11. package/package.json +52 -0
  12. package/src/aave.ts +193 -0
  13. package/src/abis.ts +84 -0
  14. package/src/allowance.ts +77 -0
  15. package/src/analysis.ts +195 -0
  16. package/src/approval.ts +99 -0
  17. package/src/balances.ts +262 -0
  18. package/src/bybit.ts +118 -0
  19. package/src/constants.ts +102 -0
  20. package/src/defillama.ts +127 -0
  21. package/src/guidance.ts +23 -0
  22. package/src/index.ts +139 -0
  23. package/src/mint-block.ts +53 -0
  24. package/src/moe.ts +111 -0
  25. package/src/nansen.ts +85 -0
  26. package/src/policy.ts +213 -0
  27. package/src/quoter.ts +87 -0
  28. package/src/raw-logs.ts +49 -0
  29. package/src/risk.ts +79 -0
  30. package/src/simulate.ts +121 -0
  31. package/src/swap.ts +108 -0
  32. package/src/tokens.ts +232 -0
  33. package/src/tools/aave.ts +425 -0
  34. package/src/tools/account-balance.ts +67 -0
  35. package/src/tools/account.ts +111 -0
  36. package/src/tools/analysis.ts +371 -0
  37. package/src/tools/balance.ts +119 -0
  38. package/src/tools/blockchain.ts +95 -0
  39. package/src/tools/cex.ts +54 -0
  40. package/src/tools/defillama.ts +83 -0
  41. package/src/tools/generic.ts +213 -0
  42. package/src/tools/identity.ts +139 -0
  43. package/src/tools/moe.ts +245 -0
  44. package/src/tools/nansen.ts +71 -0
  45. package/src/tools/policy-show.ts +74 -0
  46. package/src/tools/risk.ts +134 -0
  47. package/src/tools/simulate-tx.ts +98 -0
  48. package/src/tools/swap-best.ts +218 -0
  49. package/src/tools/swap.ts +253 -0
  50. package/src/tools/tokens-info.ts +49 -0
  51. package/src/tools/transfer.ts +164 -0
  52. package/src/tools/wrap.ts +183 -0
  53. package/src/types.ts +53 -0
  54. package/src/wait-receipt.ts +34 -0
package/src/tokens.ts ADDED
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Token resolver + cache. Resolution priority:
3
+ * cache → vendored AGNI list → on-chain ERC-20 reads (Multicall3 batch).
4
+ *
5
+ * The cache lives at <agentDir>/onchain/tokens-cache.json and is keyed by
6
+ * lowercase address. We cache-write whenever an on-chain read succeeds so
7
+ * subsequent runs skip the round-trip.
8
+ */
9
+
10
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
11
+ import { dirname, join } from 'node:path'
12
+ import {
13
+ type Address,
14
+ type PublicClient,
15
+ decodeFunctionResult,
16
+ encodeFunctionData,
17
+ getAddress,
18
+ } from 'viem'
19
+ import agniTokenList from '../data/tokens.json' with { type: 'json' }
20
+ import { ERC20_ABI, MULTICALL3_ABI } from './abis'
21
+ import { MULTICALL3, NATIVE_ALIASES } from './constants'
22
+ import type { TokenInfo } from './types'
23
+
24
+ interface AgniTokenListEntry {
25
+ address: string
26
+ symbol: string
27
+ name: string
28
+ decimals: number
29
+ chainId: number
30
+ }
31
+
32
+ interface AgniTokenList {
33
+ tokens: AgniTokenListEntry[]
34
+ }
35
+
36
+ const TYPED_LIST = agniTokenList as AgniTokenList
37
+
38
+ const NATIVE: TokenInfo = {
39
+ address: '0x0000000000000000000000000000000000000000' as Address,
40
+ symbol: 'Mantle',
41
+ name: 'Mantle',
42
+ decimals: 18,
43
+ source: 'native',
44
+ }
45
+
46
+ export function isNativeToken(input: string | undefined): boolean {
47
+ if (!input) return true
48
+ return NATIVE_ALIASES.has(input.trim())
49
+ }
50
+
51
+ export function nativeTokenInfo(): TokenInfo {
52
+ return { ...NATIVE }
53
+ }
54
+
55
+ function tokensCachePath(agentDir: string): string {
56
+ return join(agentDir, 'onchain', 'tokens-cache.json')
57
+ }
58
+
59
+ interface CacheFile {
60
+ version: 1
61
+ byAddress: Record<string, TokenInfo>
62
+ }
63
+
64
+ function emptyCache(): CacheFile {
65
+ return { version: 1, byAddress: {} }
66
+ }
67
+
68
+ export function loadTokenCache(agentDir: string): CacheFile {
69
+ const path = tokensCachePath(agentDir)
70
+ if (!existsSync(path)) return emptyCache()
71
+ try {
72
+ const raw = readFileSync(path, 'utf8')
73
+ const parsed = JSON.parse(raw) as CacheFile
74
+ if (parsed?.version === 1 && parsed.byAddress) return parsed
75
+ return emptyCache()
76
+ } catch {
77
+ return emptyCache()
78
+ }
79
+ }
80
+
81
+ export function saveTokenCache(agentDir: string, cache: CacheFile): void {
82
+ const path = tokensCachePath(agentDir)
83
+ mkdirSync(dirname(path), { recursive: true })
84
+ writeFileSync(path, JSON.stringify(cache, null, 2))
85
+ }
86
+
87
+ /**
88
+ * Best-effort lookup. Returns the token info if found in cache OR vendored
89
+ * list. On miss, callers should call `fetchOnchainErc20Info` to resolve via
90
+ * RPC and cache-write through.
91
+ */
92
+ export function lookupFromList(symbolOrAddress: string, cache: CacheFile): TokenInfo | null {
93
+ const trimmed = symbolOrAddress.trim()
94
+ // Address path
95
+ if (trimmed.startsWith('0x') && trimmed.length === 42) {
96
+ const lc = trimmed.toLowerCase()
97
+ const cached = cache.byAddress[lc]
98
+ if (cached) return cached
99
+ const fromList = TYPED_LIST.tokens.find(t => t.address.toLowerCase() === lc)
100
+ if (fromList) return tokenFromListEntry(fromList)
101
+ return null
102
+ }
103
+ // Symbol path: case-insensitive match
104
+ const upper = trimmed.toUpperCase()
105
+ const fromCache = Object.values(cache.byAddress).find(t => t.symbol.toUpperCase() === upper)
106
+ if (fromCache) return fromCache
107
+ const fromList = TYPED_LIST.tokens.find(t => t.symbol.toUpperCase() === upper)
108
+ if (fromList) return tokenFromListEntry(fromList)
109
+ return null
110
+ }
111
+
112
+ function tokenFromListEntry(e: AgniTokenListEntry): TokenInfo {
113
+ return {
114
+ address: getAddress(e.address) as Address,
115
+ symbol: e.symbol,
116
+ name: e.name,
117
+ decimals: e.decimals,
118
+ source: 'list',
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Read name/symbol/decimals for an ERC-20 via Multicall3 (single round-trip).
124
+ * Tolerates contracts that don't implement `name` (returns symbol as name);
125
+ * decimals + symbol are required. Returns null if the address isn't an ERC-20.
126
+ */
127
+ export async function fetchOnchainErc20Info(
128
+ client: PublicClient,
129
+ address: Address,
130
+ ): Promise<TokenInfo | null> {
131
+ const calls = [
132
+ {
133
+ target: address,
134
+ allowFailure: true,
135
+ callData: encodeFunctionData({ abi: ERC20_ABI, functionName: 'name' }),
136
+ },
137
+ {
138
+ target: address,
139
+ allowFailure: true,
140
+ callData: encodeFunctionData({ abi: ERC20_ABI, functionName: 'symbol' }),
141
+ },
142
+ {
143
+ target: address,
144
+ allowFailure: true,
145
+ callData: encodeFunctionData({ abi: ERC20_ABI, functionName: 'decimals' }),
146
+ },
147
+ ] as const
148
+ let results: ReadonlyArray<{ success: boolean; returnData: `0x${string}` }>
149
+ try {
150
+ results = (await client.readContract({
151
+ address: MULTICALL3,
152
+ abi: MULTICALL3_ABI,
153
+ functionName: 'aggregate3',
154
+ args: [
155
+ calls as unknown as Array<{
156
+ target: Address
157
+ allowFailure: boolean
158
+ callData: `0x${string}`
159
+ }>,
160
+ ],
161
+ })) as ReadonlyArray<{ success: boolean; returnData: `0x${string}` }>
162
+ } catch {
163
+ return null
164
+ }
165
+ const decode = <T>(idx: number, fnName: 'name' | 'symbol' | 'decimals'): T | null => {
166
+ const r = results[idx]
167
+ if (!r?.success) return null
168
+ try {
169
+ return decodeFunctionResult({
170
+ abi: ERC20_ABI,
171
+ functionName: fnName,
172
+ data: r.returnData,
173
+ }) as T
174
+ } catch {
175
+ return null
176
+ }
177
+ }
178
+ const symbol = decode<string>(1, 'symbol')
179
+ const decimals = decode<number>(2, 'decimals')
180
+ if (symbol == null || decimals == null) return null
181
+ const name = decode<string>(0, 'name') ?? symbol
182
+ return {
183
+ address: getAddress(address) as Address,
184
+ symbol,
185
+ name,
186
+ decimals: Number(decimals),
187
+ source: 'onchain',
188
+ }
189
+ }
190
+
191
+ /** Resolve, with cache-write-through on on-chain hits. */
192
+ export async function resolveToken(opts: {
193
+ client: PublicClient
194
+ agentDir: string
195
+ input: string
196
+ }): Promise<TokenInfo | null> {
197
+ const { client, agentDir, input } = opts
198
+ if (isNativeToken(input)) return nativeTokenInfo()
199
+ const cache = loadTokenCache(agentDir)
200
+ const local = lookupFromList(input, cache)
201
+ if (local) return local
202
+ // Fall through to on-chain only for address inputs
203
+ if (input.startsWith('0x') && input.length === 42) {
204
+ const fetched = await fetchOnchainErc20Info(client, input as Address)
205
+ if (fetched) {
206
+ const updated: CacheFile = {
207
+ version: 1,
208
+ byAddress: { ...cache.byAddress, [fetched.address.toLowerCase()]: fetched },
209
+ }
210
+ saveTokenCache(agentDir, updated)
211
+ return fetched
212
+ }
213
+ }
214
+ return null
215
+ }
216
+
217
+ /** Persist a known-good token info (e.g. from balance discovery) to cache. */
218
+ export function rememberToken(agentDir: string, token: TokenInfo): void {
219
+ const cache = loadTokenCache(agentDir)
220
+ cache.byAddress[token.address.toLowerCase()] = token
221
+ saveTokenCache(agentDir, cache)
222
+ }
223
+
224
+ /** Bulk variant for after a discovery scan. */
225
+ export function rememberTokens(agentDir: string, tokens: TokenInfo[]): void {
226
+ if (tokens.length === 0) return
227
+ const cache = loadTokenCache(agentDir)
228
+ for (const t of tokens) {
229
+ cache.byAddress[t.address.toLowerCase()] = t
230
+ }
231
+ saveTokenCache(agentDir, cache)
232
+ }
@@ -0,0 +1,425 @@
1
+ /**
2
+ * Aave V3 lending tools on Mantle: aave.position (read), aave.supply, aave.withdraw.
3
+ * Writes run the standard pipeline: policy -> approve (supply) -> simulate -> execute -> receipt.
4
+ */
5
+ import type { ToolDef } from 'nebula-ai-core'
6
+ import { getGasPriceWithFloor } from 'nebula-ai-core'
7
+ import { type Abi, type Address, parseUnits } from 'viem'
8
+ import { z } from 'zod'
9
+ import {
10
+ AAVE_MAX_WITHDRAW,
11
+ AAVE_V3_POOL_ABI,
12
+ AAVE_VARIABLE_RATE,
13
+ formatBaseUsd,
14
+ formatHealthFactor,
15
+ readAaveAccount,
16
+ readAaveMarkets,
17
+ } from '../aave'
18
+ import { ensureAllowance } from '../allowance'
19
+ import { AAVE_POOL_BY_NETWORK } from '../constants'
20
+ import { evaluatePolicy } from '../policy'
21
+ import { simulateContractWrite } from '../simulate'
22
+ import { resolveToken } from '../tokens'
23
+ import type { OnchainRuntimeContext } from '../types'
24
+ import { waitForReceipt } from '../wait-receipt'
25
+
26
+ function requirePool(ctx: OnchainRuntimeContext): Address {
27
+ const pool = AAVE_POOL_BY_NETWORK[ctx.network]
28
+ if (!pool) throw new Error(`Aave V3 is not deployed on ${ctx.network}`)
29
+ return pool
30
+ }
31
+
32
+ const PositionSchema = z.object({})
33
+ type PositionArgs = z.infer<typeof PositionSchema>
34
+
35
+ export function makeAavePosition(ctx: OnchainRuntimeContext): ToolDef<PositionArgs> {
36
+ return {
37
+ name: 'aave.position',
38
+ description:
39
+ 'Read your Aave V3 position on Mantle: total collateral, debt, available borrows, and health factor.',
40
+ searchHint: 'aave lending position health factor collateral debt borrow liquidation',
41
+ schema: PositionSchema,
42
+ handler: async () => {
43
+ try {
44
+ const pool = requirePool(ctx)
45
+ const a = await readAaveAccount(ctx.publicClient, pool, ctx.agentEoa)
46
+ return {
47
+ ok: true,
48
+ data: {
49
+ totalCollateral: formatBaseUsd(a.totalCollateralBase),
50
+ totalDebt: formatBaseUsd(a.totalDebtBase),
51
+ availableBorrows: formatBaseUsd(a.availableBorrowsBase),
52
+ ltvBps: a.ltvBps.toString(),
53
+ liquidationThresholdBps: a.liquidationThresholdBps.toString(),
54
+ healthFactor: formatHealthFactor(a.healthFactorRaw),
55
+ },
56
+ }
57
+ } catch (e) {
58
+ return { ok: false, error: (e as Error).message.slice(0, 240) }
59
+ }
60
+ },
61
+ }
62
+ }
63
+
64
+ const MarketsSchema = z.object({})
65
+ type MarketsArgs = z.infer<typeof MarketsSchema>
66
+
67
+ export function makeAaveMarkets(ctx: OnchainRuntimeContext): ToolDef<MarketsArgs> {
68
+ return {
69
+ name: 'aave.markets',
70
+ description:
71
+ 'List every Aave V3 reserve on Mantle with its live supply APR and variable-borrow APR. Read-only. Use it to compare lending/borrowing rates before aave.supply or aave.borrow ("what does it cost to borrow USDC", "best supply rate").',
72
+ searchHint: 'aave markets reserves rates apr supply borrow interest lending cost which asset',
73
+ schema: MarketsSchema,
74
+ handler: async () => {
75
+ try {
76
+ const pool = requirePool(ctx)
77
+ const markets = await readAaveMarkets(ctx.publicClient, pool)
78
+ return {
79
+ ok: true,
80
+ data: {
81
+ count: markets.length,
82
+ markets: markets.map(m => ({
83
+ symbol: m.symbol,
84
+ address: m.address,
85
+ supplyApr: `${m.supplyAprPct.toFixed(2)}%`,
86
+ variableBorrowApr: `${m.variableBorrowAprPct.toFixed(2)}%`,
87
+ })),
88
+ },
89
+ }
90
+ } catch (e) {
91
+ return { ok: false, error: (e as Error).message.slice(0, 240) }
92
+ }
93
+ },
94
+ }
95
+ }
96
+
97
+ const SupplySchema = z.object({
98
+ token: z.string().min(1).describe('ERC-20 symbol or 0x address to supply (e.g. WMNT, USDC).'),
99
+ amount: z.string().min(1).describe('Amount in token units (e.g. "10").'),
100
+ })
101
+ type SupplyArgs = z.infer<typeof SupplySchema>
102
+
103
+ export function makeAaveSupply(ctx: OnchainRuntimeContext): ToolDef<SupplyArgs> {
104
+ return {
105
+ name: 'aave.supply',
106
+ description:
107
+ 'Supply an ERC-20 to Aave V3 on Mantle (earns yield, becomes collateral). Auto-approves the Pool; policy-checked + simulated before execution.',
108
+ searchHint: 'aave supply deposit lend earn yield collateral',
109
+ schema: SupplySchema,
110
+ handler: async args => {
111
+ try {
112
+ const pool = requirePool(ctx)
113
+ const account = ctx.walletClient.account
114
+ if (!account) return { ok: false, error: 'walletClient has no account; cannot supply' }
115
+ const token = await resolveToken({
116
+ client: ctx.publicClient,
117
+ agentDir: ctx.agentDir,
118
+ input: args.token,
119
+ })
120
+ if (!token) return { ok: false, error: `unknown token: ${args.token}` }
121
+ const amount = parseUnits(args.amount, token.decimals)
122
+ if (ctx.policy) {
123
+ const verdict = evaluatePolicy(
124
+ { kind: 'transfer', asset: token.address, amountRaw: amount },
125
+ ctx.policy,
126
+ )
127
+ if (!verdict.allowed) {
128
+ return { ok: false, error: `policy blocked: ${verdict.violations.join('; ')}` }
129
+ }
130
+ }
131
+ const allow = await ensureAllowance({
132
+ publicClient: ctx.publicClient,
133
+ walletClient: ctx.walletClient,
134
+ token: token.address,
135
+ owner: ctx.agentEoa,
136
+ spender: pool,
137
+ amount,
138
+ })
139
+ const sim = await simulateContractWrite(ctx.publicClient, {
140
+ account: account.address,
141
+ address: pool,
142
+ abi: AAVE_V3_POOL_ABI as Abi,
143
+ functionName: 'supply',
144
+ args: [token.address, amount, ctx.agentEoa, 0],
145
+ })
146
+ if (!sim.ok) return { ok: false, error: `pre-flight simulation reverted: ${sim.reason}` }
147
+ const gasPrice = await getGasPriceWithFloor(ctx.publicClient)
148
+ const txHash = await ctx.walletClient.writeContract({
149
+ address: pool,
150
+ abi: AAVE_V3_POOL_ABI,
151
+ functionName: 'supply',
152
+ args: [token.address, amount, ctx.agentEoa, 0],
153
+ chain: ctx.walletClient.chain,
154
+ account,
155
+ gasPrice,
156
+ })
157
+ const receipt = await waitForReceipt(ctx.publicClient, txHash)
158
+ return {
159
+ ok: true,
160
+ data: {
161
+ txHash,
162
+ blockNumber: Number(receipt.blockNumber),
163
+ token: token.symbol,
164
+ amount: args.amount,
165
+ status: receipt.status === 'success' ? 'success' : 'reverted',
166
+ simGasEstimate: sim.gas.toString(),
167
+ policyEnforced: ctx.policy != null,
168
+ ...(allow.txHash ? { approveTxHash: allow.txHash } : {}),
169
+ },
170
+ }
171
+ } catch (e) {
172
+ return { ok: false, error: (e as Error).message.slice(0, 240) }
173
+ }
174
+ },
175
+ }
176
+ }
177
+
178
+ /** Read the post-tx health factor so the receipt surfaces the risk impact. */
179
+ async function healthFactorAfter(ctx: OnchainRuntimeContext, pool: Address): Promise<string> {
180
+ try {
181
+ const a = await readAaveAccount(ctx.publicClient, pool, ctx.agentEoa)
182
+ return formatHealthFactor(a.healthFactorRaw)
183
+ } catch {
184
+ return 'unknown'
185
+ }
186
+ }
187
+
188
+ const BorrowSchema = z.object({
189
+ token: z.string().min(1).describe('ERC-20 symbol or 0x address to borrow (e.g. USDC, USDT).'),
190
+ amount: z.string().min(1).describe('Amount to borrow in token units (e.g. "100").'),
191
+ })
192
+ type BorrowArgs = z.infer<typeof BorrowSchema>
193
+
194
+ export function makeAaveBorrow(ctx: OnchainRuntimeContext): ToolDef<BorrowArgs> {
195
+ return {
196
+ name: 'aave.borrow',
197
+ description:
198
+ 'Borrow an ERC-20 from Aave V3 on Mantle against your supplied collateral (variable rate). Policy-checked + simulated; Aave reverts a borrow beyond your borrowing power, so the pre-flight simulation catches an over-borrow before any tx. The receipt reports the resulting health factor — the lower it is, the closer to liquidation. Borrowing is leverage: keep it bounded.',
199
+ searchHint: 'aave borrow loan leverage debt against collateral variable rate credit',
200
+ schema: BorrowSchema,
201
+ handler: async args => {
202
+ try {
203
+ const pool = requirePool(ctx)
204
+ const account = ctx.walletClient.account
205
+ if (!account) return { ok: false, error: 'walletClient has no account; cannot borrow' }
206
+ const token = await resolveToken({
207
+ client: ctx.publicClient,
208
+ agentDir: ctx.agentDir,
209
+ input: args.token,
210
+ })
211
+ if (!token) return { ok: false, error: `unknown token: ${args.token}` }
212
+ const amount = parseUnits(args.amount, token.decimals)
213
+ if (ctx.policy) {
214
+ const verdict = evaluatePolicy(
215
+ { kind: 'transfer', asset: token.address, amountRaw: amount },
216
+ ctx.policy,
217
+ )
218
+ if (!verdict.allowed) {
219
+ return { ok: false, error: `policy blocked: ${verdict.violations.join('; ')}` }
220
+ }
221
+ }
222
+ const borrowArgs = [token.address, amount, AAVE_VARIABLE_RATE, 0, ctx.agentEoa] as const
223
+ const sim = await simulateContractWrite(ctx.publicClient, {
224
+ account: account.address,
225
+ address: pool,
226
+ abi: AAVE_V3_POOL_ABI as Abi,
227
+ functionName: 'borrow',
228
+ args: borrowArgs,
229
+ })
230
+ if (!sim.ok) return { ok: false, error: `pre-flight simulation reverted: ${sim.reason}` }
231
+ const gasPrice = await getGasPriceWithFloor(ctx.publicClient)
232
+ const txHash = await ctx.walletClient.writeContract({
233
+ address: pool,
234
+ abi: AAVE_V3_POOL_ABI,
235
+ functionName: 'borrow',
236
+ args: borrowArgs,
237
+ chain: ctx.walletClient.chain,
238
+ account,
239
+ gasPrice,
240
+ })
241
+ const receipt = await waitForReceipt(ctx.publicClient, txHash)
242
+ return {
243
+ ok: true,
244
+ data: {
245
+ txHash,
246
+ blockNumber: Number(receipt.blockNumber),
247
+ token: token.symbol,
248
+ amount: args.amount,
249
+ rateMode: 'variable',
250
+ status: receipt.status === 'success' ? 'success' : 'reverted',
251
+ healthFactorAfter: await healthFactorAfter(ctx, pool),
252
+ simGasEstimate: sim.gas.toString(),
253
+ policyEnforced: ctx.policy != null,
254
+ },
255
+ }
256
+ } catch (e) {
257
+ return { ok: false, error: (e as Error).message.slice(0, 240) }
258
+ }
259
+ },
260
+ }
261
+ }
262
+
263
+ const RepaySchema = z.object({
264
+ token: z.string().min(1).describe('ERC-20 symbol or 0x address of the debt to repay.'),
265
+ amount: z.string().min(1).describe('Amount in token units, or "max" to repay the full debt.'),
266
+ })
267
+ type RepayArgs = z.infer<typeof RepaySchema>
268
+
269
+ export function makeAaveRepay(ctx: OnchainRuntimeContext): ToolDef<RepayArgs> {
270
+ return {
271
+ name: 'aave.repay',
272
+ description:
273
+ 'Repay an Aave V3 variable-rate debt on Mantle. Use "max" to clear the full debt. Auto-approves the Pool to pull the repayment; policy-checked + simulated. The receipt reports the improved health factor.',
274
+ searchHint: 'aave repay payback debt loan close deleverage',
275
+ schema: RepaySchema,
276
+ handler: async args => {
277
+ try {
278
+ const pool = requirePool(ctx)
279
+ const account = ctx.walletClient.account
280
+ if (!account) return { ok: false, error: 'walletClient has no account; cannot repay' }
281
+ const token = await resolveToken({
282
+ client: ctx.publicClient,
283
+ agentDir: ctx.agentDir,
284
+ input: args.token,
285
+ })
286
+ if (!token) return { ok: false, error: `unknown token: ${args.token}` }
287
+ const isMax = args.amount.toLowerCase() === 'max'
288
+ const amount = isMax ? AAVE_MAX_WITHDRAW : parseUnits(args.amount, token.decimals)
289
+ if (ctx.policy && !isMax) {
290
+ const verdict = evaluatePolicy(
291
+ { kind: 'transfer', asset: token.address, amountRaw: amount },
292
+ ctx.policy,
293
+ )
294
+ if (!verdict.allowed) {
295
+ return { ok: false, error: `policy blocked: ${verdict.violations.join('; ')}` }
296
+ }
297
+ }
298
+ // Approve enough to cover repayment. For "max" we can't know the exact
299
+ // debt cheaply, so approve the uint256 max (Aave pulls only what's owed).
300
+ const allow = await ensureAllowance({
301
+ publicClient: ctx.publicClient,
302
+ walletClient: ctx.walletClient,
303
+ token: token.address,
304
+ owner: ctx.agentEoa,
305
+ spender: pool,
306
+ amount,
307
+ })
308
+ const repayArgs = [token.address, amount, AAVE_VARIABLE_RATE, ctx.agentEoa] as const
309
+ const sim = await simulateContractWrite(ctx.publicClient, {
310
+ account: account.address,
311
+ address: pool,
312
+ abi: AAVE_V3_POOL_ABI as Abi,
313
+ functionName: 'repay',
314
+ args: repayArgs,
315
+ })
316
+ if (!sim.ok) return { ok: false, error: `pre-flight simulation reverted: ${sim.reason}` }
317
+ const gasPrice = await getGasPriceWithFloor(ctx.publicClient)
318
+ const txHash = await ctx.walletClient.writeContract({
319
+ address: pool,
320
+ abi: AAVE_V3_POOL_ABI,
321
+ functionName: 'repay',
322
+ args: repayArgs,
323
+ chain: ctx.walletClient.chain,
324
+ account,
325
+ gasPrice,
326
+ })
327
+ const receipt = await waitForReceipt(ctx.publicClient, txHash)
328
+ return {
329
+ ok: true,
330
+ data: {
331
+ txHash,
332
+ blockNumber: Number(receipt.blockNumber),
333
+ token: token.symbol,
334
+ amount: args.amount,
335
+ status: receipt.status === 'success' ? 'success' : 'reverted',
336
+ healthFactorAfter: await healthFactorAfter(ctx, pool),
337
+ simGasEstimate: sim.gas.toString(),
338
+ policyEnforced: ctx.policy != null,
339
+ ...(allow.txHash ? { approveTxHash: allow.txHash } : {}),
340
+ },
341
+ }
342
+ } catch (e) {
343
+ return { ok: false, error: (e as Error).message.slice(0, 240) }
344
+ }
345
+ },
346
+ }
347
+ }
348
+
349
+ const WithdrawSchema = z.object({
350
+ token: z.string().min(1).describe('ERC-20 symbol or 0x address to withdraw.'),
351
+ amount: z
352
+ .string()
353
+ .min(1)
354
+ .describe('Amount in token units, or "max" for the full supplied balance.'),
355
+ })
356
+ type WithdrawArgs = z.infer<typeof WithdrawSchema>
357
+
358
+ export function makeAaveWithdraw(ctx: OnchainRuntimeContext): ToolDef<WithdrawArgs> {
359
+ return {
360
+ name: 'aave.withdraw',
361
+ description:
362
+ 'Withdraw a supplied ERC-20 from Aave V3 on Mantle. Use "max" for the full balance. Aave reverts a withdraw that would breach your health factor; the pre-flight simulation surfaces that before any tx.',
363
+ searchHint: 'aave withdraw redeem unwind collateral',
364
+ schema: WithdrawSchema,
365
+ handler: async args => {
366
+ try {
367
+ const pool = requirePool(ctx)
368
+ const account = ctx.walletClient.account
369
+ if (!account) return { ok: false, error: 'walletClient has no account; cannot withdraw' }
370
+ const token = await resolveToken({
371
+ client: ctx.publicClient,
372
+ agentDir: ctx.agentDir,
373
+ input: args.token,
374
+ })
375
+ if (!token) return { ok: false, error: `unknown token: ${args.token}` }
376
+ const amount =
377
+ args.amount.toLowerCase() === 'max'
378
+ ? AAVE_MAX_WITHDRAW
379
+ : parseUnits(args.amount, token.decimals)
380
+ if (ctx.policy && amount !== AAVE_MAX_WITHDRAW) {
381
+ const verdict = evaluatePolicy(
382
+ { kind: 'transfer', asset: token.address, amountRaw: amount },
383
+ ctx.policy,
384
+ )
385
+ if (!verdict.allowed) {
386
+ return { ok: false, error: `policy blocked: ${verdict.violations.join('; ')}` }
387
+ }
388
+ }
389
+ const sim = await simulateContractWrite(ctx.publicClient, {
390
+ account: account.address,
391
+ address: pool,
392
+ abi: AAVE_V3_POOL_ABI as Abi,
393
+ functionName: 'withdraw',
394
+ args: [token.address, amount, ctx.agentEoa],
395
+ })
396
+ if (!sim.ok) return { ok: false, error: `pre-flight simulation reverted: ${sim.reason}` }
397
+ const gasPrice = await getGasPriceWithFloor(ctx.publicClient)
398
+ const txHash = await ctx.walletClient.writeContract({
399
+ address: pool,
400
+ abi: AAVE_V3_POOL_ABI,
401
+ functionName: 'withdraw',
402
+ args: [token.address, amount, ctx.agentEoa],
403
+ chain: ctx.walletClient.chain,
404
+ account,
405
+ gasPrice,
406
+ })
407
+ const receipt = await waitForReceipt(ctx.publicClient, txHash)
408
+ return {
409
+ ok: true,
410
+ data: {
411
+ txHash,
412
+ blockNumber: Number(receipt.blockNumber),
413
+ token: token.symbol,
414
+ amount: args.amount,
415
+ status: receipt.status === 'success' ? 'success' : 'reverted',
416
+ simGasEstimate: sim.gas.toString(),
417
+ policyEnforced: ctx.policy != null,
418
+ },
419
+ }
420
+ } catch (e) {
421
+ return { ok: false, error: (e as Error).message.slice(0, 240) }
422
+ }
423
+ },
424
+ }
425
+ }