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