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/policy.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic on-chain policy engine — Nebula's "verifiable autonomy" core.
|
|
3
|
+
*
|
|
4
|
+
* The project rule (CLAUDE.md): the AI is advisory; fund controls are enforced
|
|
5
|
+
* in deterministic code, NOT by the model. Every write is checked here BEFORE
|
|
6
|
+
* it is simulated/broadcast. The verdict is a pure function of (action, policy)
|
|
7
|
+
* — no network, no model — so it is fully unit-testable and auditable.
|
|
8
|
+
*
|
|
9
|
+
* Order of the write pipeline: policy → simulate → (approval) → execute → receipt.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Address normalization for case-insensitive comparison. */
|
|
13
|
+
const lc = (a: string): string => a.toLowerCase()
|
|
14
|
+
|
|
15
|
+
export interface OnchainPolicy {
|
|
16
|
+
/** Reject every write (read-only treasury). */
|
|
17
|
+
readOnly?: boolean
|
|
18
|
+
/** Hard cap on native MNT per tx, in wei. */
|
|
19
|
+
maxNativeWeiPerTx?: bigint
|
|
20
|
+
/** Per-token hard cap in raw units, keyed by lowercased token address. */
|
|
21
|
+
maxTokenRawPerTx?: Record<string, bigint>
|
|
22
|
+
/** If set, only these token addresses (lowercased) may be moved/swapped. 'native' allowed by default. */
|
|
23
|
+
tokenAllowlist?: string[]
|
|
24
|
+
/** If set, transfers may only go to these recipient addresses. */
|
|
25
|
+
recipientAllowlist?: string[]
|
|
26
|
+
/** Max swap slippage tolerance, in basis points. */
|
|
27
|
+
maxSlippageBps?: number
|
|
28
|
+
/**
|
|
29
|
+
* Autonomy tier:
|
|
30
|
+
* - 'auto' execute within caps without asking
|
|
31
|
+
* - 'confirm' every write needs human approval
|
|
32
|
+
* - 'readonly' alias for readOnly=true
|
|
33
|
+
* A native send above `autoMaxNativeWeiPerTx` always escalates to approval.
|
|
34
|
+
*/
|
|
35
|
+
autonomy?: 'auto' | 'confirm' | 'readonly'
|
|
36
|
+
/** Native amount (wei) at/under which 'auto' tier executes without approval. */
|
|
37
|
+
autoMaxNativeWeiPerTx?: bigint
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface PolicyAction {
|
|
41
|
+
kind: 'transfer' | 'swap'
|
|
42
|
+
/** 'native' or a token contract address. For a swap: the INPUT asset. */
|
|
43
|
+
asset: 'native' | string
|
|
44
|
+
/** Amount in raw units (wei for native). */
|
|
45
|
+
amountRaw: bigint
|
|
46
|
+
/** Recipient (transfers only). */
|
|
47
|
+
to?: string
|
|
48
|
+
/** Swap OUTPUT asset ('native' or token address) — checked against the token allowlist. */
|
|
49
|
+
toAsset?: 'native' | string
|
|
50
|
+
/** Swap slippage tolerance in bps. */
|
|
51
|
+
slippageBps?: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface PolicyVerdict {
|
|
55
|
+
/** Hard policy violations — if non-empty the action is BLOCKED. */
|
|
56
|
+
violations: string[]
|
|
57
|
+
/** True when the action is permitted to proceed (no violations). */
|
|
58
|
+
allowed: boolean
|
|
59
|
+
/** True when a permitted action still needs human approval before execution. */
|
|
60
|
+
requiresApproval: boolean
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Evaluate a proposed on-chain action against the policy. Pure + deterministic.
|
|
65
|
+
*/
|
|
66
|
+
export function evaluatePolicy(action: PolicyAction, policy: OnchainPolicy): PolicyVerdict {
|
|
67
|
+
const violations: string[] = []
|
|
68
|
+
const readOnly = policy.readOnly || policy.autonomy === 'readonly'
|
|
69
|
+
if (readOnly) violations.push('policy is read-only: all writes are blocked')
|
|
70
|
+
|
|
71
|
+
const isNative = action.asset === 'native'
|
|
72
|
+
const asset = isNative ? 'native' : lc(action.asset)
|
|
73
|
+
|
|
74
|
+
// Token allowlist (native is always permitted unless 'native' is excluded).
|
|
75
|
+
// Checks the input asset AND, for swaps, the OUTPUT asset — otherwise the
|
|
76
|
+
// agent could swap an allowed token INTO an arbitrary one, defeating the list.
|
|
77
|
+
if (policy.tokenAllowlist) {
|
|
78
|
+
const allowed = policy.tokenAllowlist.map(lc)
|
|
79
|
+
if (!isNative && !allowed.includes(asset)) {
|
|
80
|
+
violations.push(`token ${action.asset} is not in the token allowlist`)
|
|
81
|
+
}
|
|
82
|
+
if (
|
|
83
|
+
action.kind === 'swap' &&
|
|
84
|
+
action.toAsset !== undefined &&
|
|
85
|
+
action.toAsset !== 'native' &&
|
|
86
|
+
!allowed.includes(lc(action.toAsset))
|
|
87
|
+
) {
|
|
88
|
+
violations.push(`swap output token ${action.toAsset} is not in the token allowlist`)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Recipient allowlist (transfers).
|
|
93
|
+
if (policy.recipientAllowlist && action.to) {
|
|
94
|
+
const allowed = policy.recipientAllowlist.map(lc)
|
|
95
|
+
if (!allowed.includes(lc(action.to))) {
|
|
96
|
+
violations.push(`recipient ${action.to} is not in the recipient allowlist`)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Per-tx amount caps.
|
|
101
|
+
if (
|
|
102
|
+
isNative &&
|
|
103
|
+
policy.maxNativeWeiPerTx !== undefined &&
|
|
104
|
+
action.amountRaw > policy.maxNativeWeiPerTx
|
|
105
|
+
) {
|
|
106
|
+
violations.push(
|
|
107
|
+
`native amount ${action.amountRaw} wei exceeds per-tx cap ${policy.maxNativeWeiPerTx} wei`,
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
if (!isNative && policy.maxTokenRawPerTx) {
|
|
111
|
+
const cap = policy.maxTokenRawPerTx[asset]
|
|
112
|
+
if (cap !== undefined && action.amountRaw > cap) {
|
|
113
|
+
violations.push(`amount ${action.amountRaw} exceeds per-tx cap ${cap} for token ${asset}`)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Slippage cap (swaps).
|
|
118
|
+
if (
|
|
119
|
+
action.slippageBps !== undefined &&
|
|
120
|
+
policy.maxSlippageBps !== undefined &&
|
|
121
|
+
action.slippageBps > policy.maxSlippageBps
|
|
122
|
+
) {
|
|
123
|
+
violations.push(`slippage ${action.slippageBps} bps exceeds max ${policy.maxSlippageBps} bps`)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const allowed = violations.length === 0
|
|
127
|
+
|
|
128
|
+
// Approval gate: 'confirm' tier always needs approval; 'auto' escalates only
|
|
129
|
+
// when a native send is above the auto ceiling (material risk).
|
|
130
|
+
let requiresApproval = false
|
|
131
|
+
if (allowed) {
|
|
132
|
+
if (policy.autonomy === 'confirm') {
|
|
133
|
+
requiresApproval = true
|
|
134
|
+
} else if (
|
|
135
|
+
isNative &&
|
|
136
|
+
policy.autoMaxNativeWeiPerTx !== undefined &&
|
|
137
|
+
action.amountRaw > policy.autoMaxNativeWeiPerTx
|
|
138
|
+
) {
|
|
139
|
+
requiresApproval = true
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { violations, allowed, requiresApproval }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Build a policy from environment variables (operator opt-in). Returns
|
|
148
|
+
* undefined when no policy env is set (no enforcement, back-compat).
|
|
149
|
+
* NEBULA_POLICY_READONLY=1
|
|
150
|
+
* NEBULA_POLICY_MAX_NATIVE_MNT=1.5
|
|
151
|
+
* NEBULA_POLICY_AUTO_MAX_NATIVE_MNT=0.1
|
|
152
|
+
* NEBULA_POLICY_MAX_SLIPPAGE_BPS=100
|
|
153
|
+
* NEBULA_POLICY_AUTONOMY=auto|confirm|readonly
|
|
154
|
+
* NEBULA_POLICY_RECIPIENT_ALLOWLIST=0xabc...,0xdef...
|
|
155
|
+
* NEBULA_POLICY_TOKEN_ALLOWLIST=0x...,0x...
|
|
156
|
+
*/
|
|
157
|
+
export function policyFromEnv(
|
|
158
|
+
env: Record<string, string | undefined> = process.env,
|
|
159
|
+
): OnchainPolicy | undefined {
|
|
160
|
+
const toWei = (mnt?: string): bigint | undefined => {
|
|
161
|
+
if (!mnt) return undefined
|
|
162
|
+
const n = Number(mnt)
|
|
163
|
+
if (!Number.isFinite(n) || n < 0) return undefined
|
|
164
|
+
return BigInt(Math.round(n * 1e9)) * 1_000_000_000n // mnt -> wei, 9+9 to avoid float loss
|
|
165
|
+
}
|
|
166
|
+
const list = (s?: string): string[] | undefined =>
|
|
167
|
+
s
|
|
168
|
+
? s
|
|
169
|
+
.split(',')
|
|
170
|
+
.map(x => x.trim())
|
|
171
|
+
.filter(Boolean)
|
|
172
|
+
: undefined
|
|
173
|
+
|
|
174
|
+
const policy: OnchainPolicy = {}
|
|
175
|
+
let any = false
|
|
176
|
+
if (env.NEBULA_POLICY_READONLY === '1') {
|
|
177
|
+
policy.readOnly = true
|
|
178
|
+
any = true
|
|
179
|
+
}
|
|
180
|
+
const maxNative = toWei(env.NEBULA_POLICY_MAX_NATIVE_MNT)
|
|
181
|
+
if (maxNative !== undefined) {
|
|
182
|
+
policy.maxNativeWeiPerTx = maxNative
|
|
183
|
+
any = true
|
|
184
|
+
}
|
|
185
|
+
const autoMax = toWei(env.NEBULA_POLICY_AUTO_MAX_NATIVE_MNT)
|
|
186
|
+
if (autoMax !== undefined) {
|
|
187
|
+
policy.autoMaxNativeWeiPerTx = autoMax
|
|
188
|
+
any = true
|
|
189
|
+
}
|
|
190
|
+
if (env.NEBULA_POLICY_MAX_SLIPPAGE_BPS) {
|
|
191
|
+
const bps = Number(env.NEBULA_POLICY_MAX_SLIPPAGE_BPS)
|
|
192
|
+
if (Number.isFinite(bps) && bps >= 0) {
|
|
193
|
+
policy.maxSlippageBps = bps
|
|
194
|
+
any = true
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const autonomy = env.NEBULA_POLICY_AUTONOMY
|
|
198
|
+
if (autonomy === 'auto' || autonomy === 'confirm' || autonomy === 'readonly') {
|
|
199
|
+
policy.autonomy = autonomy
|
|
200
|
+
any = true
|
|
201
|
+
}
|
|
202
|
+
const recip = list(env.NEBULA_POLICY_RECIPIENT_ALLOWLIST)
|
|
203
|
+
if (recip) {
|
|
204
|
+
policy.recipientAllowlist = recip
|
|
205
|
+
any = true
|
|
206
|
+
}
|
|
207
|
+
const toks = list(env.NEBULA_POLICY_TOKEN_ALLOWLIST)
|
|
208
|
+
if (toks) {
|
|
209
|
+
policy.tokenAllowlist = toks
|
|
210
|
+
any = true
|
|
211
|
+
}
|
|
212
|
+
return any ? policy : undefined
|
|
213
|
+
}
|
package/src/quoter.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AGNI 3-tier quote scan via Factory.getPool + Quoter V1.
|
|
3
|
+
*
|
|
4
|
+
* Critical gotcha (verified May 1 2026 cast probes): AGNI Quoter is V1 (5
|
|
5
|
+
* flat args), NOT V2 (single struct). V2 ABI silently mis-encodes and the
|
|
6
|
+
* call reverts. Pinned via the vendored testnet artifact whose bytecode is
|
|
7
|
+
* equivalent to mainnet.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { type Address, type PublicClient, decodeFunctionResult, encodeFunctionData } from 'viem'
|
|
11
|
+
import { FACTORY_ABI, MULTICALL3_ABI, QUOTER_ABI } from './abis'
|
|
12
|
+
import { AGNI_BY_NETWORK, FEE_TIERS, type FeeTier, MULTICALL3, requireMainnet } from './constants'
|
|
13
|
+
|
|
14
|
+
const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as Address
|
|
15
|
+
|
|
16
|
+
export interface QuoteResult {
|
|
17
|
+
fee: FeeTier
|
|
18
|
+
amountOut: bigint
|
|
19
|
+
pool: Address
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Iterate the 3 fee tiers, batch-call factory.getPool via multicall3.
|
|
24
|
+
* For non-zero pools, call quoter.quoteExactInputSingle one at a time
|
|
25
|
+
* (the quoter's revert behavior on zero-liquidity pools makes batching
|
|
26
|
+
* unsafe — a single missing quote would tank the whole multicall).
|
|
27
|
+
*
|
|
28
|
+
* Returns the tier with the highest amountOut, or null if no AGNI pool exists
|
|
29
|
+
* across any tier.
|
|
30
|
+
*/
|
|
31
|
+
export async function quoteAcrossTiers(opts: {
|
|
32
|
+
client: PublicClient
|
|
33
|
+
network: 'mantle-mainnet'
|
|
34
|
+
tokenIn: Address
|
|
35
|
+
tokenOut: Address
|
|
36
|
+
amountIn: bigint
|
|
37
|
+
}): Promise<QuoteResult | null> {
|
|
38
|
+
const { client, network, tokenIn, tokenOut, amountIn } = opts
|
|
39
|
+
requireMainnet(network)
|
|
40
|
+
const agni = AGNI_BY_NETWORK[network]!
|
|
41
|
+
// Step 1: batch-fetch all 3 pool addresses
|
|
42
|
+
const poolCalls = FEE_TIERS.map(fee => ({
|
|
43
|
+
target: agni.factory,
|
|
44
|
+
allowFailure: false,
|
|
45
|
+
callData: encodeFunctionData({
|
|
46
|
+
abi: FACTORY_ABI,
|
|
47
|
+
functionName: 'getPool',
|
|
48
|
+
args: [tokenIn, tokenOut, fee],
|
|
49
|
+
}),
|
|
50
|
+
}))
|
|
51
|
+
const poolResults = (await client.readContract({
|
|
52
|
+
address: MULTICALL3,
|
|
53
|
+
abi: MULTICALL3_ABI,
|
|
54
|
+
functionName: 'aggregate3',
|
|
55
|
+
args: [poolCalls],
|
|
56
|
+
})) as ReadonlyArray<{ success: boolean; returnData: `0x${string}` }>
|
|
57
|
+
const pools = FEE_TIERS.map((fee, i) => {
|
|
58
|
+
const r = poolResults[i]
|
|
59
|
+
if (!r?.success) return { fee, pool: ZERO_ADDRESS }
|
|
60
|
+
const addr = decodeFunctionResult({
|
|
61
|
+
abi: FACTORY_ABI,
|
|
62
|
+
functionName: 'getPool',
|
|
63
|
+
data: r.returnData,
|
|
64
|
+
}) as Address
|
|
65
|
+
return { fee, pool: addr }
|
|
66
|
+
}).filter(p => p.pool.toLowerCase() !== ZERO_ADDRESS.toLowerCase())
|
|
67
|
+
if (pools.length === 0) return null
|
|
68
|
+
// Step 2: quote each non-zero pool sequentially
|
|
69
|
+
const candidates: QuoteResult[] = []
|
|
70
|
+
for (const p of pools) {
|
|
71
|
+
try {
|
|
72
|
+
const amountOut = (await client.readContract({
|
|
73
|
+
address: agni.quoter,
|
|
74
|
+
abi: QUOTER_ABI,
|
|
75
|
+
functionName: 'quoteExactInputSingle',
|
|
76
|
+
args: [tokenIn, tokenOut, p.fee, amountIn, 0n],
|
|
77
|
+
})) as bigint
|
|
78
|
+
if (amountOut > 0n) {
|
|
79
|
+
candidates.push({ fee: p.fee, amountOut, pool: p.pool })
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
// pool exists but no liquidity at this size; skip
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (candidates.length === 0) return null
|
|
86
|
+
return candidates.reduce((best, c) => (c.amountOut > best.amountOut ? c : best))
|
|
87
|
+
}
|
package/src/raw-logs.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raw `eth_getLogs` wrapper that bypasses viem's `getLogs` topic-stripping.
|
|
3
|
+
*
|
|
4
|
+
* viem v2's `getLogs({topics: [t0, null, t1]})` reorders/strips topic slots
|
|
5
|
+
* when no `event` parsed input is supplied, leaving the RPC with `topics:[]`
|
|
6
|
+
* (verified May 1 2026 against Mantle mainnet RPC). The Mantle RPC then rejects with
|
|
7
|
+
* "result set exceeds max limit of 10000 logs" because the broad query
|
|
8
|
+
* matches every Transfer on chain.
|
|
9
|
+
*
|
|
10
|
+
* This helper sends the JSON-RPC payload verbatim, preserving sparse topic
|
|
11
|
+
* filtering exactly as the user wrote it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { type PublicClient, numberToHex } from 'viem'
|
|
15
|
+
|
|
16
|
+
export interface RawLog {
|
|
17
|
+
address: `0x${string}`
|
|
18
|
+
blockNumber: `0x${string}`
|
|
19
|
+
blockHash: `0x${string}`
|
|
20
|
+
transactionHash: `0x${string}`
|
|
21
|
+
transactionIndex: `0x${string}`
|
|
22
|
+
logIndex: `0x${string}`
|
|
23
|
+
data: `0x${string}`
|
|
24
|
+
topics: `0x${string}`[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface RawLogsArgs {
|
|
28
|
+
client: PublicClient
|
|
29
|
+
address?: `0x${string}` | `0x${string}`[]
|
|
30
|
+
topics: Array<`0x${string}` | null | `0x${string}`[]>
|
|
31
|
+
fromBlock: bigint
|
|
32
|
+
toBlock: bigint
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function rawGetLogs(args: RawLogsArgs): Promise<RawLog[]> {
|
|
36
|
+
const { client, address, topics, fromBlock, toBlock } = args
|
|
37
|
+
const params: Record<string, unknown> = {
|
|
38
|
+
topics,
|
|
39
|
+
fromBlock: numberToHex(fromBlock),
|
|
40
|
+
toBlock: numberToHex(toBlock),
|
|
41
|
+
}
|
|
42
|
+
if (address !== undefined) params.address = address
|
|
43
|
+
// biome-ignore lint/suspicious/noExplicitAny: viem PublicClient lacks .request in TS, but it exists
|
|
44
|
+
const result = await (client as any).request({
|
|
45
|
+
method: 'eth_getLogs',
|
|
46
|
+
params: [params],
|
|
47
|
+
})
|
|
48
|
+
return (result as RawLog[]) ?? []
|
|
49
|
+
}
|
package/src/risk.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token risk assessment — the pre-trade "is this safe to hold or swap into?"
|
|
3
|
+
* read a treasury manager wants before touching an asset.
|
|
4
|
+
*
|
|
5
|
+
* The verdict is a PURE function of signals the tool gathers (tradeability
|
|
6
|
+
* across venues, liquidity depth, restricted-RWA flag), so the risk rubric is
|
|
7
|
+
* fully unit-testable; the tool layer fetches the signals.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface TokenRiskInputs {
|
|
11
|
+
/** False when the symbol/address could not be resolved to a real token. */
|
|
12
|
+
resolved: boolean
|
|
13
|
+
symbol: string
|
|
14
|
+
/** CLAUDE.md restricted product (USDY/MI4/mUSD) — needs eligibility. */
|
|
15
|
+
restricted: boolean
|
|
16
|
+
/** Human venue labels that returned a live quote (can exit there). */
|
|
17
|
+
tradeableVenues: string[]
|
|
18
|
+
/** Largest DeFiLlama pool TVL the token appears in, or null if none. */
|
|
19
|
+
maxPoolTvlUsd: number | null
|
|
20
|
+
/** True when the address has contract code (not an EOA / typo). */
|
|
21
|
+
isContract: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type RiskLevel = 'low' | 'elevated' | 'high'
|
|
25
|
+
|
|
26
|
+
export interface TokenRiskVerdict {
|
|
27
|
+
level: RiskLevel
|
|
28
|
+
/** Plain-language reasons behind the level (ordered most → least severe). */
|
|
29
|
+
reasons: string[]
|
|
30
|
+
tradeable: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const THIN_LIQUIDITY_USD = 50_000
|
|
34
|
+
|
|
35
|
+
/** Compose a risk verdict from the gathered signals. Pure + deterministic. */
|
|
36
|
+
export function assessTokenRisk(i: TokenRiskInputs): TokenRiskVerdict {
|
|
37
|
+
const reasons: string[] = []
|
|
38
|
+
const tradeable = i.tradeableVenues.length > 0
|
|
39
|
+
|
|
40
|
+
if (!i.resolved) {
|
|
41
|
+
return {
|
|
42
|
+
level: 'high',
|
|
43
|
+
reasons: ['could not resolve this token (unknown symbol/address) — do not trade'],
|
|
44
|
+
tradeable: false,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (!i.isContract) {
|
|
48
|
+
reasons.push('address has no contract code (likely a typo or non-token) — do not trade')
|
|
49
|
+
}
|
|
50
|
+
if (!tradeable) {
|
|
51
|
+
reasons.push('no swap route on Agni or Merchant Moe — you could not exit this position')
|
|
52
|
+
}
|
|
53
|
+
if (i.restricted) {
|
|
54
|
+
reasons.push(
|
|
55
|
+
`${i.symbol} is a restricted product (RWA) — confirm eligibility before entering; do not auto-trade`,
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
if (i.maxPoolTvlUsd !== null && i.maxPoolTvlUsd < THIN_LIQUIDITY_USD) {
|
|
59
|
+
reasons.push(
|
|
60
|
+
`thin on-chain liquidity (max pool TVL ~$${Math.round(i.maxPoolTvlUsd).toLocaleString()}) — expect slippage and exit risk`,
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
if (tradeable && i.tradeableVenues.length === 1) {
|
|
64
|
+
reasons.push(`only one venue (${i.tradeableVenues[0]}) quotes it — concentrated liquidity`)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Level: hard blockers → high; soft concerns → elevated; clean → low.
|
|
68
|
+
let level: RiskLevel
|
|
69
|
+
if (!i.isContract || !tradeable) {
|
|
70
|
+
level = 'high'
|
|
71
|
+
} else if (i.restricted || (i.maxPoolTvlUsd !== null && i.maxPoolTvlUsd < THIN_LIQUIDITY_USD)) {
|
|
72
|
+
level = 'elevated'
|
|
73
|
+
} else {
|
|
74
|
+
level = 'low'
|
|
75
|
+
reasons.push('tradeable and reasonably liquid')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { level, reasons, tradeable }
|
|
79
|
+
}
|
package/src/simulate.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simulate-before-write guard.
|
|
3
|
+
*
|
|
4
|
+
* Nebula's core safety rule (project thesis): every state-changing transaction
|
|
5
|
+
* is dry-run against the live chain BEFORE it is broadcast, so reverts and
|
|
6
|
+
* insufficient-funds are caught pre-flight and surfaced to the operator instead
|
|
7
|
+
* of burning gas on a doomed tx. Read-only — no transaction is sent here.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
type Abi,
|
|
12
|
+
type Address,
|
|
13
|
+
BaseError,
|
|
14
|
+
ContractFunctionRevertedError,
|
|
15
|
+
type PublicClient,
|
|
16
|
+
} from 'viem'
|
|
17
|
+
|
|
18
|
+
export interface SimOk {
|
|
19
|
+
ok: true
|
|
20
|
+
/** Estimated gas for the (validated) transaction. */
|
|
21
|
+
gas: bigint
|
|
22
|
+
}
|
|
23
|
+
export interface SimFail {
|
|
24
|
+
ok: false
|
|
25
|
+
/** Decoded revert reason or node error (truncated). */
|
|
26
|
+
reason: string
|
|
27
|
+
}
|
|
28
|
+
export type SimResult = SimOk | SimFail
|
|
29
|
+
|
|
30
|
+
/** Pull a human revert reason out of a viem error chain. */
|
|
31
|
+
function extractRevert(e: unknown): string {
|
|
32
|
+
if (e instanceof BaseError) {
|
|
33
|
+
const reverted = e.walk(err => err instanceof ContractFunctionRevertedError)
|
|
34
|
+
if (reverted instanceof ContractFunctionRevertedError) {
|
|
35
|
+
return reverted.reason ?? reverted.shortMessage ?? 'reverted'
|
|
36
|
+
}
|
|
37
|
+
return e.shortMessage ?? e.message.slice(0, 200)
|
|
38
|
+
}
|
|
39
|
+
return (e as Error)?.message?.slice(0, 200) ?? 'unknown simulation error'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Dry-run a native-value send. `estimateGas` both validates the call and
|
|
44
|
+
* catches insufficient-funds (`gas * price + value > balance`).
|
|
45
|
+
*/
|
|
46
|
+
export async function simulateNativeSend(
|
|
47
|
+
client: PublicClient,
|
|
48
|
+
args: { account: Address; to: Address; value: bigint },
|
|
49
|
+
): Promise<SimResult> {
|
|
50
|
+
try {
|
|
51
|
+
const gas = await client.estimateGas({
|
|
52
|
+
account: args.account,
|
|
53
|
+
to: args.to,
|
|
54
|
+
value: args.value,
|
|
55
|
+
})
|
|
56
|
+
return { ok: true, gas }
|
|
57
|
+
} catch (e) {
|
|
58
|
+
return { ok: false, reason: extractRevert(e) }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Dry-run an arbitrary tx by raw calldata (e.g. a composed DEX multicall).
|
|
64
|
+
* `estimateGas` executes the call at the node, so reverts and funding
|
|
65
|
+
* shortfalls surface here without broadcasting.
|
|
66
|
+
*/
|
|
67
|
+
export async function simulateRawTx(
|
|
68
|
+
client: PublicClient,
|
|
69
|
+
args: { account: Address; to: Address; data?: `0x${string}`; value?: bigint },
|
|
70
|
+
): Promise<SimResult> {
|
|
71
|
+
try {
|
|
72
|
+
const gas = await client.estimateGas({
|
|
73
|
+
account: args.account,
|
|
74
|
+
to: args.to,
|
|
75
|
+
...(args.data !== undefined ? { data: args.data } : {}),
|
|
76
|
+
...(args.value !== undefined ? { value: args.value } : {}),
|
|
77
|
+
})
|
|
78
|
+
return { ok: true, gas }
|
|
79
|
+
} catch (e) {
|
|
80
|
+
return { ok: false, reason: extractRevert(e) }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Dry-run a contract write (e.g. ERC-20 transfer, a DEX swap). `simulateContract`
|
|
86
|
+
* decodes custom-error/`require` reverts; `estimateContractGas` adds the gas
|
|
87
|
+
* figure and catches funding shortfalls.
|
|
88
|
+
*/
|
|
89
|
+
export async function simulateContractWrite(
|
|
90
|
+
client: PublicClient,
|
|
91
|
+
args: {
|
|
92
|
+
account: Address
|
|
93
|
+
address: Address
|
|
94
|
+
abi: Abi
|
|
95
|
+
functionName: string
|
|
96
|
+
args: readonly unknown[]
|
|
97
|
+
value?: bigint
|
|
98
|
+
},
|
|
99
|
+
): Promise<SimResult> {
|
|
100
|
+
try {
|
|
101
|
+
await client.simulateContract({
|
|
102
|
+
account: args.account,
|
|
103
|
+
address: args.address,
|
|
104
|
+
abi: args.abi,
|
|
105
|
+
functionName: args.functionName,
|
|
106
|
+
args: args.args,
|
|
107
|
+
...(args.value !== undefined ? { value: args.value } : {}),
|
|
108
|
+
})
|
|
109
|
+
const gas = await client.estimateContractGas({
|
|
110
|
+
account: args.account,
|
|
111
|
+
address: args.address,
|
|
112
|
+
abi: args.abi,
|
|
113
|
+
functionName: args.functionName,
|
|
114
|
+
args: args.args,
|
|
115
|
+
...(args.value !== undefined ? { value: args.value } : {}),
|
|
116
|
+
})
|
|
117
|
+
return { ok: true, gas }
|
|
118
|
+
} catch (e) {
|
|
119
|
+
return { ok: false, reason: extractRevert(e) }
|
|
120
|
+
}
|
|
121
|
+
}
|
package/src/swap.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Swap calldata builder + multicall composer for AGNI.
|
|
3
|
+
*
|
|
4
|
+
* Three native-handling cases (verified live on mainnet May 1):
|
|
5
|
+
* 1. native IN → multicall([exactInputSingle(tokenIn=WMNT, ...), refundETH()])
|
|
6
|
+
* with msg.value=amountIn
|
|
7
|
+
* 2. native OUT → multicall([exactInputSingle(recipient=router, ...), unwrapWETH9(min, recipient)])
|
|
8
|
+
* 3. ERC-20 ↔ ERC-20 → direct exactInputSingle (recipient=agent)
|
|
9
|
+
*
|
|
10
|
+
* The router is the OLD SwapRouter (NOT SwapRouter02). Struct includes
|
|
11
|
+
* `deadline: uint256`. Vendored ABI in abis/swap-router.json.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { type Address, encodeFunctionData } from 'viem'
|
|
15
|
+
import { SWAP_ROUTER_ABI } from './abis'
|
|
16
|
+
|
|
17
|
+
export interface ExactInputSingleParams {
|
|
18
|
+
tokenIn: Address
|
|
19
|
+
tokenOut: Address
|
|
20
|
+
fee: number
|
|
21
|
+
recipient: Address
|
|
22
|
+
deadline: bigint
|
|
23
|
+
amountIn: bigint
|
|
24
|
+
amountOutMinimum: bigint
|
|
25
|
+
sqrtPriceLimitX96: bigint
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function encodeExactInputSingle(params: ExactInputSingleParams): `0x${string}` {
|
|
29
|
+
return encodeFunctionData({
|
|
30
|
+
abi: SWAP_ROUTER_ABI,
|
|
31
|
+
functionName: 'exactInputSingle',
|
|
32
|
+
args: [params],
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function encodeRefundETH(): `0x${string}` {
|
|
37
|
+
return encodeFunctionData({
|
|
38
|
+
abi: SWAP_ROUTER_ABI,
|
|
39
|
+
functionName: 'refundETH',
|
|
40
|
+
args: [],
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function encodeUnwrapWETH9(amountMin: bigint, recipient: Address): `0x${string}` {
|
|
45
|
+
return encodeFunctionData({
|
|
46
|
+
abi: SWAP_ROUTER_ABI,
|
|
47
|
+
functionName: 'unwrapWETH9',
|
|
48
|
+
args: [amountMin, recipient],
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ComposeArgs {
|
|
53
|
+
params: ExactInputSingleParams
|
|
54
|
+
nativeIn: boolean
|
|
55
|
+
nativeOut: boolean
|
|
56
|
+
router: Address
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ComposedCall {
|
|
60
|
+
to: Address
|
|
61
|
+
data: `0x${string}`
|
|
62
|
+
value: bigint
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Produce the (to, data, value) for a single swap tx, handling native
|
|
67
|
+
* in/out via multicall composition.
|
|
68
|
+
*/
|
|
69
|
+
export function composeSwap({ params, nativeIn, nativeOut, router }: ComposeArgs): ComposedCall {
|
|
70
|
+
if (nativeIn && nativeOut) {
|
|
71
|
+
throw new Error('nativeIn AND nativeOut not supported (use chain.wrap/unwrap)')
|
|
72
|
+
}
|
|
73
|
+
if (nativeIn) {
|
|
74
|
+
const calls = [encodeExactInputSingle(params), encodeRefundETH()]
|
|
75
|
+
return {
|
|
76
|
+
to: router,
|
|
77
|
+
data: encodeFunctionData({
|
|
78
|
+
abi: SWAP_ROUTER_ABI,
|
|
79
|
+
functionName: 'multicall',
|
|
80
|
+
args: [calls],
|
|
81
|
+
}),
|
|
82
|
+
value: params.amountIn,
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (nativeOut) {
|
|
86
|
+
// Inner exactInputSingle's recipient must be the router so it holds WMNT
|
|
87
|
+
// for the unwrap step. Outer unwrap sends native to the agent.
|
|
88
|
+
const innerParams: ExactInputSingleParams = { ...params, recipient: router }
|
|
89
|
+
const calls = [
|
|
90
|
+
encodeExactInputSingle(innerParams),
|
|
91
|
+
encodeUnwrapWETH9(params.amountOutMinimum, params.recipient),
|
|
92
|
+
]
|
|
93
|
+
return {
|
|
94
|
+
to: router,
|
|
95
|
+
data: encodeFunctionData({
|
|
96
|
+
abi: SWAP_ROUTER_ABI,
|
|
97
|
+
functionName: 'multicall',
|
|
98
|
+
args: [calls],
|
|
99
|
+
}),
|
|
100
|
+
value: 0n,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
to: router,
|
|
105
|
+
data: encodeExactInputSingle(params),
|
|
106
|
+
value: 0n,
|
|
107
|
+
}
|
|
108
|
+
}
|