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,94 @@
1
+ {
2
+ "name": "Jaine Token List",
3
+ "timestamp": "2025-01-03T20:47:02.923Z",
4
+ "version": {
5
+ "major": 1,
6
+ "minor": 0,
7
+ "patch": 0
8
+ },
9
+ "tags": {},
10
+ "logoURI": "ipfs://QmNa8mQkrNKp1WEEeGjFezDmDeodkWRevGFN8JCV7b4Xir",
11
+ "keywords": ["jaine", "active"],
12
+ "tokens": [
13
+ {
14
+ "name": "stOG",
15
+ "address": "0x7bbc63d01ca42491c3e084c941c3e86e55951404",
16
+ "symbol": "st0G",
17
+ "decimals": 18,
18
+ "chainId": 16661,
19
+ "logoURI": "https://raw.githubusercontent.com/Mantle-X/jaine-token-lists/main/assets/mainnet/0x7bbc63d01ca42491c3e084c941c3e86e55951404/stOG.svg"
20
+ },
21
+ {
22
+ "name": "USDCe",
23
+ "address": "0x1f3aa82227281ca364bfb3d253b0f1af1da6473e",
24
+ "symbol": "USDCe",
25
+ "decimals": 6,
26
+ "chainId": 16661,
27
+ "logoURI": "https://raw.githubusercontent.com/Mantle-X/jaine-token-lists/main/assets/mainnet/USDCe.svg"
28
+ },
29
+ {
30
+ "name": "wstETH",
31
+ "address": "0x161a128567bf0c005b58211757f7e46eed983f02",
32
+ "symbol": "wstETH",
33
+ "decimals": 18,
34
+ "chainId": 16661,
35
+ "logoURI": "https://raw.githubusercontent.com/Mantle-X/jaine-token-lists/main/assets/mainnet/wstETH.svg"
36
+ },
37
+ {
38
+ "name": "PAI",
39
+ "address": "0x59ef6f3943bbdfe2fb19565037ac85071223e94c",
40
+ "symbol": "PAI",
41
+ "decimals": 9,
42
+ "chainId": 16661,
43
+ "logoURI": "https://raw.githubusercontent.com/Mantle-X/jaine-token-lists/main/assets/mainnet/PAI.svg"
44
+ },
45
+ {
46
+ "name": "wETH",
47
+ "address": "0x564770837ef8bbf077cfe54e5f6106538c815b22",
48
+ "symbol": "wETH",
49
+ "decimals": 18,
50
+ "chainId": 16661,
51
+ "logoURI": "https://raw.githubusercontent.com/Mantle-X/jaine-token-lists/main/assets/mainnet/wETH.svg"
52
+ },
53
+ {
54
+ "name": "oUSDT",
55
+ "address": "0x1217bfe6c773eec6cc4a38b5dc45b92292b6e189",
56
+ "symbol": "oUSDT",
57
+ "decimals": 6,
58
+ "chainId": 16661,
59
+ "logoURI": "https://raw.githubusercontent.com/Mantle-X/jaine-token-lists/main/assets/mainnet/oUSDT.svg"
60
+ },
61
+ {
62
+ "name": "LINK",
63
+ "address": "0x76159c2b43ff6f630193e37ec68452169914c1bb",
64
+ "symbol": "LINK",
65
+ "decimals": 18,
66
+ "chainId": 16661,
67
+ "logoURI": "https://raw.githubusercontent.com/Mantle-X/jaine-token-lists/main/assets/mainnet/Link.svg"
68
+ },
69
+ {
70
+ "name": "WBTC",
71
+ "address": "0x0555e30da8f98308edb960aa94c0db47230d2b9c",
72
+ "symbol": "wBTC",
73
+ "decimals": 8,
74
+ "chainId": 16661,
75
+ "logoURI": "https://raw.githubusercontent.com/Mantle-X/jaine-token-lists/main/assets/mainnet/wBTC.png"
76
+ },
77
+ {
78
+ "name": "cbBTC",
79
+ "address": "0xa5613ac7f1e83a68719b1398c8f6aaa25581db82",
80
+ "symbol": "CoinbaseBTC",
81
+ "decimals": 8,
82
+ "chainId": 16661,
83
+ "logoURI": "https://raw.githubusercontent.com/Mantle-X/jaine-token-lists/main/assets/mainnet/cbBTC.svg"
84
+ },
85
+ {
86
+ "name": "HaiO",
87
+ "address": "0xbCB868d3d0A823ced82B77DD72E4f7A3C720A40E",
88
+ "symbol": "HAIO",
89
+ "decimals": 9,
90
+ "chainId": 16661,
91
+ "logoURI": "https://raw.githubusercontent.com/Mantle-X/jaine-token-lists/main/assets/mainnet/HaiO_SVG.svg"
92
+ }
93
+ ]
94
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "nebula-ai-plugin-onchain",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "The Mantle on-chain tools for nebula: transfers, Agni + Merchant Moe swaps, Aave V3 lending, DeFiLlama discovery, and ERC-8004 identity — every write policy + simulation gated",
6
+ "license": "MIT",
7
+ "homepage": "https://github.com/rstfulzz/nebula",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/rstfulzz/nebula.git",
11
+ "directory": "packages/plugin-onchain"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/rstfulzz/nebula/issues"
15
+ },
16
+ "keywords": [
17
+ "nebula",
18
+ "ai",
19
+ "agent",
20
+ "mantle",
21
+ "defi",
22
+ "swap",
23
+ "aave",
24
+ "agni",
25
+ "erc-8004",
26
+ "plugin"
27
+ ],
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "engines": {
32
+ "bun": ">=1.1"
33
+ },
34
+ "files": [
35
+ "src",
36
+ "!src/**/*.test.ts",
37
+ "abis",
38
+ "data",
39
+ "README.md"
40
+ ],
41
+ "main": "./src/index.ts",
42
+ "types": "./src/index.ts",
43
+ "scripts": {
44
+ "build": "tsc -b",
45
+ "test": "bun test"
46
+ },
47
+ "dependencies": {
48
+ "nebula-ai-core": "0.1.0",
49
+ "viem": "^2.21.55",
50
+ "zod": "^3.23.8"
51
+ }
52
+ }
package/src/aave.ts ADDED
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Aave V3 adapter for Mantle. Pool verified live (getReservesList returns the
3
+ * supported markets). We expose supply / withdraw (writes, guarded by the
4
+ * policy + simulate pipeline) and a read-only position view (health factor).
5
+ */
6
+ import { type Address, type PublicClient, erc20Abi, parseAbi } from 'viem'
7
+ import { MULTICALL3 } from './constants'
8
+
9
+ export const AAVE_V3_POOL_ABI = [
10
+ {
11
+ name: 'supply',
12
+ type: 'function',
13
+ stateMutability: 'nonpayable',
14
+ inputs: [
15
+ { name: 'asset', type: 'address' },
16
+ { name: 'amount', type: 'uint256' },
17
+ { name: 'onBehalfOf', type: 'address' },
18
+ { name: 'referralCode', type: 'uint16' },
19
+ ],
20
+ outputs: [],
21
+ },
22
+ {
23
+ name: 'withdraw',
24
+ type: 'function',
25
+ stateMutability: 'nonpayable',
26
+ inputs: [
27
+ { name: 'asset', type: 'address' },
28
+ { name: 'amount', type: 'uint256' },
29
+ { name: 'to', type: 'address' },
30
+ ],
31
+ outputs: [{ type: 'uint256' }],
32
+ },
33
+ {
34
+ name: 'borrow',
35
+ type: 'function',
36
+ stateMutability: 'nonpayable',
37
+ inputs: [
38
+ { name: 'asset', type: 'address' },
39
+ { name: 'amount', type: 'uint256' },
40
+ { name: 'interestRateMode', type: 'uint256' },
41
+ { name: 'referralCode', type: 'uint16' },
42
+ { name: 'onBehalfOf', type: 'address' },
43
+ ],
44
+ outputs: [],
45
+ },
46
+ {
47
+ name: 'repay',
48
+ type: 'function',
49
+ stateMutability: 'nonpayable',
50
+ inputs: [
51
+ { name: 'asset', type: 'address' },
52
+ { name: 'amount', type: 'uint256' },
53
+ { name: 'interestRateMode', type: 'uint256' },
54
+ { name: 'onBehalfOf', type: 'address' },
55
+ ],
56
+ outputs: [{ type: 'uint256' }],
57
+ },
58
+ {
59
+ name: 'getUserAccountData',
60
+ type: 'function',
61
+ stateMutability: 'view',
62
+ inputs: [{ name: 'user', type: 'address' }],
63
+ outputs: [
64
+ { name: 'totalCollateralBase', type: 'uint256' },
65
+ { name: 'totalDebtBase', type: 'uint256' },
66
+ { name: 'availableBorrowsBase', type: 'uint256' },
67
+ { name: 'currentLiquidationThreshold', type: 'uint256' },
68
+ { name: 'ltv', type: 'uint256' },
69
+ { name: 'healthFactor', type: 'uint256' },
70
+ ],
71
+ },
72
+ {
73
+ name: 'getReservesList',
74
+ type: 'function',
75
+ stateMutability: 'view',
76
+ inputs: [],
77
+ outputs: [{ type: 'address[]' }],
78
+ },
79
+ ] as const
80
+
81
+ /** Full-balance sentinel for Aave withdraw (and full-debt sentinel for repay). */
82
+ export const AAVE_MAX_WITHDRAW = (1n << 256n) - 1n
83
+
84
+ /** Aave V3 interest rate mode: 2 = variable (stable rate mode is deprecated). */
85
+ export const AAVE_VARIABLE_RATE = 2n
86
+
87
+ /** getReserveData returns the V3 ReserveData struct; we read the two rate fields. */
88
+ const AAVE_RESERVE_DATA_ABI = parseAbi([
89
+ 'struct ReserveData { uint256 configuration; uint128 liquidityIndex; uint128 currentLiquidityRate; uint128 variableBorrowIndex; uint128 currentVariableBorrowRate; uint128 currentStableBorrowRate; uint40 lastUpdateTimestamp; uint16 id; address aTokenAddress; address stableDebtTokenAddress; address variableDebtTokenAddress; address interestRateStrategyAddress; uint128 accruedToTreasury; uint128 unbacked; uint128 isolationModeTotalDebt; }',
90
+ 'function getReserveData(address asset) view returns (ReserveData)',
91
+ 'function getReservesList() view returns (address[])',
92
+ ])
93
+
94
+ const RAY = 10n ** 27n
95
+
96
+ /** Aave rates are per-second APR scaled to RAY (1e27). APR% = rate / 1e27 * 100. */
97
+ export function rayToAprPct(rateRay: bigint): number {
98
+ return Number((rateRay * 1_000_000n) / RAY) / 10_000
99
+ }
100
+
101
+ export interface AaveMarket {
102
+ symbol: string
103
+ address: Address
104
+ supplyAprPct: number
105
+ variableBorrowAprPct: number
106
+ }
107
+
108
+ /** Read every Aave V3 reserve on Mantle with its live supply + variable-borrow APR. */
109
+ export async function readAaveMarkets(client: PublicClient, pool: Address): Promise<AaveMarket[]> {
110
+ const reserves = (await client.readContract({
111
+ address: pool,
112
+ abi: AAVE_RESERVE_DATA_ABI,
113
+ functionName: 'getReservesList',
114
+ })) as readonly Address[]
115
+
116
+ // One Multicall3 batch instead of 2N parallel calls (public RPCs rate-limit
117
+ // a 20-call burst). For each reserve: getReserveData(pool) + symbol(asset).
118
+ const contracts = reserves.flatMap(asset => [
119
+ {
120
+ address: pool,
121
+ abi: AAVE_RESERVE_DATA_ABI,
122
+ functionName: 'getReserveData' as const,
123
+ args: [asset] as const,
124
+ },
125
+ { address: asset, abi: erc20Abi, functionName: 'symbol' as const },
126
+ ])
127
+ const results = await client.multicall({
128
+ contracts,
129
+ allowFailure: true,
130
+ multicallAddress: MULTICALL3,
131
+ })
132
+
133
+ return reserves.map((asset, i) => {
134
+ const dRes = results[i * 2]
135
+ const symRes = results[i * 2 + 1]
136
+ const d =
137
+ dRes?.status === 'success'
138
+ ? (dRes.result as { currentLiquidityRate: bigint; currentVariableBorrowRate: bigint })
139
+ : { currentLiquidityRate: 0n, currentVariableBorrowRate: 0n }
140
+ const symbol = symRes?.status === 'success' ? (symRes.result as string) : '?'
141
+ return {
142
+ symbol,
143
+ address: asset,
144
+ supplyAprPct: rayToAprPct(d.currentLiquidityRate),
145
+ variableBorrowAprPct: rayToAprPct(d.currentVariableBorrowRate),
146
+ }
147
+ })
148
+ }
149
+
150
+ export interface AaveAccount {
151
+ totalCollateralBase: bigint
152
+ totalDebtBase: bigint
153
+ availableBorrowsBase: bigint
154
+ liquidationThresholdBps: bigint
155
+ ltvBps: bigint
156
+ healthFactorRaw: bigint
157
+ }
158
+
159
+ /** Human health factor — '∞ (no debt)' when there is no borrow, else 1e18-scaled. */
160
+ export function formatHealthFactor(raw: bigint): string {
161
+ if (raw >= AAVE_MAX_WITHDRAW) return '∞ (no debt)'
162
+ const whole = raw / 10n ** 18n
163
+ const frac = ((raw % 10n ** 18n) * 100n) / 10n ** 18n
164
+ return `${whole.toString()}.${frac.toString().padStart(2, '0')}`
165
+ }
166
+
167
+ /** Aave base currency is USD with 8 decimals on this market. */
168
+ export function formatBaseUsd(base: bigint): string {
169
+ const whole = base / 10n ** 8n
170
+ const frac = ((base % 10n ** 8n) * 100n) / 10n ** 8n
171
+ return `$${whole.toString()}.${frac.toString().padStart(2, '0')}`
172
+ }
173
+
174
+ export async function readAaveAccount(
175
+ client: PublicClient,
176
+ pool: Address,
177
+ user: Address,
178
+ ): Promise<AaveAccount> {
179
+ const d = (await client.readContract({
180
+ address: pool,
181
+ abi: AAVE_V3_POOL_ABI,
182
+ functionName: 'getUserAccountData',
183
+ args: [user],
184
+ })) as readonly bigint[]
185
+ return {
186
+ totalCollateralBase: d[0] ?? 0n,
187
+ totalDebtBase: d[1] ?? 0n,
188
+ availableBorrowsBase: d[2] ?? 0n,
189
+ liquidationThresholdBps: d[3] ?? 0n,
190
+ ltvBps: d[4] ?? 0n,
191
+ healthFactorRaw: d[5] ?? 0n,
192
+ }
193
+ }
package/src/abis.ts ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Typed ABIs for plugin-onchain. Small ABIs use `parseAbi` inline so viem can
3
+ * infer arg/return types. The big vendored JSON ABIs (SwapRouter, Quoter,
4
+ * Factory) load via JSON import + `as Abi` cast — too large to inline,
5
+ * generated from the canonical AGNI testnet artifacts (bytecode-equivalent
6
+ * on mainnet, verified May 1 2026).
7
+ */
8
+
9
+ import { type Abi, parseAbi } from 'viem'
10
+ import factoryJson from '../abis/factory.json' with { type: 'json' }
11
+ import quoterJson from '../abis/quoter.json' with { type: 'json' }
12
+ import swapRouterJson from '../abis/swap-router.json' with { type: 'json' }
13
+
14
+ export const ERC20_ABI = parseAbi([
15
+ 'function name() view returns (string)',
16
+ 'function symbol() view returns (string)',
17
+ 'function decimals() view returns (uint8)',
18
+ 'function totalSupply() view returns (uint256)',
19
+ 'function balanceOf(address account) view returns (uint256)',
20
+ 'function allowance(address owner, address spender) view returns (uint256)',
21
+ 'function transfer(address to, uint256 amount) returns (bool)',
22
+ 'function approve(address spender, uint256 amount) returns (bool)',
23
+ 'event Transfer(address indexed from, address indexed to, uint256 value)',
24
+ 'event Approval(address indexed owner, address indexed spender, uint256 value)',
25
+ ])
26
+
27
+ export const WETH9_ABI = parseAbi([
28
+ 'function deposit() payable',
29
+ 'function withdraw(uint256 wad)',
30
+ 'function balanceOf(address account) view returns (uint256)',
31
+ 'function totalSupply() view returns (uint256)',
32
+ 'function transfer(address to, uint256 wad) returns (bool)',
33
+ 'function approve(address spender, uint256 wad) returns (bool)',
34
+ 'function name() view returns (string)',
35
+ 'function symbol() view returns (string)',
36
+ 'function decimals() view returns (uint8)',
37
+ ])
38
+
39
+ // `aggregate3` is `payable` on-chain but we only ever call it for batched
40
+ // reads (no msg.value). Marking it `view` here lets viem's `readContract`
41
+ // type-narrowing keep `aggregate3` callable; the runtime contract still
42
+ // accepts the call without msg.value.
43
+ export const MULTICALL3_ABI = parseAbi([
44
+ 'function aggregate3((address target, bool allowFailure, bytes callData)[] calls) view returns ((bool success, bytes returnData)[] returnData)',
45
+ 'function getEthBalance(address addr) view returns (uint256 balance)',
46
+ 'function getBlockNumber() view returns (uint256 blockNumber)',
47
+ ])
48
+
49
+ export const SWAP_ROUTER_ABI = swapRouterJson as Abi
50
+ export const QUOTER_ABI = quoterJson as Abi
51
+ export const FACTORY_ABI = factoryJson as Abi
52
+
53
+ /**
54
+ * Merchant Moe Liquidity Book quoter. `findBestPathFromAmountIn` scans LB pairs
55
+ * for the best route; the last element of `amounts` is the output. `binSteps`
56
+ * + `versions` + `route` feed directly into the router's swap `Path`.
57
+ */
58
+ export const LB_QUOTER_ABI = parseAbi([
59
+ 'struct Quote { address[] route; address[] pairs; uint256[] binSteps; uint8[] versions; uint128[] amounts; uint128[] virtualAmountsWithoutSlippage; uint128[] fees; }',
60
+ 'function findBestPathFromAmountIn(address[] route, uint128 amountIn) view returns (Quote)',
61
+ ])
62
+
63
+ /**
64
+ * Merchant Moe Liquidity Book router. The `Path` struct carries the per-hop bin
65
+ * steps + pair versions (V1=0, V2=1, V2_1=2, V2_2=3) + token path returned by
66
+ * the quoter. Native legs go through WNATIVE (WMNT) automatically.
67
+ */
68
+ export const LB_ROUTER_ABI = parseAbi([
69
+ 'struct Path { uint256[] pairBinSteps; uint8[] versions; address[] tokenPath; }',
70
+ 'function getWNATIVE() view returns (address)',
71
+ 'function swapExactTokensForTokens(uint256 amountIn, uint256 amountOutMin, Path path, address to, uint256 deadline) returns (uint256 amountOut)',
72
+ 'function swapExactNATIVEForTokens(uint256 amountOutMin, Path path, address to, uint256 deadline) payable returns (uint256 amountOut)',
73
+ 'function swapExactTokensForNATIVE(uint256 amountIn, uint256 amountOutMinNATIVE, Path path, address to, uint256 deadline) returns (uint256 amountOut)',
74
+ ])
75
+
76
+ /** All known function fragments concatenated, for `analysis.decodeCalldata`. */
77
+ export const ALL_KNOWN_ABIS: Abi = [
78
+ ...(SWAP_ROUTER_ABI as readonly unknown[]),
79
+ ...(QUOTER_ABI as readonly unknown[]),
80
+ ...(FACTORY_ABI as readonly unknown[]),
81
+ ...(WETH9_ABI as readonly unknown[]),
82
+ ...(ERC20_ABI as readonly unknown[]),
83
+ ...(MULTICALL3_ABI as readonly unknown[]),
84
+ ] as Abi
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Shared ERC-20 allowance helper. Reads current allowance via Multicall3,
3
+ * sends approve(spender, maxUint256) only if insufficient. Used by
4
+ * `swap.execute` (Agni router) and `aave.supply` (Aave Pool).
5
+ */
6
+
7
+ import { getGasPriceWithFloor } from 'nebula-ai-core'
8
+ import {
9
+ type Address,
10
+ type PublicClient,
11
+ type WalletClient,
12
+ decodeFunctionResult,
13
+ encodeFunctionData,
14
+ getAddress,
15
+ maxUint256,
16
+ } from 'viem'
17
+ import { ERC20_ABI, MULTICALL3_ABI } from './abis'
18
+ import { MULTICALL3 } from './constants'
19
+ import { waitForReceipt } from './wait-receipt'
20
+
21
+ export async function readAllowance(opts: {
22
+ client: PublicClient
23
+ token: Address
24
+ owner: Address
25
+ spender: Address
26
+ }): Promise<bigint> {
27
+ const { client, token, owner, spender } = opts
28
+ const calls = [
29
+ {
30
+ target: token,
31
+ allowFailure: false,
32
+ callData: encodeFunctionData({
33
+ abi: ERC20_ABI,
34
+ functionName: 'allowance',
35
+ args: [owner, spender],
36
+ }),
37
+ },
38
+ ]
39
+ const results = (await client.readContract({
40
+ address: MULTICALL3,
41
+ abi: MULTICALL3_ABI,
42
+ functionName: 'aggregate3',
43
+ args: [calls],
44
+ })) as ReadonlyArray<{ success: boolean; returnData: `0x${string}` }>
45
+ return decodeFunctionResult({
46
+ abi: ERC20_ABI,
47
+ functionName: 'allowance',
48
+ data: results[0]!.returnData,
49
+ }) as bigint
50
+ }
51
+
52
+ export async function ensureAllowance(opts: {
53
+ publicClient: PublicClient
54
+ walletClient: WalletClient
55
+ token: Address
56
+ owner: Address
57
+ spender: Address
58
+ amount: bigint
59
+ }): Promise<{ approved: boolean; txHash?: `0x${string}` }> {
60
+ const { publicClient, walletClient, token, owner, spender, amount } = opts
61
+ const current = await readAllowance({ client: publicClient, token, owner, spender })
62
+ if (current >= amount) return { approved: false }
63
+ const gasPrice = await getGasPriceWithFloor(publicClient)
64
+ const account = walletClient.account
65
+ if (!account) throw new Error('walletClient has no account; cannot approve')
66
+ const txHash = await walletClient.writeContract({
67
+ address: getAddress(token) as Address,
68
+ abi: ERC20_ABI,
69
+ functionName: 'approve',
70
+ args: [spender, maxUint256],
71
+ chain: walletClient.chain,
72
+ account,
73
+ gasPrice,
74
+ })
75
+ await waitForReceipt(publicClient, txHash)
76
+ return { approved: true, txHash }
77
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Tx + contract analysis helpers. Tries our local ABI library first, falls
3
+ * back to the 4byte directory for unknown selectors with a canonical-first
4
+ * filter (longest, lowercase, simplest signature wins; spam filtered out).
5
+ */
6
+
7
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
8
+ import { dirname, join } from 'node:path'
9
+ import { type AbiFunction, decodeFunctionData, parseAbiItem, toFunctionSelector } from 'viem'
10
+ import { ALL_KNOWN_ABIS } from './abis'
11
+
12
+ const KNOWN_ABIS = ALL_KNOWN_ABIS as readonly AbiFunction[]
13
+
14
+ /**
15
+ * Build a selector → abi item map from KNOWN_ABIS. Multiple ABIs can include
16
+ * the same selector (e.g. `transfer` in both ERC-20 and W0G); we keep the
17
+ * first hit since they share the same signature anyway.
18
+ */
19
+ const KNOWN_SELECTORS = (() => {
20
+ const map = new Map<string, AbiFunction>()
21
+ for (const item of KNOWN_ABIS) {
22
+ if ((item as AbiFunction).type !== 'function') continue
23
+ const fn = item as AbiFunction
24
+ try {
25
+ const sel = toFunctionSelector(fn).toLowerCase()
26
+ if (!map.has(sel)) map.set(sel, fn)
27
+ } catch {
28
+ // skip items that don't selector-encode (events, errors)
29
+ }
30
+ }
31
+ return map
32
+ })()
33
+
34
+ interface FourByteCacheFile {
35
+ version: 1
36
+ hits: Record<string, string> // selector → canonical signature
37
+ }
38
+
39
+ function fourByteCachePath(agentDir: string): string {
40
+ return join(agentDir, 'onchain', '4byte-cache.json')
41
+ }
42
+
43
+ function loadFourByteCache(agentDir: string): FourByteCacheFile {
44
+ const path = fourByteCachePath(agentDir)
45
+ if (!existsSync(path)) return { version: 1, hits: {} }
46
+ try {
47
+ const raw = readFileSync(path, 'utf8')
48
+ const parsed = JSON.parse(raw) as FourByteCacheFile
49
+ if (parsed?.version === 1 && parsed.hits) return parsed
50
+ return { version: 1, hits: {} }
51
+ } catch {
52
+ return { version: 1, hits: {} }
53
+ }
54
+ }
55
+
56
+ function saveFourByteCache(agentDir: string, cache: FourByteCacheFile): void {
57
+ const path = fourByteCachePath(agentDir)
58
+ mkdirSync(dirname(path), { recursive: true })
59
+ writeFileSync(path, JSON.stringify(cache, null, 2))
60
+ }
61
+
62
+ interface FourByteResult {
63
+ text_signature: string
64
+ hex_signature: string
65
+ bytes_signature: string
66
+ }
67
+
68
+ /**
69
+ * Pick the most "canonical" signature out of 4byte's results. Spam bots
70
+ * register PascalCase or all-caps names whose hashes collide with real
71
+ * selectors; filter those out, then prefer fewer args + shorter names.
72
+ */
73
+ function pickCanonical(results: FourByteResult[]): string | null {
74
+ if (results.length === 0) return null
75
+ const scored = results
76
+ .map(r => {
77
+ const sig = r.text_signature
78
+ const fnName = sig.split('(')[0] ?? sig
79
+ const argList = sig.slice(fnName.length + 1, -1)
80
+ const argCount = argList.length === 0 ? 0 : argList.split(',').length
81
+ const looksLikeSpam =
82
+ /^[A-Z][a-zA-Z]*$/.test(fnName) || /[A-Z]{4,}/.test(fnName) || fnName.length > 32
83
+ return { sig, fnName, argCount, looksLikeSpam }
84
+ })
85
+ .filter(s => !s.looksLikeSpam)
86
+ .sort((a, b) => {
87
+ if (a.argCount !== b.argCount) return a.argCount - b.argCount
88
+ if (a.fnName.length !== b.fnName.length) return a.fnName.length - b.fnName.length
89
+ return a.fnName.localeCompare(b.fnName)
90
+ })
91
+ return scored[0]?.sig ?? null
92
+ }
93
+
94
+ async function lookup4byte(selector: string): Promise<FourByteResult[]> {
95
+ const url = `https://www.4byte.directory/api/v1/signatures/?hex_signature=${selector}`
96
+ const ctrl = new AbortController()
97
+ const t = setTimeout(() => ctrl.abort(), 5000)
98
+ try {
99
+ const res = await fetch(url, { signal: ctrl.signal })
100
+ if (!res.ok) return []
101
+ const json = (await res.json()) as { results?: FourByteResult[] }
102
+ return json.results ?? []
103
+ } catch {
104
+ return []
105
+ } finally {
106
+ clearTimeout(t)
107
+ }
108
+ }
109
+
110
+ export interface DecodedFunction {
111
+ name: string
112
+ signature: string
113
+ args: unknown[]
114
+ source: 'local' | '4byte' | 'cache'
115
+ }
116
+
117
+ export async function decodeCalldata(opts: {
118
+ data: `0x${string}`
119
+ agentDir: string
120
+ }): Promise<DecodedFunction | { selector: `0x${string}`; source: 'unknown' }> {
121
+ const { data, agentDir } = opts
122
+ if (data.length < 10) {
123
+ return { selector: '0x' as `0x${string}`, source: 'unknown' }
124
+ }
125
+ const selector = data.slice(0, 10).toLowerCase() as `0x${string}`
126
+ // Local ABI hit
127
+ const localHit = KNOWN_SELECTORS.get(selector)
128
+ if (localHit) {
129
+ try {
130
+ const decoded = decodeFunctionData({
131
+ abi: [localHit] as readonly [AbiFunction],
132
+ data,
133
+ })
134
+ return {
135
+ name: localHit.name,
136
+ signature: formatAbiFunction(localHit),
137
+ args: Array.isArray(decoded.args) ? [...decoded.args] : [],
138
+ source: 'local',
139
+ }
140
+ } catch {
141
+ // fall through to 4byte
142
+ }
143
+ }
144
+ // Cache hit
145
+ const cache = loadFourByteCache(agentDir)
146
+ const cached = cache.hits[selector]
147
+ if (cached) {
148
+ const args = tryDecodeWithSignature(cached, data)?.args ?? []
149
+ return {
150
+ name: cached.split('(')[0] ?? cached,
151
+ signature: cached,
152
+ args,
153
+ source: 'cache',
154
+ }
155
+ }
156
+ // 4byte fallback
157
+ const results = await lookup4byte(selector)
158
+ const canonical = pickCanonical(results)
159
+ if (!canonical) {
160
+ return { selector, source: 'unknown' }
161
+ }
162
+ cache.hits[selector] = canonical
163
+ saveFourByteCache(agentDir, cache)
164
+ const out = tryDecodeWithSignature(canonical, data)
165
+ return {
166
+ name: canonical.split('(')[0] ?? canonical,
167
+ signature: canonical,
168
+ args: out?.args ?? [],
169
+ source: '4byte',
170
+ }
171
+ }
172
+
173
+ function tryDecodeWithSignature(
174
+ sig: string,
175
+ data: `0x${string}`,
176
+ ): { decoded: boolean; args: unknown[] } | null {
177
+ try {
178
+ const fn = parseAbiItem(`function ${sig}`) as AbiFunction
179
+ const decoded = decodeFunctionData({
180
+ abi: [fn] as readonly [AbiFunction],
181
+ data,
182
+ })
183
+ return {
184
+ decoded: true,
185
+ args: Array.isArray(decoded.args) ? [...decoded.args] : [],
186
+ }
187
+ } catch {
188
+ return null
189
+ }
190
+ }
191
+
192
+ function formatAbiFunction(fn: AbiFunction): string {
193
+ const params = fn.inputs.map(p => p.type).join(',')
194
+ return `${fn.name}(${params})`
195
+ }