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.
- package/README.md +26 -0
- package/abis/erc20.json +78 -0
- package/abis/factory.json +236 -0
- package/abis/gimo-pool.json +53 -0
- package/abis/multicall3.json +76 -0
- package/abis/quoter.json +193 -0
- package/abis/stog.json +58 -0
- package/abis/swap-router.json +565 -0
- package/abis/weth9.json +65 -0
- package/data/tokens.json +94 -0
- package/package.json +52 -0
- package/src/aave.ts +193 -0
- package/src/abis.ts +84 -0
- package/src/allowance.ts +77 -0
- package/src/analysis.ts +195 -0
- package/src/approval.ts +99 -0
- package/src/balances.ts +262 -0
- package/src/bybit.ts +118 -0
- package/src/constants.ts +102 -0
- package/src/defillama.ts +127 -0
- package/src/guidance.ts +23 -0
- package/src/index.ts +139 -0
- package/src/mint-block.ts +53 -0
- package/src/moe.ts +111 -0
- package/src/nansen.ts +85 -0
- package/src/policy.ts +213 -0
- package/src/quoter.ts +87 -0
- package/src/raw-logs.ts +49 -0
- package/src/risk.ts +79 -0
- package/src/simulate.ts +121 -0
- package/src/swap.ts +108 -0
- package/src/tokens.ts +232 -0
- package/src/tools/aave.ts +425 -0
- package/src/tools/account-balance.ts +67 -0
- package/src/tools/account.ts +111 -0
- package/src/tools/analysis.ts +371 -0
- package/src/tools/balance.ts +119 -0
- package/src/tools/blockchain.ts +95 -0
- package/src/tools/cex.ts +54 -0
- package/src/tools/defillama.ts +83 -0
- package/src/tools/generic.ts +213 -0
- package/src/tools/identity.ts +139 -0
- package/src/tools/moe.ts +245 -0
- package/src/tools/nansen.ts +71 -0
- package/src/tools/policy-show.ts +74 -0
- package/src/tools/risk.ts +134 -0
- package/src/tools/simulate-tx.ts +98 -0
- package/src/tools/swap-best.ts +218 -0
- package/src/tools/swap.ts +253 -0
- package/src/tools/tokens-info.ts +49 -0
- package/src/tools/transfer.ts +164 -0
- package/src/tools/wrap.ts +183 -0
- package/src/types.ts +53 -0
- 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
|
+
}
|