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
@@ -0,0 +1,67 @@
1
+ /**
2
+ * `account.balance` — the agent EOA's native MNT position across mainnet and
3
+ * testnet. Kept separate from `account.info` (which bundles identity + tokens
4
+ * + activity): this is the top-line "how much MNT do we hold" answer.
5
+ */
6
+
7
+ import { NETWORK_RPC, formatMnt } from 'nebula-ai-core'
8
+ import type { ToolDef } from 'nebula-ai-core'
9
+ import { http, type Address, createPublicClient } from 'viem'
10
+ import { z } from 'zod'
11
+ import type { OnchainRuntimeContext } from '../types'
12
+
13
+ const Schema = z.object({})
14
+ type Args = z.infer<typeof Schema>
15
+
16
+ interface BalanceResult {
17
+ agentEoa: Address
18
+ eoaMainnet: { wei: string; formatted: string }
19
+ eoaTestnet: { wei: string; formatted: string }
20
+ positionSummary: {
21
+ mainnetTotalFormatted: string
22
+ testnetTotalFormatted: string
23
+ }
24
+ }
25
+
26
+ export function makeAccountBalance(ctx: OnchainRuntimeContext): ToolDef<Args> {
27
+ return {
28
+ name: 'account.balance',
29
+ description:
30
+ 'Agent EOA native MNT balance on both Mantle mainnet and testnet. Read-only, no signer.',
31
+ searchHint:
32
+ 'balance position funds MNT total — call this for "what\'s my balance" / "how much do we have" / "show full position". Use account.info for identity + token bundling.',
33
+ schema: Schema,
34
+ handler: async () => {
35
+ try {
36
+ // ctx.publicClient is bound to config.network; explicitly create per-chain
37
+ // clients so an agent on testnet still gets distinct mainnet vs testnet reads.
38
+ const mainnetClient =
39
+ ctx.network === 'mantle-mainnet'
40
+ ? ctx.publicClient
41
+ : createPublicClient({ transport: http(NETWORK_RPC['mantle-mainnet']) })
42
+ const testnetClient =
43
+ ctx.network === 'mantle-testnet'
44
+ ? ctx.publicClient
45
+ : createPublicClient({ transport: http(NETWORK_RPC['mantle-testnet']) })
46
+
47
+ const [eoaMainnetWei, eoaTestnetWei] = await Promise.all([
48
+ mainnetClient.getBalance({ address: ctx.agentEoa }).catch(() => 0n),
49
+ testnetClient.getBalance({ address: ctx.agentEoa }).catch(() => 0n),
50
+ ])
51
+ const result: BalanceResult = {
52
+ agentEoa: ctx.agentEoa,
53
+ eoaMainnet: { wei: eoaMainnetWei.toString(), formatted: formatMnt(eoaMainnetWei) },
54
+ eoaTestnet: { wei: eoaTestnetWei.toString(), formatted: formatMnt(eoaTestnetWei) },
55
+ positionSummary: {
56
+ mainnetTotalFormatted: formatMnt(eoaMainnetWei),
57
+ testnetTotalFormatted: formatMnt(eoaTestnetWei),
58
+ },
59
+ }
60
+
61
+ return { ok: true, data: result }
62
+ } catch (e) {
63
+ return { ok: false, error: (e as Error).message.slice(0, 240) }
64
+ }
65
+ },
66
+ }
67
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * `account.info` — wallet + iNFT + brain + activity bundle in one call.
3
+ */
4
+
5
+ import { existsSync, readFileSync } from 'node:fs'
6
+ import { join } from 'node:path'
7
+ import type { ToolDef } from 'nebula-ai-core'
8
+ import { z } from 'zod'
9
+ import { snapshotBalances } from '../balances'
10
+ import type { OnchainRuntimeContext } from '../types'
11
+
12
+ const Schema = z.object({})
13
+ type Args = z.infer<typeof Schema>
14
+
15
+ export function makeAccountInfo(ctx: OnchainRuntimeContext): ToolDef<Args> {
16
+ return {
17
+ name: 'account.info',
18
+ description:
19
+ 'Bundle of agent wallet snapshot + iNFT identity + brain provider + last 5 activity entries. Single round-trip via Multicall3.',
20
+ searchHint: 'identity wallet snapshot account info self worth usd value holdings',
21
+ schema: Schema,
22
+ handler: async () => {
23
+ try {
24
+ const [snap, recent] = await Promise.all([
25
+ snapshotBalances({
26
+ client: ctx.publicClient,
27
+ agentDir: ctx.agentDir,
28
+ address: ctx.agentEoa,
29
+ mintBlock: ctx.mintBlock,
30
+ }),
31
+ readRecentActivity(ctx.agentDir, 5),
32
+ ])
33
+
34
+ const tokens = snap.tokens.map(t => ({
35
+ symbol: t.symbol,
36
+ address: t.address,
37
+ decimals: t.decimals,
38
+ raw: t.raw,
39
+ formatted: t.formatted,
40
+ }))
41
+
42
+ return {
43
+ ok: true,
44
+ data: {
45
+ agentEoa: ctx.agentEoa,
46
+ iNFT: ctx.iNFT
47
+ ? {
48
+ contract: ctx.iNFT.contract,
49
+ tokenId: ctx.iNFT.tokenId.toString(),
50
+ }
51
+ : null,
52
+ network: ctx.network,
53
+ brain: { provider: ctx.brainProvider ?? null, model: ctx.brainModel ?? null },
54
+ wallet: {
55
+ native: snap.native,
56
+ tokens,
57
+ blockNumber: snap.blockNumber,
58
+ },
59
+ recentActivity: recent,
60
+ },
61
+ }
62
+ } catch (e) {
63
+ return { ok: false, error: (e as Error).message.slice(0, 240) }
64
+ }
65
+ },
66
+ }
67
+ }
68
+
69
+ interface ActivityEntry {
70
+ ts: number
71
+ kind: string
72
+ summary: string
73
+ }
74
+
75
+ function readRecentActivity(agentDir: string, limit: number): ActivityEntry[] {
76
+ const path = join(agentDir, 'activity.jsonl')
77
+ if (!existsSync(path)) return []
78
+ let raw: string
79
+ try {
80
+ raw = readFileSync(path, 'utf8')
81
+ } catch {
82
+ return []
83
+ }
84
+ const lines = raw.split('\n').filter(Boolean)
85
+ const tail = lines.slice(-limit)
86
+ const out: ActivityEntry[] = []
87
+ for (const line of tail) {
88
+ try {
89
+ const obj = JSON.parse(line) as { ts?: number; kind?: string; data?: unknown }
90
+ if (typeof obj.ts === 'number' && typeof obj.kind === 'string') {
91
+ out.push({
92
+ ts: obj.ts,
93
+ kind: obj.kind,
94
+ summary: summarizeActivity(obj),
95
+ })
96
+ }
97
+ } catch {
98
+ // ignore malformed
99
+ }
100
+ }
101
+ return out
102
+ }
103
+
104
+ function summarizeActivity(obj: { kind?: string; data?: unknown }): string {
105
+ if (obj.kind === 'tool-call' && obj.data && typeof obj.data === 'object') {
106
+ const data = obj.data as { call?: { name?: string } }
107
+ return data.call?.name ?? 'tool'
108
+ }
109
+ if (typeof obj.kind === 'string') return obj.kind
110
+ return 'event'
111
+ }
@@ -0,0 +1,371 @@
1
+ /**
2
+ * `chain.tx` + `chain.contract` + `chain.activity` — read-only analysis tools.
3
+ */
4
+
5
+ import type { ToolDef } from 'nebula-ai-core'
6
+ import {
7
+ type Address,
8
+ decodeFunctionResult,
9
+ encodeFunctionData,
10
+ formatUnits,
11
+ getAddress,
12
+ pad,
13
+ } from 'viem'
14
+ import { z } from 'zod'
15
+ import { ERC20_ABI, MULTICALL3_ABI } from '../abis'
16
+ import { decodeCalldata } from '../analysis'
17
+ import {
18
+ EIP1967_IMPL_SLOT,
19
+ ERC165_INTERFACES,
20
+ LOG_SCAN_CHUNK_BLOCKS,
21
+ LOG_SCAN_MAX_CHUNKS,
22
+ MULTICALL3,
23
+ TRANSFER_TOPIC0,
24
+ } from '../constants'
25
+ import { rawGetLogs } from '../raw-logs'
26
+ import { loadTokenCache, lookupFromList } from '../tokens'
27
+ import type { OnchainRuntimeContext } from '../types'
28
+
29
+ const TxSchema = z.object({
30
+ hash: z.string().min(66).describe('0x... 32-byte tx hash'),
31
+ })
32
+ type TxArgs = z.infer<typeof TxSchema>
33
+
34
+ function jsonifyArgs(args: unknown[]): unknown[] {
35
+ return args.map(a => {
36
+ if (typeof a === 'bigint') return a.toString()
37
+ if (Array.isArray(a)) return jsonifyArgs(a)
38
+ if (a && typeof a === 'object') {
39
+ const out: Record<string, unknown> = {}
40
+ for (const [k, v] of Object.entries(a as Record<string, unknown>)) {
41
+ out[k] = typeof v === 'bigint' ? v.toString() : v
42
+ }
43
+ return out
44
+ }
45
+ return a
46
+ })
47
+ }
48
+
49
+ export function makeChainTx(ctx: OnchainRuntimeContext): ToolDef<TxArgs> {
50
+ return {
51
+ name: 'chain.tx',
52
+ description:
53
+ "Decode any Mantle tx hash: from, to, value, status, gas, decoded function call (via vendored ABIs first, 4byte directory fallback), event log summary. ALWAYS call this when the operator gives you a 0x-prefixed hash — do NOT pre-judge whether the hash 'looks valid' by inspecting its bytes; the RPC will return a clean 'tx not found' error if it doesn't exist, and that's the operator-facing source of truth. Skipping the call to call it fake is a hallucination.",
54
+ searchHint: 'transaction tx decode hash receipt analysis',
55
+ schema: TxSchema,
56
+ handler: async args => {
57
+ try {
58
+ const hash = args.hash as `0x${string}`
59
+ const [tx, receipt] = await Promise.all([
60
+ ctx.publicClient.getTransaction({ hash }).catch(() => null),
61
+ ctx.publicClient.getTransactionReceipt({ hash }).catch(() => null),
62
+ ])
63
+ if (!tx || !receipt) {
64
+ return { ok: false, error: `tx not found: ${hash}` }
65
+ }
66
+ const decoded = await decodeCalldata({
67
+ data: tx.input as `0x${string}`,
68
+ agentDir: ctx.agentDir,
69
+ })
70
+ return {
71
+ ok: true,
72
+ data: {
73
+ hash,
74
+ from: tx.from,
75
+ to: tx.to,
76
+ value: tx.value.toString(),
77
+ blockNumber: Number(receipt.blockNumber),
78
+ status: receipt.status === 'success' ? 'success' : 'reverted',
79
+ gasUsed: receipt.gasUsed.toString(),
80
+ effectiveGasPrice: receipt.effectiveGasPrice?.toString() ?? null,
81
+ function:
82
+ 'name' in decoded
83
+ ? {
84
+ name: decoded.name,
85
+ signature: decoded.signature,
86
+ args: jsonifyArgs(decoded.args),
87
+ source: decoded.source,
88
+ }
89
+ : { selector: decoded.selector, source: 'unknown' },
90
+ logs: receipt.logs.map(l => ({
91
+ address: l.address,
92
+ topic0: l.topics[0] ?? null,
93
+ topicCount: l.topics.length,
94
+ dataSize: (l.data.length - 2) / 2,
95
+ })),
96
+ },
97
+ }
98
+ } catch (e) {
99
+ return { ok: false, error: (e as Error).message.slice(0, 240) }
100
+ }
101
+ },
102
+ }
103
+ }
104
+
105
+ const ContractSchema = z.object({
106
+ address: z.string().min(42),
107
+ })
108
+ type ContractArgs = z.infer<typeof ContractSchema>
109
+
110
+ export function makeChainContract(ctx: OnchainRuntimeContext): ToolDef<ContractArgs> {
111
+ return {
112
+ name: 'chain.contract',
113
+ description:
114
+ 'Introspect any Mantle contract: bytecode size, EIP-1967 proxy detection, ERC-20/721/1155 interface check, name/symbol if ERC-20.',
115
+ searchHint: 'contract introspect proxy erc20 erc721 supportsInterface',
116
+ schema: ContractSchema,
117
+ handler: async args => {
118
+ try {
119
+ const address = getAddress(args.address) as Address
120
+ const code = await ctx.publicClient.getCode({ address })
121
+ const bytecodeSize = code ? (code.length - 2) / 2 : 0
122
+ const isContract = bytecodeSize > 0
123
+ if (!isContract) {
124
+ return {
125
+ ok: true,
126
+ data: { address, isContract: false, bytecodeSize: 0 },
127
+ }
128
+ }
129
+ const [implRaw, supports721, supports1155] = await Promise.all([
130
+ ctx.publicClient.getStorageAt({ address, slot: EIP1967_IMPL_SLOT }).catch(() => null),
131
+ tryReadSupportsInterface(ctx.publicClient, address, ERC165_INTERFACES.ERC721),
132
+ tryReadSupportsInterface(ctx.publicClient, address, ERC165_INTERFACES.ERC1155),
133
+ ])
134
+ const proxy =
135
+ implRaw && implRaw !== '0x' && implRaw !== `0x${'0'.repeat(64)}`
136
+ ? { implementation: `0x${implRaw.slice(-40)}` as Address }
137
+ : null
138
+ const interfaces: string[] = []
139
+ if (supports721) interfaces.push('ERC721')
140
+ if (supports1155) interfaces.push('ERC1155')
141
+ // ERC-20 detection: try Multicall3 reads of name/symbol/decimals.
142
+ // None of those are mandatory in ERC-20 but in practice every legit
143
+ // token has them; if all three return data, label ERC-20.
144
+ const erc20Calls = [
145
+ {
146
+ target: address,
147
+ allowFailure: true,
148
+ callData: encodeFunctionData({ abi: ERC20_ABI, functionName: 'name' }),
149
+ },
150
+ {
151
+ target: address,
152
+ allowFailure: true,
153
+ callData: encodeFunctionData({ abi: ERC20_ABI, functionName: 'symbol' }),
154
+ },
155
+ {
156
+ target: address,
157
+ allowFailure: true,
158
+ callData: encodeFunctionData({ abi: ERC20_ABI, functionName: 'decimals' }),
159
+ },
160
+ ]
161
+ const erc20Results = (await ctx.publicClient.readContract({
162
+ address: MULTICALL3,
163
+ abi: MULTICALL3_ABI,
164
+ functionName: 'aggregate3',
165
+ args: [erc20Calls],
166
+ })) as ReadonlyArray<{ success: boolean; returnData: `0x${string}` }>
167
+ let erc20: { name: string; symbol: string; decimals: number } | null = null
168
+ if (erc20Results.length === 3 && erc20Results.every(r => r.success)) {
169
+ try {
170
+ const name = decodeFunctionResult({
171
+ abi: ERC20_ABI,
172
+ functionName: 'name',
173
+ data: erc20Results[0]!.returnData,
174
+ }) as string
175
+ const symbol = decodeFunctionResult({
176
+ abi: ERC20_ABI,
177
+ functionName: 'symbol',
178
+ data: erc20Results[1]!.returnData,
179
+ }) as string
180
+ const decimals = Number(
181
+ decodeFunctionResult({
182
+ abi: ERC20_ABI,
183
+ functionName: 'decimals',
184
+ data: erc20Results[2]!.returnData,
185
+ }) as number,
186
+ )
187
+ erc20 = { name, symbol, decimals }
188
+ interfaces.push('ERC20')
189
+ } catch {
190
+ // not actually an ERC-20
191
+ }
192
+ }
193
+ return {
194
+ ok: true,
195
+ data: {
196
+ address,
197
+ isContract: true,
198
+ bytecodeSize,
199
+ proxy,
200
+ interfaces,
201
+ erc20,
202
+ },
203
+ }
204
+ } catch (e) {
205
+ return { ok: false, error: (e as Error).message.slice(0, 240) }
206
+ }
207
+ },
208
+ }
209
+ }
210
+
211
+ const SUPPORTS_INTERFACE_SELECTOR = '0x01ffc9a7' // keccak('supportsInterface(bytes4)')[:4]
212
+
213
+ async function tryReadSupportsInterface(
214
+ client: import('viem').PublicClient,
215
+ address: Address,
216
+ interfaceId: string,
217
+ ): Promise<boolean> {
218
+ try {
219
+ const data =
220
+ `${SUPPORTS_INTERFACE_SELECTOR}${interfaceId.slice(2).padEnd(64, '0')}` as `0x${string}`
221
+ const out = await client.call({ to: address, data })
222
+ if (!out.data || out.data === '0x') return false
223
+ return out.data.endsWith('1')
224
+ } catch {
225
+ return false
226
+ }
227
+ }
228
+
229
+ const ActivitySchema = z.object({
230
+ address: z.string().optional(),
231
+ limit: z.number().int().positive().max(200).optional(),
232
+ decodeMethods: z
233
+ .boolean()
234
+ .optional()
235
+ .describe(
236
+ 'Also decode the function that triggered each transfer (e.g. "transfer", "swapExactInputSingle"). Costs an extra RPC per unique tx, so it is capped — use for an audit deep-dive.',
237
+ ),
238
+ })
239
+ type ActivityArgs = z.infer<typeof ActivitySchema>
240
+
241
+ /** Cap on per-tx decode lookups when decodeMethods is set, to bound RPC load. */
242
+ const ACTIVITY_DECODE_CAP = 15
243
+
244
+ export function makeChainActivity(ctx: OnchainRuntimeContext): ToolDef<ActivityArgs> {
245
+ return {
246
+ name: 'chain.activity',
247
+ description:
248
+ 'Recent ERC-20 Transfer events for an address (in + out) sorted newest-first, token-decorated with direction + counterparty. Defaults to your wallet, last 50. Pass `decodeMethods: true` to also label the function behind each transfer (transfer / swap / supply / ...) for an audit deep-dive.',
249
+ searchHint: 'activity transfers history events recent audit decode method what happened',
250
+ schema: ActivitySchema,
251
+ handler: async args => {
252
+ try {
253
+ const target = args.address ? (getAddress(args.address) as Address) : ctx.agentEoa
254
+ const limit = args.limit ?? 50
255
+ const head = await ctx.publicClient.getBlockNumber()
256
+ const padded = pad(target, { size: 32 })
257
+ const events: Array<{
258
+ blockNumber: number
259
+ txHash: string
260
+ logIndex: number
261
+ token: string
262
+ from: string
263
+ to: string
264
+ value: bigint
265
+ direction: 'in' | 'out'
266
+ }> = []
267
+ // Walk backwards in chunks until we have `limit` events or hit mintBlock
268
+ let cursor = head
269
+ let chunks = 0
270
+ while (events.length < limit && chunks < LOG_SCAN_MAX_CHUNKS && cursor > ctx.mintBlock) {
271
+ const start = cursor - LOG_SCAN_CHUNK_BLOCKS + 1n
272
+ const from = start > ctx.mintBlock ? start : ctx.mintBlock
273
+ for (const direction of ['in', 'out'] as const) {
274
+ const topics: Array<`0x${string}` | null> =
275
+ direction === 'in' ? [TRANSFER_TOPIC0, null, padded] : [TRANSFER_TOPIC0, padded, null]
276
+ try {
277
+ const logs = await rawGetLogs({
278
+ client: ctx.publicClient,
279
+ topics,
280
+ fromBlock: from,
281
+ toBlock: cursor,
282
+ })
283
+ for (const l of logs) {
284
+ const fromAddr = `0x${(l.topics[1] ?? '').slice(-40)}`
285
+ const toAddr = `0x${(l.topics[2] ?? '').slice(-40)}`
286
+ const value = BigInt(l.data || '0x0')
287
+ events.push({
288
+ blockNumber: Number(BigInt(l.blockNumber)),
289
+ txHash: l.transactionHash,
290
+ logIndex: Number(BigInt(l.logIndex)),
291
+ token: l.address,
292
+ from: fromAddr,
293
+ to: toAddr,
294
+ value,
295
+ direction,
296
+ })
297
+ }
298
+ } catch {
299
+ // skip chunk on failure
300
+ }
301
+ }
302
+ cursor = from - 1n
303
+ chunks += 1
304
+ }
305
+ events.sort((a, b) => b.blockNumber - a.blockNumber || b.logIndex - a.logIndex)
306
+ const trimmed = events.slice(0, limit)
307
+ const cache = loadTokenCache(ctx.agentDir)
308
+ const decorated = trimmed.map(e => {
309
+ const meta = lookupFromList(e.token, cache)
310
+ return {
311
+ blockNumber: e.blockNumber,
312
+ txHash: e.txHash,
313
+ direction: e.direction,
314
+ token: meta
315
+ ? {
316
+ symbol: meta.symbol,
317
+ address: e.token,
318
+ decimals: meta.decimals,
319
+ formatted: formatUnits(e.value, meta.decimals),
320
+ }
321
+ : { symbol: '?', address: e.token, decimals: 0, formatted: e.value.toString() },
322
+ from: e.from,
323
+ to: e.to,
324
+ counterparty: e.direction === 'in' ? e.from : e.to,
325
+ }
326
+ })
327
+ if (!args.decodeMethods) {
328
+ return { ok: true, data: { address: target, count: decorated.length, events: decorated } }
329
+ }
330
+
331
+ // Opt-in: decode the function behind each transfer (one RPC per unique
332
+ // tx, capped). Reuses chain.tx's decoder (vendored ABIs + 4byte).
333
+ const uniqueTxs = [...new Set(decorated.map(e => e.txHash))].slice(0, ACTIVITY_DECODE_CAP)
334
+ const decodedPairs = await Promise.all(
335
+ uniqueTxs.map(async h => {
336
+ const tx = await ctx.publicClient
337
+ .getTransaction({ hash: h as `0x${string}` })
338
+ .catch(() => null)
339
+ if (!tx) return [h, null] as const
340
+ const d = await decodeCalldata({
341
+ data: tx.input as `0x${string}`,
342
+ agentDir: ctx.agentDir,
343
+ }).catch(() => null)
344
+ if (!d) return [h, null] as const
345
+ const method =
346
+ 'name' in d
347
+ ? { name: d.name, signature: d.signature, source: d.source }
348
+ : { selector: d.selector, source: 'unknown' as const }
349
+ return [h, method] as const
350
+ }),
351
+ )
352
+ const methodByTx = Object.fromEntries(decodedPairs)
353
+ const eventsWithMethods = decorated.map(e => ({
354
+ ...e,
355
+ method: methodByTx[e.txHash] ?? null,
356
+ }))
357
+ return {
358
+ ok: true,
359
+ data: {
360
+ address: target,
361
+ count: eventsWithMethods.length,
362
+ decodedTxCount: decodedPairs.filter(([, m]) => m !== null).length,
363
+ events: eventsWithMethods,
364
+ },
365
+ }
366
+ } catch (e) {
367
+ return { ok: false, error: (e as Error).message.slice(0, 240) }
368
+ }
369
+ },
370
+ }
371
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * `chain.balance` — read native + ERC-20 balances.
3
+ *
4
+ * - No args: full discovered snapshot (Multicall3 + Transfer-event scan).
5
+ * - `token` arg: single token. Native if symbol ∈ {Mantle, OG, native}; else
6
+ * resolves via `tokens.ts` cache → list → on-chain.
7
+ * - `address` arg: read for any address (default = agent EOA).
8
+ */
9
+
10
+ import type { ToolDef } from 'nebula-ai-core'
11
+ import { type Address, formatEther, formatUnits, getAddress } from 'viem'
12
+ import { z } from 'zod'
13
+ import { ERC20_ABI } from '../abis'
14
+ import { snapshotBalances } from '../balances'
15
+ import { isNativeToken, nativeTokenInfo, resolveToken } from '../tokens'
16
+ import type { OnchainRuntimeContext } from '../types'
17
+
18
+ const Schema = z.object({
19
+ token: z
20
+ .string()
21
+ .optional()
22
+ .describe(
23
+ 'Optional symbol or 0x address. Omit for the full holdings snapshot. Use "Mantle"/"native" for native.',
24
+ ),
25
+ address: z
26
+ .string()
27
+ .optional()
28
+ .describe('Optional 0x address to inspect (default: your agent EOA).'),
29
+ refresh: z
30
+ .boolean()
31
+ .optional()
32
+ .describe(
33
+ 'Force re-discovery from the iNFT mint block (ignore cached last-scanned block). Slower; use after a tx if the cache looks stale.',
34
+ ),
35
+ })
36
+ type Args = z.infer<typeof Schema>
37
+
38
+ export function makeChainBalance(ctx: OnchainRuntimeContext): ToolDef<Args> {
39
+ return {
40
+ name: 'chain.balance',
41
+ description:
42
+ 'Read native + ERC-20 balances on Mantle. No args = full discovered snapshot for your wallet (Multicall3 + Transfer-event auto-discovery; no curated list). Pass `token` for a specific asset, `address` to inspect another wallet.',
43
+ searchHint: 'wallet balance erc20 native holdings discover',
44
+ schema: Schema,
45
+ handler: async args => {
46
+ try {
47
+ const target = args.address ? (getAddress(args.address) as Address) : ctx.agentEoa
48
+ if (args.token) {
49
+ if (isNativeToken(args.token)) {
50
+ const wei = await ctx.publicClient.getBalance({ address: target })
51
+ const native = nativeTokenInfo()
52
+ return {
53
+ ok: true,
54
+ data: {
55
+ address: target,
56
+ token: native.symbol,
57
+ raw: wei.toString(),
58
+ formatted: formatEther(wei),
59
+ decimals: native.decimals,
60
+ },
61
+ }
62
+ }
63
+ const token = await resolveToken({
64
+ client: ctx.publicClient,
65
+ agentDir: ctx.agentDir,
66
+ input: args.token,
67
+ })
68
+ if (!token) {
69
+ return {
70
+ ok: false,
71
+ error: `unknown token: ${args.token}. Try a 0x address.`,
72
+ }
73
+ }
74
+ const wei = (await ctx.publicClient.readContract({
75
+ address: token.address,
76
+ abi: ERC20_ABI,
77
+ functionName: 'balanceOf',
78
+ args: [target],
79
+ })) as bigint
80
+ return {
81
+ ok: true,
82
+ data: {
83
+ address: target,
84
+ token: token.symbol,
85
+ tokenAddress: token.address,
86
+ raw: wei.toString(),
87
+ formatted: formatUnits(wei, token.decimals),
88
+ decimals: token.decimals,
89
+ },
90
+ }
91
+ }
92
+ const snap = await snapshotBalances({
93
+ client: ctx.publicClient,
94
+ agentDir: ctx.agentDir,
95
+ address: target,
96
+ mintBlock: ctx.mintBlock,
97
+ refresh: args.refresh ?? false,
98
+ })
99
+ return {
100
+ ok: true,
101
+ data: {
102
+ address: snap.address,
103
+ blockNumber: snap.blockNumber,
104
+ native: snap.native,
105
+ tokens: snap.tokens.map(t => ({
106
+ symbol: t.symbol,
107
+ address: t.address,
108
+ decimals: t.decimals,
109
+ raw: t.raw,
110
+ formatted: t.formatted,
111
+ })),
112
+ },
113
+ }
114
+ } catch (e) {
115
+ return { ok: false, error: (e as Error).message.slice(0, 240) }
116
+ }
117
+ },
118
+ }
119
+ }