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,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `swap.compare` + `swap.best` — multi-venue best execution across Agni and
|
|
3
|
+
* Merchant Moe.
|
|
4
|
+
*
|
|
5
|
+
* `swap.compare` (read-only) quotes the same trade on both DEXes and reports
|
|
6
|
+
* which returns more output. `swap.best` does the same, then EXECUTES on the
|
|
7
|
+
* winning venue by delegating to that venue's existing tool — so the trade runs
|
|
8
|
+
* through the identical policy -> simulate -> (approval) -> execute pipeline and
|
|
9
|
+
* decision receipt, with no duplicated execution path.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ToolDef } from 'nebula-ai-core'
|
|
13
|
+
import { type Address, formatUnits, parseUnits } from 'viem'
|
|
14
|
+
import { z } from 'zod'
|
|
15
|
+
import { AGNI_BY_NETWORK, MOE_LB_BY_NETWORK, requireMainnet } from '../constants'
|
|
16
|
+
import { quoteMoe } from '../moe'
|
|
17
|
+
import { quoteAcrossTiers } from '../quoter'
|
|
18
|
+
import { isNativeToken, resolveToken } from '../tokens'
|
|
19
|
+
import type { OnchainRuntimeContext } from '../types'
|
|
20
|
+
import { makeMoeSwap } from './moe'
|
|
21
|
+
import { makeSwapExecute } from './swap'
|
|
22
|
+
|
|
23
|
+
interface Resolved {
|
|
24
|
+
address: Address
|
|
25
|
+
decimals: number
|
|
26
|
+
symbol: string
|
|
27
|
+
isNative: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function resolve(ctx: OnchainRuntimeContext, input: string): Promise<Resolved | null> {
|
|
31
|
+
if (isNativeToken(input)) {
|
|
32
|
+
return {
|
|
33
|
+
address: AGNI_BY_NETWORK[ctx.network]!.weth9 as Address,
|
|
34
|
+
decimals: 18,
|
|
35
|
+
symbol: 'WMNT',
|
|
36
|
+
isNative: true,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const t = await resolveToken({ client: ctx.publicClient, agentDir: ctx.agentDir, input })
|
|
40
|
+
if (!t) return null
|
|
41
|
+
return { address: t.address, decimals: t.decimals, symbol: t.symbol, isNative: false }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const Schema = z.object({
|
|
45
|
+
tokenIn: z.string().describe('Input token: symbol, 0x address, or "MNT"/"native".'),
|
|
46
|
+
tokenOut: z.string().describe('Output token: symbol, 0x address, or "MNT"/"native".'),
|
|
47
|
+
amountIn: z.string().describe('Input amount in tokenIn units (e.g. "1.5").'),
|
|
48
|
+
slippageBps: z.number().int().nonnegative().max(10000).optional(),
|
|
49
|
+
})
|
|
50
|
+
type Args = z.infer<typeof Schema>
|
|
51
|
+
|
|
52
|
+
export interface VenueQuote {
|
|
53
|
+
venue: 'agni' | 'moe'
|
|
54
|
+
amountOutRaw: bigint
|
|
55
|
+
amountOut: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface RankedQuotes {
|
|
59
|
+
sorted: VenueQuote[]
|
|
60
|
+
best: VenueQuote
|
|
61
|
+
/** Output edge of the best venue over the worst, percent (null if one venue). */
|
|
62
|
+
edgePct: number | null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Pure: rank venue quotes by output desc and compute the best-vs-worst edge. */
|
|
66
|
+
export function rankVenueQuotes(quotes: VenueQuote[]): RankedQuotes | null {
|
|
67
|
+
if (quotes.length === 0) return null
|
|
68
|
+
const sorted = [...quotes].sort((a, b) =>
|
|
69
|
+
b.amountOutRaw > a.amountOutRaw ? 1 : b.amountOutRaw < a.amountOutRaw ? -1 : 0,
|
|
70
|
+
)
|
|
71
|
+
const best = sorted[0]!
|
|
72
|
+
const worst = sorted[sorted.length - 1]!
|
|
73
|
+
const edgePct =
|
|
74
|
+
sorted.length > 1 && worst.amountOutRaw > 0n
|
|
75
|
+
? Number(((best.amountOutRaw - worst.amountOutRaw) * 10000n) / worst.amountOutRaw) / 100
|
|
76
|
+
: null
|
|
77
|
+
return { sorted, best, edgePct }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function quoteVenues(
|
|
81
|
+
ctx: OnchainRuntimeContext,
|
|
82
|
+
args: Args,
|
|
83
|
+
): Promise<
|
|
84
|
+
| { ok: false; error: string }
|
|
85
|
+
| {
|
|
86
|
+
ok: true
|
|
87
|
+
tokenIn: string
|
|
88
|
+
tokenOut: string
|
|
89
|
+
decimalsOut: number
|
|
90
|
+
quotes: VenueQuote[]
|
|
91
|
+
}
|
|
92
|
+
> {
|
|
93
|
+
requireMainnet(ctx.network)
|
|
94
|
+
const tin = await resolve(ctx, args.tokenIn)
|
|
95
|
+
const tout = await resolve(ctx, args.tokenOut)
|
|
96
|
+
if (!tin) return { ok: false, error: `unknown tokenIn: ${args.tokenIn}` }
|
|
97
|
+
if (!tout) return { ok: false, error: `unknown tokenOut: ${args.tokenOut}` }
|
|
98
|
+
const amountInWei = parseUnits(args.amountIn, tin.decimals)
|
|
99
|
+
const moe = MOE_LB_BY_NETWORK[ctx.network]!
|
|
100
|
+
|
|
101
|
+
const [agni, moeQ] = await Promise.all([
|
|
102
|
+
quoteAcrossTiers({
|
|
103
|
+
client: ctx.publicClient,
|
|
104
|
+
network: ctx.network,
|
|
105
|
+
tokenIn: tin.address,
|
|
106
|
+
tokenOut: tout.address,
|
|
107
|
+
amountIn: amountInWei,
|
|
108
|
+
}).catch(() => null),
|
|
109
|
+
quoteMoe({
|
|
110
|
+
client: ctx.publicClient,
|
|
111
|
+
quoter: moe.quoter as Address,
|
|
112
|
+
route: [tin.address, tout.address],
|
|
113
|
+
amountIn: amountInWei,
|
|
114
|
+
}).catch(() => null),
|
|
115
|
+
])
|
|
116
|
+
|
|
117
|
+
const quotes: VenueQuote[] = []
|
|
118
|
+
if (agni)
|
|
119
|
+
quotes.push({
|
|
120
|
+
venue: 'agni',
|
|
121
|
+
amountOutRaw: agni.amountOut,
|
|
122
|
+
amountOut: formatUnits(agni.amountOut, tout.decimals),
|
|
123
|
+
})
|
|
124
|
+
if (moeQ)
|
|
125
|
+
quotes.push({
|
|
126
|
+
venue: 'moe',
|
|
127
|
+
amountOutRaw: moeQ.amountOut,
|
|
128
|
+
amountOut: formatUnits(moeQ.amountOut, tout.decimals),
|
|
129
|
+
})
|
|
130
|
+
if (quotes.length === 0)
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
error: `no liquidity on Agni or Merchant Moe for ${tin.symbol}→${tout.symbol}`,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
ok: true,
|
|
138
|
+
tokenIn: tin.symbol,
|
|
139
|
+
tokenOut: tout.symbol,
|
|
140
|
+
decimalsOut: tout.decimals,
|
|
141
|
+
quotes,
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function venueLabel(v: 'agni' | 'moe'): string {
|
|
146
|
+
return v === 'agni' ? 'Agni Finance' : 'Merchant Moe (Liquidity Book)'
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function makeSwapCompare(ctx: OnchainRuntimeContext): ToolDef<Args> {
|
|
150
|
+
return {
|
|
151
|
+
name: 'swap.compare',
|
|
152
|
+
description:
|
|
153
|
+
'Compare a swap across both DEX venues (Agni + Merchant Moe) and report which returns more output. Read-only. Use before a non-trivial swap, or call swap.best to compare AND execute on the winner in one step.',
|
|
154
|
+
searchHint: 'swap compare best execution venue route agni moe which dex better price quote',
|
|
155
|
+
schema: Schema,
|
|
156
|
+
handler: async (args: Args) => {
|
|
157
|
+
try {
|
|
158
|
+
const r = await quoteVenues(ctx, args)
|
|
159
|
+
if (!r.ok) return { ok: false, error: r.error }
|
|
160
|
+
const ranked = rankVenueQuotes(r.quotes)!
|
|
161
|
+
return {
|
|
162
|
+
ok: true,
|
|
163
|
+
data: {
|
|
164
|
+
tokenIn: r.tokenIn,
|
|
165
|
+
tokenOut: r.tokenOut,
|
|
166
|
+
amountIn: args.amountIn,
|
|
167
|
+
best: {
|
|
168
|
+
venue: venueLabel(ranked.best.venue),
|
|
169
|
+
amountOut: ranked.best.amountOut,
|
|
170
|
+
},
|
|
171
|
+
quotes: ranked.sorted.map(q => ({
|
|
172
|
+
venue: venueLabel(q.venue),
|
|
173
|
+
amountOut: q.amountOut,
|
|
174
|
+
})),
|
|
175
|
+
bestEdgePct: ranked.edgePct,
|
|
176
|
+
},
|
|
177
|
+
}
|
|
178
|
+
} catch (e) {
|
|
179
|
+
return { ok: false, error: (e as Error).message.slice(0, 240) }
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function makeSwapBest(ctx: OnchainRuntimeContext): ToolDef<Args> {
|
|
186
|
+
return {
|
|
187
|
+
name: 'swap.best',
|
|
188
|
+
description:
|
|
189
|
+
'Best execution: quote the swap on BOTH Agni and Merchant Moe, then execute on whichever returns more output. Runs the winning venue through the full policy -> simulate -> (approval) -> execute pipeline. One call instead of quoting both venues by hand.',
|
|
190
|
+
searchHint: 'swap best execution auto route smart order router agni moe trade exchange optimal',
|
|
191
|
+
schema: Schema,
|
|
192
|
+
handler: async (args: Args) => {
|
|
193
|
+
try {
|
|
194
|
+
const r = await quoteVenues(ctx, args)
|
|
195
|
+
if (!r.ok) return { ok: false, error: r.error }
|
|
196
|
+
const ranked = rankVenueQuotes(r.quotes)!
|
|
197
|
+
// Delegate execution to the winning venue's existing tool: same args,
|
|
198
|
+
// same policy/simulate/approval/receipt pipeline (re-quotes at exec).
|
|
199
|
+
const executor = ranked.best.venue === 'agni' ? makeSwapExecute(ctx) : makeMoeSwap(ctx)
|
|
200
|
+
const exec = await executor.handler(args)
|
|
201
|
+
if (!exec.ok) return exec
|
|
202
|
+
return {
|
|
203
|
+
ok: true,
|
|
204
|
+
data: {
|
|
205
|
+
routedTo: venueLabel(ranked.best.venue),
|
|
206
|
+
comparedQuotes: ranked.sorted.map(q => ({
|
|
207
|
+
venue: venueLabel(q.venue),
|
|
208
|
+
amountOut: q.amountOut,
|
|
209
|
+
})),
|
|
210
|
+
...(exec.data as Record<string, unknown>),
|
|
211
|
+
},
|
|
212
|
+
}
|
|
213
|
+
} catch (e) {
|
|
214
|
+
return { ok: false, error: (e as Error).message.slice(0, 240) }
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `swap.quote` + `swap.execute` — AGNI V3 single-pool swaps with 3-tier scan.
|
|
3
|
+
*
|
|
4
|
+
* Quote and execute share the same resolver path so the executed price
|
|
5
|
+
* matches what was quoted (re-quote at exec for slippage protection).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ToolDef } from 'nebula-ai-core'
|
|
9
|
+
import { getGasPriceWithFloor } from 'nebula-ai-core'
|
|
10
|
+
import { type Address, formatUnits, parseUnits } from 'viem'
|
|
11
|
+
import { z } from 'zod'
|
|
12
|
+
import { ensureAllowance } from '../allowance'
|
|
13
|
+
import {
|
|
14
|
+
AGNI_BY_NETWORK,
|
|
15
|
+
DEFAULT_DEADLINE_SECS,
|
|
16
|
+
DEFAULT_SLIPPAGE_BPS,
|
|
17
|
+
requireMainnet,
|
|
18
|
+
} from '../constants'
|
|
19
|
+
import { evaluatePolicy } from '../policy'
|
|
20
|
+
import { quoteAcrossTiers } from '../quoter'
|
|
21
|
+
import { simulateRawTx } from '../simulate'
|
|
22
|
+
import { type ExactInputSingleParams, composeSwap } from '../swap'
|
|
23
|
+
import { isNativeToken, resolveToken } from '../tokens'
|
|
24
|
+
import type { OnchainRuntimeContext, TokenInfo } from '../types'
|
|
25
|
+
import { waitForReceipt } from '../wait-receipt'
|
|
26
|
+
|
|
27
|
+
async function resolveOrNative(
|
|
28
|
+
ctx: OnchainRuntimeContext,
|
|
29
|
+
input: string,
|
|
30
|
+
): Promise<{ token: TokenInfo; isNative: boolean } | null> {
|
|
31
|
+
if (isNativeToken(input)) {
|
|
32
|
+
requireMainnet(ctx.network)
|
|
33
|
+
const wmnt = AGNI_BY_NETWORK[ctx.network]!.weth9
|
|
34
|
+
return {
|
|
35
|
+
token: {
|
|
36
|
+
address: wmnt as Address,
|
|
37
|
+
symbol: 'WMNT',
|
|
38
|
+
name: 'Wrapped Mantle',
|
|
39
|
+
decimals: 18,
|
|
40
|
+
source: 'list',
|
|
41
|
+
},
|
|
42
|
+
isNative: true,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const t = await resolveToken({
|
|
46
|
+
client: ctx.publicClient,
|
|
47
|
+
agentDir: ctx.agentDir,
|
|
48
|
+
input,
|
|
49
|
+
})
|
|
50
|
+
if (!t) return null
|
|
51
|
+
return { token: t, isNative: false }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const QuoteSchema = z.object({
|
|
55
|
+
tokenIn: z.string().describe('Input token: symbol, 0x address, or "Mantle"/"native".'),
|
|
56
|
+
tokenOut: z.string().describe('Output token: symbol, 0x address, or "Mantle"/"native".'),
|
|
57
|
+
amountIn: z.string().describe('Input amount in tokenIn units (e.g. "0.005").'),
|
|
58
|
+
slippageBps: z
|
|
59
|
+
.number()
|
|
60
|
+
.int()
|
|
61
|
+
.nonnegative()
|
|
62
|
+
.max(10000)
|
|
63
|
+
.optional()
|
|
64
|
+
.describe(`Slippage tolerance in basis points (default ${DEFAULT_SLIPPAGE_BPS} = 0.5%).`),
|
|
65
|
+
})
|
|
66
|
+
type QuoteArgs = z.infer<typeof QuoteSchema>
|
|
67
|
+
|
|
68
|
+
export function makeSwapQuote(ctx: OnchainRuntimeContext): ToolDef<QuoteArgs> {
|
|
69
|
+
return {
|
|
70
|
+
name: 'swap.quote',
|
|
71
|
+
description:
|
|
72
|
+
'Preview a swap on AGNI. Scans all 3 fee tiers and returns the best route + amountOut + amountOutMin (after slippage). Read-only.',
|
|
73
|
+
searchHint: 'swap quote price preview agni dex amountout',
|
|
74
|
+
schema: QuoteSchema,
|
|
75
|
+
handler: async args => {
|
|
76
|
+
try {
|
|
77
|
+
requireMainnet(ctx.network)
|
|
78
|
+
const tin = await resolveOrNative(ctx, args.tokenIn)
|
|
79
|
+
const tout = await resolveOrNative(ctx, args.tokenOut)
|
|
80
|
+
if (!tin) return { ok: false, error: `unknown tokenIn: ${args.tokenIn}` }
|
|
81
|
+
if (!tout) return { ok: false, error: `unknown tokenOut: ${args.tokenOut}` }
|
|
82
|
+
const amountInWei = parseUnits(args.amountIn, tin.token.decimals)
|
|
83
|
+
const quote = await quoteAcrossTiers({
|
|
84
|
+
client: ctx.publicClient,
|
|
85
|
+
network: ctx.network,
|
|
86
|
+
tokenIn: tin.token.address,
|
|
87
|
+
tokenOut: tout.token.address,
|
|
88
|
+
amountIn: amountInWei,
|
|
89
|
+
})
|
|
90
|
+
if (!quote) {
|
|
91
|
+
return {
|
|
92
|
+
ok: false,
|
|
93
|
+
error: `no AGNI pool with liquidity for ${tin.token.symbol}→${tout.token.symbol}`,
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const slippageBps = BigInt(args.slippageBps ?? DEFAULT_SLIPPAGE_BPS)
|
|
97
|
+
const amountOutMin = (quote.amountOut * (10000n - slippageBps)) / 10000n
|
|
98
|
+
return {
|
|
99
|
+
ok: true,
|
|
100
|
+
data: {
|
|
101
|
+
tokenIn: tin.token.symbol,
|
|
102
|
+
tokenOut: tout.token.symbol,
|
|
103
|
+
amountIn: args.amountIn,
|
|
104
|
+
amountOut: formatUnits(quote.amountOut, tout.token.decimals),
|
|
105
|
+
amountOutMin: formatUnits(amountOutMin, tout.token.decimals),
|
|
106
|
+
fee: quote.fee,
|
|
107
|
+
pool: quote.pool,
|
|
108
|
+
slippageBps: Number(slippageBps),
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
} catch (e) {
|
|
112
|
+
return { ok: false, error: (e as Error).message.slice(0, 240) }
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const ExecuteSchema = z.object({
|
|
119
|
+
tokenIn: z.string(),
|
|
120
|
+
tokenOut: z.string(),
|
|
121
|
+
amountIn: z.string(),
|
|
122
|
+
slippageBps: z.number().int().nonnegative().max(10000).optional(),
|
|
123
|
+
})
|
|
124
|
+
type ExecuteArgs = z.infer<typeof ExecuteSchema>
|
|
125
|
+
|
|
126
|
+
export function makeSwapExecute(ctx: OnchainRuntimeContext): ToolDef<ExecuteArgs> {
|
|
127
|
+
return {
|
|
128
|
+
name: 'swap.execute',
|
|
129
|
+
description:
|
|
130
|
+
'Execute a swap on AGNI. Re-quotes at exec time for slippage protection; auto-approves the router for ERC-20 input on first use. Native via multicall+refundETH; native output via unwrapWETH9 chain.',
|
|
131
|
+
searchHint: 'swap execute trade agni dex exchange',
|
|
132
|
+
schema: ExecuteSchema,
|
|
133
|
+
handler: async args => {
|
|
134
|
+
try {
|
|
135
|
+
requireMainnet(ctx.network)
|
|
136
|
+
const account = ctx.walletClient.account
|
|
137
|
+
if (!account) return { ok: false, error: 'walletClient has no account; cannot swap' }
|
|
138
|
+
const agni = AGNI_BY_NETWORK[ctx.network]!
|
|
139
|
+
const tin = await resolveOrNative(ctx, args.tokenIn)
|
|
140
|
+
const tout = await resolveOrNative(ctx, args.tokenOut)
|
|
141
|
+
if (!tin) return { ok: false, error: `unknown tokenIn: ${args.tokenIn}` }
|
|
142
|
+
if (!tout) return { ok: false, error: `unknown tokenOut: ${args.tokenOut}` }
|
|
143
|
+
const amountInWei = parseUnits(args.amountIn, tin.token.decimals)
|
|
144
|
+
// Policy gate (deterministic): block BEFORE any allowance/quote/execute.
|
|
145
|
+
if (ctx.policy) {
|
|
146
|
+
const verdict = evaluatePolicy(
|
|
147
|
+
{
|
|
148
|
+
kind: 'swap',
|
|
149
|
+
asset: tin.isNative ? 'native' : tin.token.address,
|
|
150
|
+
toAsset: tout.isNative ? 'native' : tout.token.address,
|
|
151
|
+
amountRaw: amountInWei,
|
|
152
|
+
slippageBps: Number(args.slippageBps ?? DEFAULT_SLIPPAGE_BPS),
|
|
153
|
+
},
|
|
154
|
+
ctx.policy,
|
|
155
|
+
)
|
|
156
|
+
if (!verdict.allowed) {
|
|
157
|
+
return { ok: false, error: `policy blocked: ${verdict.violations.join('; ')}` }
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// Quote and allowance are independent: race them so the ERC-20 path
|
|
161
|
+
// doesn't pay two sequential RPC round-trips when one would do.
|
|
162
|
+
// Native input has no allowance to ensure (router pulls via msg.value).
|
|
163
|
+
const [quote, allow] = await Promise.all([
|
|
164
|
+
quoteAcrossTiers({
|
|
165
|
+
client: ctx.publicClient,
|
|
166
|
+
network: ctx.network,
|
|
167
|
+
tokenIn: tin.token.address,
|
|
168
|
+
tokenOut: tout.token.address,
|
|
169
|
+
amountIn: amountInWei,
|
|
170
|
+
}),
|
|
171
|
+
tin.isNative
|
|
172
|
+
? Promise.resolve({ approved: false, txHash: undefined as `0x${string}` | undefined })
|
|
173
|
+
: ensureAllowance({
|
|
174
|
+
publicClient: ctx.publicClient,
|
|
175
|
+
walletClient: ctx.walletClient,
|
|
176
|
+
token: tin.token.address,
|
|
177
|
+
owner: ctx.agentEoa,
|
|
178
|
+
spender: agni.swapRouter as Address,
|
|
179
|
+
amount: amountInWei,
|
|
180
|
+
}),
|
|
181
|
+
])
|
|
182
|
+
if (!quote) {
|
|
183
|
+
return {
|
|
184
|
+
ok: false,
|
|
185
|
+
error: `no AGNI pool for ${tin.token.symbol}→${tout.token.symbol}`,
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const slippageBps = BigInt(args.slippageBps ?? DEFAULT_SLIPPAGE_BPS)
|
|
189
|
+
const amountOutMin = (quote.amountOut * (10000n - slippageBps)) / 10000n
|
|
190
|
+
const approveTxHash = allow.txHash
|
|
191
|
+
|
|
192
|
+
const params: ExactInputSingleParams = {
|
|
193
|
+
tokenIn: tin.token.address,
|
|
194
|
+
tokenOut: tout.token.address,
|
|
195
|
+
fee: quote.fee,
|
|
196
|
+
recipient: ctx.agentEoa,
|
|
197
|
+
deadline: BigInt(Math.floor(Date.now() / 1000)) + DEFAULT_DEADLINE_SECS,
|
|
198
|
+
amountIn: amountInWei,
|
|
199
|
+
amountOutMinimum: amountOutMin,
|
|
200
|
+
sqrtPriceLimitX96: 0n,
|
|
201
|
+
}
|
|
202
|
+
const composed = composeSwap({
|
|
203
|
+
params,
|
|
204
|
+
nativeIn: tin.isNative,
|
|
205
|
+
nativeOut: tout.isNative,
|
|
206
|
+
router: agni.swapRouter as Address,
|
|
207
|
+
})
|
|
208
|
+
// Simulate-before-write: dry-run the composed swap; abort if it would revert.
|
|
209
|
+
const sim = await simulateRawTx(ctx.publicClient, {
|
|
210
|
+
account: account.address,
|
|
211
|
+
to: composed.to,
|
|
212
|
+
data: composed.data,
|
|
213
|
+
value: composed.value,
|
|
214
|
+
})
|
|
215
|
+
if (!sim.ok) {
|
|
216
|
+
return { ok: false, error: `pre-flight simulation reverted: ${sim.reason}` }
|
|
217
|
+
}
|
|
218
|
+
const gasPrice = await getGasPriceWithFloor(ctx.publicClient)
|
|
219
|
+
const txHash = await ctx.walletClient.sendTransaction({
|
|
220
|
+
to: composed.to,
|
|
221
|
+
data: composed.data,
|
|
222
|
+
value: composed.value,
|
|
223
|
+
chain: ctx.walletClient.chain,
|
|
224
|
+
account,
|
|
225
|
+
gasPrice,
|
|
226
|
+
})
|
|
227
|
+
const receipt = await waitForReceipt(ctx.publicClient, txHash)
|
|
228
|
+
return {
|
|
229
|
+
ok: true,
|
|
230
|
+
data: {
|
|
231
|
+
...(approveTxHash ? { approveTxHash } : {}),
|
|
232
|
+
txHash,
|
|
233
|
+
blockNumber: Number(receipt.blockNumber),
|
|
234
|
+
gasUsed: receipt.gasUsed.toString(),
|
|
235
|
+
tokenIn: tin.token.symbol,
|
|
236
|
+
tokenOut: tout.token.symbol,
|
|
237
|
+
amountIn: args.amountIn,
|
|
238
|
+
amountOutExpected: formatUnits(quote.amountOut, tout.token.decimals),
|
|
239
|
+
amountOutMin: formatUnits(amountOutMin, tout.token.decimals),
|
|
240
|
+
fee: quote.fee,
|
|
241
|
+
pool: quote.pool,
|
|
242
|
+
status: receipt.status === 'success' ? 'success' : 'reverted',
|
|
243
|
+
// Decision receipt: proof this swap was policy-checked + simulated.
|
|
244
|
+
simGasEstimate: sim.gas.toString(),
|
|
245
|
+
policyEnforced: ctx.policy != null,
|
|
246
|
+
},
|
|
247
|
+
}
|
|
248
|
+
} catch (e) {
|
|
249
|
+
return { ok: false, error: (e as Error).message.slice(0, 240) }
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tokens.info` — resolve a symbol or address to token metadata.
|
|
3
|
+
*
|
|
4
|
+
* Source priority: cache → vendored AGNI list → on-chain ERC-20 reads
|
|
5
|
+
* (cache-write-through). Returns `{symbol, name, address, decimals, source}`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ToolDef } from 'nebula-ai-core'
|
|
9
|
+
import { z } from 'zod'
|
|
10
|
+
import { isNativeToken, nativeTokenInfo, resolveToken } from '../tokens'
|
|
11
|
+
import type { OnchainRuntimeContext } from '../types'
|
|
12
|
+
|
|
13
|
+
const Schema = z.object({
|
|
14
|
+
symbol: z.string().optional().describe('Symbol (e.g. "USDCe", "stOG", "Mantle").'),
|
|
15
|
+
address: z.string().optional().describe('0x token contract address.'),
|
|
16
|
+
})
|
|
17
|
+
type Args = z.infer<typeof Schema>
|
|
18
|
+
|
|
19
|
+
export function makeTokensInfo(ctx: OnchainRuntimeContext): ToolDef<Args> {
|
|
20
|
+
return {
|
|
21
|
+
name: 'tokens.info',
|
|
22
|
+
description:
|
|
23
|
+
'Resolve a token symbol or address to {symbol, name, address, decimals, source}. Tries local cache → vendored AGNI token list → on-chain ERC-20 reads (cached after).',
|
|
24
|
+
searchHint: 'token metadata symbol decimals erc20 lookup',
|
|
25
|
+
schema: Schema,
|
|
26
|
+
handler: async args => {
|
|
27
|
+
try {
|
|
28
|
+
const input = args.symbol ?? args.address ?? ''
|
|
29
|
+
if (!input) {
|
|
30
|
+
return { ok: false, error: 'provide one of `symbol` or `address`' }
|
|
31
|
+
}
|
|
32
|
+
if (isNativeToken(input)) {
|
|
33
|
+
return { ok: true, data: nativeTokenInfo() }
|
|
34
|
+
}
|
|
35
|
+
const token = await resolveToken({
|
|
36
|
+
client: ctx.publicClient,
|
|
37
|
+
agentDir: ctx.agentDir,
|
|
38
|
+
input,
|
|
39
|
+
})
|
|
40
|
+
if (!token) {
|
|
41
|
+
return { ok: false, error: `token not found: ${input}` }
|
|
42
|
+
}
|
|
43
|
+
return { ok: true, data: token }
|
|
44
|
+
} catch (e) {
|
|
45
|
+
return { ok: false, error: (e as Error).message.slice(0, 240) }
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chain.send` — native or ERC-20 transfer.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ToolDef } from 'nebula-ai-core'
|
|
6
|
+
import { getGasPriceWithFloor } from 'nebula-ai-core'
|
|
7
|
+
import {
|
|
8
|
+
type Abi,
|
|
9
|
+
type Address,
|
|
10
|
+
type PublicClient,
|
|
11
|
+
getAddress,
|
|
12
|
+
isAddress,
|
|
13
|
+
parseEther,
|
|
14
|
+
parseUnits,
|
|
15
|
+
} from 'viem'
|
|
16
|
+
import { z } from 'zod'
|
|
17
|
+
import { ERC20_ABI } from '../abis'
|
|
18
|
+
import { evaluatePolicy } from '../policy'
|
|
19
|
+
import { simulateContractWrite, simulateNativeSend } from '../simulate'
|
|
20
|
+
import { isNativeToken, resolveToken } from '../tokens'
|
|
21
|
+
import type { OnchainRuntimeContext } from '../types'
|
|
22
|
+
import { waitForReceipt } from '../wait-receipt'
|
|
23
|
+
|
|
24
|
+
const Schema = z.object({
|
|
25
|
+
to: z.string().min(1).describe('Recipient 0x address.'),
|
|
26
|
+
amount: z.string().min(1).describe('Amount in token units (e.g. "0.05" for 0.05 MNT).'),
|
|
27
|
+
token: z
|
|
28
|
+
.string()
|
|
29
|
+
.optional()
|
|
30
|
+
.describe('Symbol or 0x address. Omit / "MNT" / "native" for native transfer.'),
|
|
31
|
+
})
|
|
32
|
+
type Args = z.infer<typeof Schema>
|
|
33
|
+
|
|
34
|
+
// Recipient resolution is 0x-only on Mantle. (Nebula does not depend on an
|
|
35
|
+
// on-chain name service; pass a checksummed/lowercase 0x address.)
|
|
36
|
+
export async function resolveRecipient(to: string, _publicClient: PublicClient): Promise<Address> {
|
|
37
|
+
const trimmed = to.trim()
|
|
38
|
+
if (isAddress(trimmed)) return getAddress(trimmed) as Address
|
|
39
|
+
throw new Error(`cannot resolve recipient "${trimmed}": expected a 0x address`)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function makeChainSend(ctx: OnchainRuntimeContext): ToolDef<Args> {
|
|
43
|
+
return {
|
|
44
|
+
name: 'chain.send',
|
|
45
|
+
description:
|
|
46
|
+
'Transfer Mantle or any ERC-20 from your agent EOA. Pass `token` for ERC-20; omit for native Mantle. Auto-detects decimals via tokens.info.',
|
|
47
|
+
searchHint: 'send transfer 0g native erc20 pay',
|
|
48
|
+
schema: Schema,
|
|
49
|
+
handler: async args => {
|
|
50
|
+
try {
|
|
51
|
+
const recipient = await resolveRecipient(args.to, ctx.publicClient)
|
|
52
|
+
const account = ctx.walletClient.account
|
|
53
|
+
if (!account) {
|
|
54
|
+
return { ok: false, error: 'walletClient has no account; cannot send' }
|
|
55
|
+
}
|
|
56
|
+
const gasPrice = await getGasPriceWithFloor(ctx.publicClient)
|
|
57
|
+
if (isNativeToken(args.token)) {
|
|
58
|
+
const value = parseEther(args.amount)
|
|
59
|
+
// Policy gate (deterministic): block before simulate/execute.
|
|
60
|
+
if (ctx.policy) {
|
|
61
|
+
const verdict = evaluatePolicy(
|
|
62
|
+
{ kind: 'transfer', asset: 'native', amountRaw: value, to: recipient },
|
|
63
|
+
ctx.policy,
|
|
64
|
+
)
|
|
65
|
+
if (!verdict.allowed) {
|
|
66
|
+
return { ok: false, error: `policy blocked: ${verdict.violations.join('; ')}` }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// Simulate-before-write: dry-run against the chain; abort if it would revert.
|
|
70
|
+
const sim = await simulateNativeSend(ctx.publicClient, {
|
|
71
|
+
account: account.address,
|
|
72
|
+
to: recipient,
|
|
73
|
+
value,
|
|
74
|
+
})
|
|
75
|
+
if (!sim.ok) {
|
|
76
|
+
return { ok: false, error: `pre-flight simulation reverted: ${sim.reason}` }
|
|
77
|
+
}
|
|
78
|
+
const txHash = await ctx.walletClient.sendTransaction({
|
|
79
|
+
to: recipient,
|
|
80
|
+
value,
|
|
81
|
+
chain: ctx.walletClient.chain,
|
|
82
|
+
account,
|
|
83
|
+
gasPrice,
|
|
84
|
+
})
|
|
85
|
+
const receipt = await waitForReceipt(ctx.publicClient, txHash)
|
|
86
|
+
return {
|
|
87
|
+
ok: true,
|
|
88
|
+
data: {
|
|
89
|
+
txHash,
|
|
90
|
+
blockNumber: Number(receipt.blockNumber),
|
|
91
|
+
gasUsed: receipt.gasUsed.toString(),
|
|
92
|
+
token: 'Mantle',
|
|
93
|
+
amount: args.amount,
|
|
94
|
+
recipient,
|
|
95
|
+
status: receipt.status === 'success' ? 'success' : 'reverted',
|
|
96
|
+
// Decision receipt: proof this write was policy-checked + simulated.
|
|
97
|
+
simGasEstimate: sim.gas.toString(),
|
|
98
|
+
policyEnforced: ctx.policy != null,
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const token = await resolveToken({
|
|
103
|
+
client: ctx.publicClient,
|
|
104
|
+
agentDir: ctx.agentDir,
|
|
105
|
+
input: args.token!,
|
|
106
|
+
})
|
|
107
|
+
if (!token) {
|
|
108
|
+
return { ok: false, error: `unknown token: ${args.token}` }
|
|
109
|
+
}
|
|
110
|
+
const value = parseUnits(args.amount, token.decimals)
|
|
111
|
+
// Policy gate (deterministic): block before simulate/execute.
|
|
112
|
+
if (ctx.policy) {
|
|
113
|
+
const verdict = evaluatePolicy(
|
|
114
|
+
{ kind: 'transfer', asset: token.address, amountRaw: value, to: recipient },
|
|
115
|
+
ctx.policy,
|
|
116
|
+
)
|
|
117
|
+
if (!verdict.allowed) {
|
|
118
|
+
return { ok: false, error: `policy blocked: ${verdict.violations.join('; ')}` }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Simulate-before-write: dry-run the ERC-20 transfer; abort if it would revert.
|
|
122
|
+
const sim = await simulateContractWrite(ctx.publicClient, {
|
|
123
|
+
account: account.address,
|
|
124
|
+
address: token.address,
|
|
125
|
+
abi: ERC20_ABI as Abi,
|
|
126
|
+
functionName: 'transfer',
|
|
127
|
+
args: [recipient, value],
|
|
128
|
+
})
|
|
129
|
+
if (!sim.ok) {
|
|
130
|
+
return { ok: false, error: `pre-flight simulation reverted: ${sim.reason}` }
|
|
131
|
+
}
|
|
132
|
+
const txHash = await ctx.walletClient.writeContract({
|
|
133
|
+
address: token.address,
|
|
134
|
+
abi: ERC20_ABI,
|
|
135
|
+
functionName: 'transfer',
|
|
136
|
+
args: [recipient, value],
|
|
137
|
+
chain: ctx.walletClient.chain,
|
|
138
|
+
account,
|
|
139
|
+
gasPrice,
|
|
140
|
+
})
|
|
141
|
+
const receipt = await waitForReceipt(ctx.publicClient, txHash)
|
|
142
|
+
return {
|
|
143
|
+
ok: true,
|
|
144
|
+
data: {
|
|
145
|
+
txHash,
|
|
146
|
+
blockNumber: Number(receipt.blockNumber),
|
|
147
|
+
gasUsed: receipt.gasUsed.toString(),
|
|
148
|
+
token: token.symbol,
|
|
149
|
+
tokenAddress: token.address,
|
|
150
|
+
amount: args.amount,
|
|
151
|
+
amountRaw: value.toString(),
|
|
152
|
+
recipient,
|
|
153
|
+
status: receipt.status === 'success' ? 'success' : 'reverted',
|
|
154
|
+
// Decision receipt: proof this write was policy-checked + simulated.
|
|
155
|
+
simGasEstimate: sim.gas.toString(),
|
|
156
|
+
policyEnforced: ctx.policy != null,
|
|
157
|
+
},
|
|
158
|
+
}
|
|
159
|
+
} catch (e) {
|
|
160
|
+
return { ok: false, error: (e as Error).message.slice(0, 240) }
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
}
|
|
164
|
+
}
|