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/tools/moe.ts
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `moe.quote` + `moe.swap` — Merchant Moe Liquidity Book swaps.
|
|
3
|
+
*
|
|
4
|
+
* A second on-chain DEX venue alongside Agni. The brain can quote both
|
|
5
|
+
* (`swap.quote` for Agni, `moe.quote` for Merchant Moe) and execute on whichever
|
|
6
|
+
* is better — agent-driven best execution. `moe.swap` runs the same fund-control
|
|
7
|
+
* pipeline as every other write: policy -> simulate -> (approval) -> execute.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ToolDef } from 'nebula-ai-core'
|
|
11
|
+
import { getGasPriceWithFloor } from 'nebula-ai-core'
|
|
12
|
+
import { type Address, formatUnits, parseUnits } from 'viem'
|
|
13
|
+
import { z } from 'zod'
|
|
14
|
+
import { ensureAllowance } from '../allowance'
|
|
15
|
+
import {
|
|
16
|
+
AGNI_BY_NETWORK,
|
|
17
|
+
DEFAULT_DEADLINE_SECS,
|
|
18
|
+
DEFAULT_SLIPPAGE_BPS,
|
|
19
|
+
MOE_LB_BY_NETWORK,
|
|
20
|
+
requireMainnet,
|
|
21
|
+
} from '../constants'
|
|
22
|
+
import { encodeMoeSwap, quoteMoe } from '../moe'
|
|
23
|
+
import { evaluatePolicy } from '../policy'
|
|
24
|
+
import { simulateRawTx } from '../simulate'
|
|
25
|
+
import { isNativeToken, resolveToken } from '../tokens'
|
|
26
|
+
import type { OnchainRuntimeContext, TokenInfo } from '../types'
|
|
27
|
+
import { waitForReceipt } from '../wait-receipt'
|
|
28
|
+
|
|
29
|
+
/** WMNT is the wrapped-native used as the path endpoint for native legs. */
|
|
30
|
+
function wmnt(ctx: OnchainRuntimeContext): Address {
|
|
31
|
+
return AGNI_BY_NETWORK[ctx.network]!.weth9 as Address
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function resolveOrNative(
|
|
35
|
+
ctx: OnchainRuntimeContext,
|
|
36
|
+
input: string,
|
|
37
|
+
): Promise<{ token: TokenInfo; isNative: boolean } | null> {
|
|
38
|
+
if (isNativeToken(input)) {
|
|
39
|
+
requireMainnet(ctx.network)
|
|
40
|
+
return {
|
|
41
|
+
token: {
|
|
42
|
+
address: wmnt(ctx),
|
|
43
|
+
symbol: 'WMNT',
|
|
44
|
+
name: 'Wrapped Mantle',
|
|
45
|
+
decimals: 18,
|
|
46
|
+
source: 'list',
|
|
47
|
+
},
|
|
48
|
+
isNative: true,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const t = await resolveToken({ client: ctx.publicClient, agentDir: ctx.agentDir, input })
|
|
52
|
+
if (!t) return null
|
|
53
|
+
return { token: t, isNative: false }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const QuoteSchema = z.object({
|
|
57
|
+
tokenIn: z.string().describe('Input token: symbol, 0x address, or "MNT"/"native".'),
|
|
58
|
+
tokenOut: z.string().describe('Output token: symbol, 0x address, or "MNT"/"native".'),
|
|
59
|
+
amountIn: z.string().describe('Input amount in tokenIn units (e.g. "1.5").'),
|
|
60
|
+
slippageBps: z
|
|
61
|
+
.number()
|
|
62
|
+
.int()
|
|
63
|
+
.nonnegative()
|
|
64
|
+
.max(10000)
|
|
65
|
+
.optional()
|
|
66
|
+
.describe(`Slippage tolerance in basis points (default ${DEFAULT_SLIPPAGE_BPS} = 0.5%).`),
|
|
67
|
+
})
|
|
68
|
+
type QuoteArgs = z.infer<typeof QuoteSchema>
|
|
69
|
+
|
|
70
|
+
export function makeMoeQuote(ctx: OnchainRuntimeContext): ToolDef<QuoteArgs> {
|
|
71
|
+
return {
|
|
72
|
+
name: 'moe.quote',
|
|
73
|
+
description:
|
|
74
|
+
'Preview a swap on Merchant Moe (Liquidity Book). Returns the best LB route + amountOut + amountOutMin (after slippage). Read-only. Quote both moe.quote and swap.quote (Agni) to pick the best venue.',
|
|
75
|
+
searchHint: 'moe merchant moe quote swap price preview liquidity book lb dex best execution',
|
|
76
|
+
schema: QuoteSchema,
|
|
77
|
+
handler: async (args: QuoteArgs) => {
|
|
78
|
+
try {
|
|
79
|
+
requireMainnet(ctx.network)
|
|
80
|
+
const moe = MOE_LB_BY_NETWORK[ctx.network]!
|
|
81
|
+
const tin = await resolveOrNative(ctx, args.tokenIn)
|
|
82
|
+
const tout = await resolveOrNative(ctx, args.tokenOut)
|
|
83
|
+
if (!tin) return { ok: false, error: `unknown tokenIn: ${args.tokenIn}` }
|
|
84
|
+
if (!tout) return { ok: false, error: `unknown tokenOut: ${args.tokenOut}` }
|
|
85
|
+
const amountInWei = parseUnits(args.amountIn, tin.token.decimals)
|
|
86
|
+
const quote = await quoteMoe({
|
|
87
|
+
client: ctx.publicClient,
|
|
88
|
+
quoter: moe.quoter as Address,
|
|
89
|
+
route: [tin.token.address, tout.token.address],
|
|
90
|
+
amountIn: amountInWei,
|
|
91
|
+
})
|
|
92
|
+
if (!quote) {
|
|
93
|
+
return {
|
|
94
|
+
ok: false,
|
|
95
|
+
error: `no Merchant Moe LB route with liquidity for ${tin.token.symbol}→${tout.token.symbol}`,
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const slippageBps = BigInt(args.slippageBps ?? DEFAULT_SLIPPAGE_BPS)
|
|
99
|
+
const amountOutMin = (quote.amountOut * (10000n - slippageBps)) / 10000n
|
|
100
|
+
return {
|
|
101
|
+
ok: true,
|
|
102
|
+
data: {
|
|
103
|
+
venue: 'Merchant Moe (Liquidity Book)',
|
|
104
|
+
tokenIn: tin.token.symbol,
|
|
105
|
+
tokenOut: tout.token.symbol,
|
|
106
|
+
amountIn: args.amountIn,
|
|
107
|
+
amountOut: formatUnits(quote.amountOut, tout.token.decimals),
|
|
108
|
+
amountOutMin: formatUnits(amountOutMin, tout.token.decimals),
|
|
109
|
+
hops: quote.route.length - 1,
|
|
110
|
+
slippageBps: Number(slippageBps),
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
} catch (e) {
|
|
114
|
+
return { ok: false, error: (e as Error).message.slice(0, 240) }
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const ExecuteSchema = z.object({
|
|
121
|
+
tokenIn: z.string(),
|
|
122
|
+
tokenOut: z.string(),
|
|
123
|
+
amountIn: z.string(),
|
|
124
|
+
slippageBps: z.number().int().nonnegative().max(10000).optional(),
|
|
125
|
+
})
|
|
126
|
+
type ExecuteArgs = z.infer<typeof ExecuteSchema>
|
|
127
|
+
|
|
128
|
+
export function makeMoeSwap(ctx: OnchainRuntimeContext): ToolDef<ExecuteArgs> {
|
|
129
|
+
return {
|
|
130
|
+
name: 'moe.swap',
|
|
131
|
+
description:
|
|
132
|
+
'Execute a swap on Merchant Moe (Liquidity Book). Re-quotes at exec for slippage protection; auto-approves the router for ERC-20 input on first use. Runs policy -> simulate -> (approval) -> execute like every write.',
|
|
133
|
+
searchHint: 'moe merchant moe swap execute trade liquidity book lb dex exchange',
|
|
134
|
+
schema: ExecuteSchema,
|
|
135
|
+
handler: async (args: ExecuteArgs) => {
|
|
136
|
+
try {
|
|
137
|
+
requireMainnet(ctx.network)
|
|
138
|
+
const account = ctx.walletClient.account
|
|
139
|
+
if (!account) return { ok: false, error: 'walletClient has no account; cannot swap' }
|
|
140
|
+
const moe = MOE_LB_BY_NETWORK[ctx.network]!
|
|
141
|
+
const tin = await resolveOrNative(ctx, args.tokenIn)
|
|
142
|
+
const tout = await resolveOrNative(ctx, args.tokenOut)
|
|
143
|
+
if (!tin) return { ok: false, error: `unknown tokenIn: ${args.tokenIn}` }
|
|
144
|
+
if (!tout) return { ok: false, error: `unknown tokenOut: ${args.tokenOut}` }
|
|
145
|
+
const amountInWei = parseUnits(args.amountIn, tin.token.decimals)
|
|
146
|
+
|
|
147
|
+
// Policy gate (deterministic): block BEFORE any allowance/quote/execute.
|
|
148
|
+
if (ctx.policy) {
|
|
149
|
+
const verdict = evaluatePolicy(
|
|
150
|
+
{
|
|
151
|
+
kind: 'swap',
|
|
152
|
+
asset: tin.isNative ? 'native' : tin.token.address,
|
|
153
|
+
toAsset: tout.isNative ? 'native' : tout.token.address,
|
|
154
|
+
amountRaw: amountInWei,
|
|
155
|
+
slippageBps: Number(args.slippageBps ?? DEFAULT_SLIPPAGE_BPS),
|
|
156
|
+
},
|
|
157
|
+
ctx.policy,
|
|
158
|
+
)
|
|
159
|
+
if (!verdict.allowed) {
|
|
160
|
+
return { ok: false, error: `policy blocked: ${verdict.violations.join('; ')}` }
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const [quote, allow] = await Promise.all([
|
|
165
|
+
quoteMoe({
|
|
166
|
+
client: ctx.publicClient,
|
|
167
|
+
quoter: moe.quoter as Address,
|
|
168
|
+
route: [tin.token.address, tout.token.address],
|
|
169
|
+
amountIn: amountInWei,
|
|
170
|
+
}),
|
|
171
|
+
tin.isNative
|
|
172
|
+
? Promise.resolve({ approved: false, txHash: undefined as `0x${string}` | undefined })
|
|
173
|
+
: ensureAllowance({
|
|
174
|
+
publicClient: ctx.publicClient,
|
|
175
|
+
walletClient: ctx.walletClient,
|
|
176
|
+
token: tin.token.address,
|
|
177
|
+
owner: ctx.agentEoa,
|
|
178
|
+
spender: moe.router as Address,
|
|
179
|
+
amount: amountInWei,
|
|
180
|
+
}),
|
|
181
|
+
])
|
|
182
|
+
if (!quote) {
|
|
183
|
+
return {
|
|
184
|
+
ok: false,
|
|
185
|
+
error: `no Merchant Moe LB route for ${tin.token.symbol}→${tout.token.symbol}`,
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const slippageBps = BigInt(args.slippageBps ?? DEFAULT_SLIPPAGE_BPS)
|
|
189
|
+
const amountOutMin = (quote.amountOut * (10000n - slippageBps)) / 10000n
|
|
190
|
+
const approveTxHash = allow.txHash
|
|
191
|
+
|
|
192
|
+
const composed = encodeMoeSwap({
|
|
193
|
+
quote,
|
|
194
|
+
amountIn: amountInWei,
|
|
195
|
+
amountOutMin,
|
|
196
|
+
to: ctx.agentEoa,
|
|
197
|
+
deadline: BigInt(Math.floor(Date.now() / 1000)) + DEFAULT_DEADLINE_SECS,
|
|
198
|
+
nativeIn: tin.isNative,
|
|
199
|
+
nativeOut: tout.isNative,
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
const sim = await simulateRawTx(ctx.publicClient, {
|
|
203
|
+
account: account.address,
|
|
204
|
+
to: moe.router as Address,
|
|
205
|
+
data: composed.data,
|
|
206
|
+
value: composed.value,
|
|
207
|
+
})
|
|
208
|
+
if (!sim.ok) {
|
|
209
|
+
return { ok: false, error: `pre-flight simulation reverted: ${sim.reason}` }
|
|
210
|
+
}
|
|
211
|
+
const gasPrice = await getGasPriceWithFloor(ctx.publicClient)
|
|
212
|
+
const txHash = await ctx.walletClient.sendTransaction({
|
|
213
|
+
to: moe.router as Address,
|
|
214
|
+
data: composed.data,
|
|
215
|
+
value: composed.value,
|
|
216
|
+
chain: ctx.walletClient.chain,
|
|
217
|
+
account,
|
|
218
|
+
gasPrice,
|
|
219
|
+
})
|
|
220
|
+
const receipt = await waitForReceipt(ctx.publicClient, txHash)
|
|
221
|
+
return {
|
|
222
|
+
ok: true,
|
|
223
|
+
data: {
|
|
224
|
+
venue: 'Merchant Moe (Liquidity Book)',
|
|
225
|
+
...(approveTxHash ? { approveTxHash } : {}),
|
|
226
|
+
txHash,
|
|
227
|
+
blockNumber: Number(receipt.blockNumber),
|
|
228
|
+
gasUsed: receipt.gasUsed.toString(),
|
|
229
|
+
tokenIn: tin.token.symbol,
|
|
230
|
+
tokenOut: tout.token.symbol,
|
|
231
|
+
amountIn: args.amountIn,
|
|
232
|
+
amountOutExpected: formatUnits(quote.amountOut, tout.token.decimals),
|
|
233
|
+
amountOutMin: formatUnits(amountOutMin, tout.token.decimals),
|
|
234
|
+
status: receipt.status === 'success' ? 'success' : 'reverted',
|
|
235
|
+
// Decision receipt: proof this swap was policy-checked + simulated.
|
|
236
|
+
simGasEstimate: sim.gas.toString(),
|
|
237
|
+
policyEnforced: ctx.policy != null,
|
|
238
|
+
},
|
|
239
|
+
}
|
|
240
|
+
} catch (e) {
|
|
241
|
+
return { ok: false, error: (e as Error).message.slice(0, 240) }
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nansen.labels` — Nansen address intelligence for counterparty vetting.
|
|
3
|
+
* Read-only. Requires NANSEN_API_KEY in the environment (never committed);
|
|
4
|
+
* surfaces a clear message when the key is unset or out of credits.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ToolDef } from 'nebula-ai-core'
|
|
8
|
+
import { z } from 'zod'
|
|
9
|
+
import { categorySummary, fetchNansenLabels, redFlags } from '../nansen'
|
|
10
|
+
import type { OnchainRuntimeContext } from '../types'
|
|
11
|
+
|
|
12
|
+
const Schema = z.object({
|
|
13
|
+
address: z.string().min(1).describe('0x address to look up.'),
|
|
14
|
+
chain: z
|
|
15
|
+
.string()
|
|
16
|
+
.optional()
|
|
17
|
+
.describe('Chain to query (e.g. "ethereum", "mantle"). Default "ethereum".'),
|
|
18
|
+
})
|
|
19
|
+
type Args = z.infer<typeof Schema>
|
|
20
|
+
|
|
21
|
+
export function makeNansenLabels(_ctx: OnchainRuntimeContext): ToolDef<Args> {
|
|
22
|
+
return {
|
|
23
|
+
name: 'nansen.labels',
|
|
24
|
+
description:
|
|
25
|
+
'Nansen entity labels for an address (exchange / fund / smart-money / contract / and red-flag categories like scam, hack, sanctioned, mixer) — vet a counterparty before transacting. Read-only; needs NANSEN_API_KEY in the env. Reports a flagged warning when the address carries a red-flag label.',
|
|
26
|
+
searchHint:
|
|
27
|
+
'nansen address labels entity intel exchange smart money scam sanctioned counterparty vet who is',
|
|
28
|
+
schema: Schema,
|
|
29
|
+
handler: async (args: Args) => {
|
|
30
|
+
try {
|
|
31
|
+
const apiKey = process.env.NANSEN_API_KEY
|
|
32
|
+
if (!apiKey) {
|
|
33
|
+
return {
|
|
34
|
+
ok: true,
|
|
35
|
+
data: {
|
|
36
|
+
configured: false,
|
|
37
|
+
note: 'NANSEN_API_KEY is not set — counterparty intel unavailable. Set it in the env (never commit it) to enable nansen.labels.',
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const chain = args.chain ?? 'ethereum'
|
|
42
|
+
const res = await fetchNansenLabels({ address: args.address, chain, apiKey })
|
|
43
|
+
if (!res.ok) {
|
|
44
|
+
return {
|
|
45
|
+
ok: true,
|
|
46
|
+
data: { address: args.address, chain, available: false, note: res.error },
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const flags = redFlags(res.labels)
|
|
50
|
+
return {
|
|
51
|
+
ok: true,
|
|
52
|
+
data: {
|
|
53
|
+
address: args.address,
|
|
54
|
+
chain,
|
|
55
|
+
labelCount: res.labels.length,
|
|
56
|
+
categories: categorySummary(res.labels),
|
|
57
|
+
labels: res.labels.slice(0, 25),
|
|
58
|
+
flagged: flags.length > 0,
|
|
59
|
+
...(flags.length > 0
|
|
60
|
+
? {
|
|
61
|
+
warning: `RED FLAG: address carries Nansen label(s) in [${flags.join(', ')}] — do not transact without confirmation`,
|
|
62
|
+
}
|
|
63
|
+
: {}),
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
} catch (e) {
|
|
67
|
+
return { ok: false, error: (e as Error).message.slice(0, 240) }
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `policy.show` — surface the active deterministic fund-control policy.
|
|
3
|
+
*
|
|
4
|
+
* The control layer is only trustworthy if it is legible. This read-only tool
|
|
5
|
+
* reports exactly which caps, allowlists, and autonomy tier are in force for
|
|
6
|
+
* this session (resolved from NEBULA_POLICY_* at runtime), so the operator (and
|
|
7
|
+
* the agent, when asked "what are my limits") sees the enforced boundary rather
|
|
8
|
+
* than guessing. No signer, no state change.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ToolDef } from 'nebula-ai-core'
|
|
12
|
+
import { formatEther } from 'viem'
|
|
13
|
+
import { z } from 'zod'
|
|
14
|
+
import type { OnchainRuntimeContext } from '../types'
|
|
15
|
+
|
|
16
|
+
const Schema = z.object({})
|
|
17
|
+
type Args = z.infer<typeof Schema>
|
|
18
|
+
|
|
19
|
+
export function makePolicyShow(ctx: OnchainRuntimeContext): ToolDef<Args> {
|
|
20
|
+
return {
|
|
21
|
+
name: 'policy.show',
|
|
22
|
+
description:
|
|
23
|
+
'Show the active deterministic fund-control policy: hard caps, allowlists, slippage cap, autonomy tier, and the approval threshold. Read-only. Call this for "what are my limits", "what can you spend", "show the policy", or before explaining why an action was blocked or needs approval.',
|
|
24
|
+
searchHint:
|
|
25
|
+
'policy limits caps allowlist autonomy approval guardrails rules what can you spend',
|
|
26
|
+
schema: Schema,
|
|
27
|
+
handler: async () => {
|
|
28
|
+
const p = ctx.policy
|
|
29
|
+
if (!p) {
|
|
30
|
+
return {
|
|
31
|
+
ok: true,
|
|
32
|
+
data: {
|
|
33
|
+
enforced: false,
|
|
34
|
+
note: 'No NEBULA_POLICY_* configured — there are no in-code caps this session. Set NEBULA_POLICY_* (e.g. MAX_NATIVE_MNT, AUTO_MAX_NATIVE_MNT, AUTONOMY) to arm fund controls. Value-moving actions still go through the session permission mode + pre-flight simulation.',
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const readOnly = p.readOnly === true || p.autonomy === 'readonly'
|
|
39
|
+
const maxNative =
|
|
40
|
+
p.maxNativeWeiPerTx === undefined ? null : `${formatEther(p.maxNativeWeiPerTx)} MNT`
|
|
41
|
+
const autoUpTo =
|
|
42
|
+
p.autoMaxNativeWeiPerTx === undefined ? null : `${formatEther(p.autoMaxNativeWeiPerTx)} MNT`
|
|
43
|
+
const lines: string[] = []
|
|
44
|
+
if (readOnly) lines.push('READ-ONLY: all writes are blocked.')
|
|
45
|
+
if (maxNative) lines.push(`Hard cap: native sends over ${maxNative} are blocked.`)
|
|
46
|
+
if (autoUpTo)
|
|
47
|
+
lines.push(`Auto-execute native sends up to ${autoUpTo}; above that requires approval.`)
|
|
48
|
+
if (p.maxSlippageBps !== undefined)
|
|
49
|
+
lines.push(`Swaps over ${p.maxSlippageBps} bps slippage are blocked.`)
|
|
50
|
+
if (p.recipientAllowlist?.length)
|
|
51
|
+
lines.push(`Transfers only to ${p.recipientAllowlist.length} allowlisted recipient(s).`)
|
|
52
|
+
if (p.tokenAllowlist?.length)
|
|
53
|
+
lines.push(`Only ${p.tokenAllowlist.length} allowlisted token(s) may be moved/swapped.`)
|
|
54
|
+
if (p.autonomy === 'confirm') lines.push('Autonomy=confirm: every write needs approval.')
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
ok: true,
|
|
58
|
+
data: {
|
|
59
|
+
enforced: true,
|
|
60
|
+
readOnly,
|
|
61
|
+
autonomy: p.autonomy ?? 'auto',
|
|
62
|
+
maxNativePerTx: maxNative,
|
|
63
|
+
autoApproveUpToNative: autoUpTo,
|
|
64
|
+
approvalAboveAuto: autoUpTo !== null,
|
|
65
|
+
maxSlippageBps: p.maxSlippageBps ?? null,
|
|
66
|
+
recipientAllowlist: p.recipientAllowlist ?? null,
|
|
67
|
+
tokenAllowlist: p.tokenAllowlist ?? null,
|
|
68
|
+
summary:
|
|
69
|
+
lines.length > 0 ? lines.join(' ') : 'Policy armed but with no specific caps set.',
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `risk.token` — pre-trade risk assessment for any Mantle token. Read-only.
|
|
3
|
+
*
|
|
4
|
+
* Composes the signals a treasury manager checks before holding or swapping
|
|
5
|
+
* into an asset: can you actually exit it (a live quote on Agni / Merchant Moe),
|
|
6
|
+
* how deep is its liquidity, is it a restricted RWA, and is the address even a
|
|
7
|
+
* contract. Returns a low/elevated/high verdict with plain-language reasons.
|
|
8
|
+
* Analytics only — moves nothing.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ToolDef } from 'nebula-ai-core'
|
|
12
|
+
import { type Address, parseUnits } from 'viem'
|
|
13
|
+
import { z } from 'zod'
|
|
14
|
+
import { AGNI_BY_NETWORK, MOE_LB_BY_NETWORK } from '../constants'
|
|
15
|
+
import { fetchMantleYields, isRestrictedAsset } from '../defillama'
|
|
16
|
+
import { quoteMoe } from '../moe'
|
|
17
|
+
import { quoteAcrossTiers } from '../quoter'
|
|
18
|
+
import { assessTokenRisk } from '../risk'
|
|
19
|
+
import { resolveToken } from '../tokens'
|
|
20
|
+
import type { OnchainRuntimeContext } from '../types'
|
|
21
|
+
|
|
22
|
+
const USDC: Address = '0x09Bc4E0D864854c6aFB6eB9A9cdF58aC190D0dF9'
|
|
23
|
+
|
|
24
|
+
const Schema = z.object({
|
|
25
|
+
token: z.string().min(1).describe('Token symbol or 0x address to assess.'),
|
|
26
|
+
})
|
|
27
|
+
type Args = z.infer<typeof Schema>
|
|
28
|
+
|
|
29
|
+
export function makeRiskToken(ctx: OnchainRuntimeContext): ToolDef<Args> {
|
|
30
|
+
return {
|
|
31
|
+
name: 'risk.token',
|
|
32
|
+
description:
|
|
33
|
+
'Assess the risk of holding or swapping into a Mantle token before you act: tradeability (can you exit it on Agni / Merchant Moe), liquidity depth, restricted-RWA flag, and whether the address is a real contract. Returns a low/elevated/high verdict with reasons. Read-only analytics — call it before proposing a buy/supply into an unfamiliar token.',
|
|
34
|
+
searchHint:
|
|
35
|
+
'risk token assess safe rug honeypot liquidity tradeable exit restricted rwa due diligence vet',
|
|
36
|
+
schema: Schema,
|
|
37
|
+
handler: async (args: Args) => {
|
|
38
|
+
try {
|
|
39
|
+
const wmnt = AGNI_BY_NETWORK[ctx.network]?.weth9 as Address | undefined
|
|
40
|
+
const moe = MOE_LB_BY_NETWORK[ctx.network]
|
|
41
|
+
const token = await resolveToken({
|
|
42
|
+
client: ctx.publicClient,
|
|
43
|
+
agentDir: ctx.agentDir,
|
|
44
|
+
input: args.token,
|
|
45
|
+
})
|
|
46
|
+
if (!token) {
|
|
47
|
+
const v = assessTokenRisk({
|
|
48
|
+
resolved: false,
|
|
49
|
+
symbol: args.token,
|
|
50
|
+
restricted: false,
|
|
51
|
+
tradeableVenues: [],
|
|
52
|
+
maxPoolTvlUsd: null,
|
|
53
|
+
isContract: false,
|
|
54
|
+
})
|
|
55
|
+
return { ok: true, data: { token: args.token, ...v } }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Reference asset for the tradeability probe (avoid self-pairing).
|
|
59
|
+
// Agni + Merchant Moe are mainnet-only, so the venue probes run there.
|
|
60
|
+
const mainnet = ctx.network === 'mantle-mainnet'
|
|
61
|
+
const ref = token.address.toLowerCase() === USDC.toLowerCase() ? wmnt : USDC
|
|
62
|
+
const isReference =
|
|
63
|
+
token.address.toLowerCase() === USDC.toLowerCase() ||
|
|
64
|
+
(wmnt && token.address.toLowerCase() === wmnt.toLowerCase())
|
|
65
|
+
const amountIn = parseUnits('1', token.decimals)
|
|
66
|
+
|
|
67
|
+
const [code, yields, agni, moeQ] = await Promise.all([
|
|
68
|
+
// '0x' = genuinely no code (EOA/typo); 'ERR' = RPC failed (don't penalize).
|
|
69
|
+
ctx.publicClient
|
|
70
|
+
.getBytecode({ address: token.address })
|
|
71
|
+
.then(c => c ?? '0x')
|
|
72
|
+
.catch(() => 'ERR' as const),
|
|
73
|
+
fetchMantleYields({ minTvlUsd: 0, sortBy: 'tvl', limit: 50 }).catch(() => []),
|
|
74
|
+
mainnet && ref
|
|
75
|
+
? quoteAcrossTiers({
|
|
76
|
+
client: ctx.publicClient,
|
|
77
|
+
network: 'mantle-mainnet',
|
|
78
|
+
tokenIn: token.address,
|
|
79
|
+
tokenOut: ref,
|
|
80
|
+
amountIn,
|
|
81
|
+
}).catch(() => null)
|
|
82
|
+
: Promise.resolve(null),
|
|
83
|
+
mainnet && ref && moe
|
|
84
|
+
? quoteMoe({
|
|
85
|
+
client: ctx.publicClient,
|
|
86
|
+
quoter: moe.quoter as Address,
|
|
87
|
+
route: [token.address, ref],
|
|
88
|
+
amountIn,
|
|
89
|
+
}).catch(() => null)
|
|
90
|
+
: Promise.resolve(null),
|
|
91
|
+
])
|
|
92
|
+
|
|
93
|
+
const tradeableVenues: string[] = []
|
|
94
|
+
if (isReference) {
|
|
95
|
+
tradeableVenues.push('reference asset (deep liquidity)')
|
|
96
|
+
} else {
|
|
97
|
+
if (agni) tradeableVenues.push('Agni Finance')
|
|
98
|
+
if (moeQ) tradeableVenues.push('Merchant Moe')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const sym = token.symbol.toUpperCase()
|
|
102
|
+
const maxPoolTvlUsd =
|
|
103
|
+
yields
|
|
104
|
+
.filter(p => p.symbol.toUpperCase().includes(sym))
|
|
105
|
+
.reduce((mx, p) => Math.max(mx, p.tvlUsd), 0) || null
|
|
106
|
+
|
|
107
|
+
const verdict = assessTokenRisk({
|
|
108
|
+
resolved: true,
|
|
109
|
+
symbol: token.symbol,
|
|
110
|
+
restricted: isRestrictedAsset(token.symbol, ''),
|
|
111
|
+
tradeableVenues,
|
|
112
|
+
maxPoolTvlUsd,
|
|
113
|
+
// Only a successful '0x' read means "not a contract"; an RPC error must
|
|
114
|
+
// not down-rank a real token.
|
|
115
|
+
isContract: code === 'ERR' ? true : code !== '0x',
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
ok: true,
|
|
120
|
+
data: {
|
|
121
|
+
token: token.symbol,
|
|
122
|
+
address: token.address,
|
|
123
|
+
tradeableVenues,
|
|
124
|
+
maxPoolTvlUsd,
|
|
125
|
+
restricted: isRestrictedAsset(token.symbol, ''),
|
|
126
|
+
...verdict,
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
} catch (e) {
|
|
130
|
+
return { ok: false, error: (e as Error).message.slice(0, 240) }
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tx.simulate` — dry-run any contract call before doing it. Read-only: it
|
|
3
|
+
* never broadcasts. Exposes the same pre-flight simulation engine that guards
|
|
4
|
+
* every write, as a tool the agent (or operator) can point at an arbitrary
|
|
5
|
+
* call to answer "would this succeed, and what would it cost?" up front.
|
|
6
|
+
*
|
|
7
|
+
* Same arg shape as `chain.write` (to + signature + args + value) plus an
|
|
8
|
+
* optional `from` to simulate as another account.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ToolDef } from 'nebula-ai-core'
|
|
12
|
+
import { type Address, encodeFunctionData, getAddress, isAddress } from 'viem'
|
|
13
|
+
import { z } from 'zod'
|
|
14
|
+
import { simulateRawTx } from '../simulate'
|
|
15
|
+
import type { OnchainRuntimeContext } from '../types'
|
|
16
|
+
import { buildAbiFunction, coerceArg, parseChainWriteValue } from './generic'
|
|
17
|
+
|
|
18
|
+
const Schema = z.object({
|
|
19
|
+
to: z.string().min(42).describe('0x contract address to call.'),
|
|
20
|
+
signature: z
|
|
21
|
+
.string()
|
|
22
|
+
.optional()
|
|
23
|
+
.describe('Function signature, e.g. "transfer(address,uint256)". Omit if passing raw `data`.'),
|
|
24
|
+
args: z.array(z.unknown()).optional().describe('Args matching the signature.'),
|
|
25
|
+
data: z.string().optional().describe('Raw calldata hex (alternative to signature+args).'),
|
|
26
|
+
value: z
|
|
27
|
+
.string()
|
|
28
|
+
.optional()
|
|
29
|
+
.describe('Native value to attach. Decimal MNT ("0.01") or wei integer.'),
|
|
30
|
+
from: z.string().optional().describe('Account to simulate as (default: the agent EOA).'),
|
|
31
|
+
})
|
|
32
|
+
type Args = z.infer<typeof Schema>
|
|
33
|
+
|
|
34
|
+
export function makeTxSimulate(ctx: OnchainRuntimeContext): ToolDef<Args> {
|
|
35
|
+
return {
|
|
36
|
+
name: 'tx.simulate',
|
|
37
|
+
description:
|
|
38
|
+
'Dry-run a contract call WITHOUT broadcasting: returns whether it would succeed and the gas estimate, or the decoded revert reason if it would fail. Pass `to` + `signature` + `args` (like chain.write) or raw `data`, plus optional `value`/`from`. Read-only. Use it to preview an action, debug a revert, or check a call before proposing it.',
|
|
39
|
+
searchHint:
|
|
40
|
+
'simulate dry run preview estimate gas would revert test call before execute eth_estimategas',
|
|
41
|
+
schema: Schema,
|
|
42
|
+
handler: async (args: Args) => {
|
|
43
|
+
try {
|
|
44
|
+
let data: `0x${string}` | undefined
|
|
45
|
+
if (args.signature) {
|
|
46
|
+
const fn = buildAbiFunction(args.signature)
|
|
47
|
+
const coerced = (args.args ?? []).map(coerceArg)
|
|
48
|
+
data = encodeFunctionData({
|
|
49
|
+
abi: [fn] as readonly [import('viem').AbiFunction],
|
|
50
|
+
args: coerced,
|
|
51
|
+
})
|
|
52
|
+
} else if (args.data) {
|
|
53
|
+
data = args.data as `0x${string}`
|
|
54
|
+
} else {
|
|
55
|
+
return { ok: false, error: 'provide either `signature` (+args) or raw `data`' }
|
|
56
|
+
}
|
|
57
|
+
if (!isAddress(args.to)) return { ok: false, error: `invalid address: ${args.to}` }
|
|
58
|
+
const from =
|
|
59
|
+
args.from && isAddress(args.from) ? (getAddress(args.from) as Address) : ctx.agentEoa
|
|
60
|
+
const value = args.value ? parseChainWriteValue(args.value) : undefined
|
|
61
|
+
|
|
62
|
+
const sim = await simulateRawTx(ctx.publicClient, {
|
|
63
|
+
account: from,
|
|
64
|
+
to: getAddress(args.to) as Address,
|
|
65
|
+
data,
|
|
66
|
+
value,
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
if (sim.ok) {
|
|
70
|
+
return {
|
|
71
|
+
ok: true,
|
|
72
|
+
data: {
|
|
73
|
+
wouldSucceed: true,
|
|
74
|
+
gasEstimate: sim.gas.toString(),
|
|
75
|
+
to: getAddress(args.to),
|
|
76
|
+
from,
|
|
77
|
+
...(args.signature ? { signature: args.signature } : {}),
|
|
78
|
+
...(value !== undefined ? { value: value.toString() } : {}),
|
|
79
|
+
note: 'Dry-run only — nothing was broadcast.',
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
ok: true,
|
|
85
|
+
data: {
|
|
86
|
+
wouldSucceed: false,
|
|
87
|
+
revertReason: sim.reason,
|
|
88
|
+
to: getAddress(args.to),
|
|
89
|
+
from,
|
|
90
|
+
note: 'Dry-run only — this call would revert; nothing was broadcast.',
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
} catch (e) {
|
|
94
|
+
return { ok: false, error: (e as Error).message.slice(0, 240) }
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
}
|