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
|
@@ -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
|
+
}
|