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,99 @@
1
+ /**
2
+ * Permission-gate bridge between the deterministic policy engine and the
3
+ * harness permission service.
4
+ *
5
+ * The CLI + gateway pre-tool-call hooks build a `PermissionRequest` for every
6
+ * value-moving tool call and ask the permission service to resolve it. That
7
+ * service is driven by the operator's session MODE (strict/prompt/off). On its
8
+ * own it has no notion of "how much" — so under YOLO it would let any in-cap
9
+ * spend through silently. This helper closes that gap: it runs the SAME
10
+ * `evaluatePolicy` the tool runs and, when the policy flags the action as
11
+ * material-risk (`requiresApproval`), the hook sets `force` on the request so
12
+ * approval is demanded beneath the session mode. Fund controls in code, not in
13
+ * the model (CLAUDE.md).
14
+ *
15
+ * Native amounts are parsed from the human MNT arg. Token amounts can't be
16
+ * sized without decimals at this layer, so token/swap escalation rides on the
17
+ * policy autonomy tier (which `evaluatePolicy` resolves without an amount); the
18
+ * tool itself still enforces per-token hard caps with real decimals.
19
+ */
20
+
21
+ import { parseEther } from 'viem'
22
+ import { type OnchainPolicy, type PolicyAction, evaluatePolicy } from './policy'
23
+ import { isNativeToken } from './tokens'
24
+
25
+ function parseMntToWei(v: unknown): bigint {
26
+ if (typeof v !== 'string' && typeof v !== 'number') return 0n
27
+ try {
28
+ return parseEther(String(v))
29
+ } catch {
30
+ return 0n
31
+ }
32
+ }
33
+
34
+ /** `chain.write` already carries `value` in wei (string/number/bigint). */
35
+ function parseWeiLike(v: unknown): bigint {
36
+ if (typeof v === 'bigint') return v
37
+ if (typeof v === 'number' && Number.isFinite(v)) return BigInt(Math.trunc(v))
38
+ if (typeof v === 'string' && /^\d+$/.test(v.trim())) return BigInt(v.trim())
39
+ return 0n
40
+ }
41
+
42
+ /** Map a tool call (name + raw args) to a best-effort PolicyAction. */
43
+ function actionForCall(name: string, a: Record<string, unknown>): PolicyAction | null {
44
+ switch (name) {
45
+ case 'chain.send': {
46
+ const token = typeof a.token === 'string' ? a.token : undefined
47
+ const native = isNativeToken(token)
48
+ return {
49
+ kind: 'transfer',
50
+ asset: native ? 'native' : (token as string),
51
+ amountRaw: native ? parseMntToWei(a.amount) : 0n,
52
+ to: typeof a.to === 'string' ? a.to : undefined,
53
+ }
54
+ }
55
+ case 'chain.wrap':
56
+ case 'chain.unwrap':
57
+ return { kind: 'transfer', asset: 'native', amountRaw: parseMntToWei(a.amount) }
58
+ case 'swap.execute':
59
+ case 'moe.swap':
60
+ case 'swap.best':
61
+ return {
62
+ kind: 'swap',
63
+ asset: typeof a.tokenIn === 'string' ? a.tokenIn : 'native',
64
+ amountRaw: 0n,
65
+ slippageBps: typeof a.slippageBps === 'number' ? a.slippageBps : undefined,
66
+ }
67
+ case 'aave.supply':
68
+ case 'aave.withdraw':
69
+ case 'aave.borrow':
70
+ case 'aave.repay':
71
+ // Token amount needs decimals we don't have here, so the floor rides the
72
+ // autonomy tier (confirm => approval); the tool re-checks with decimals.
73
+ return {
74
+ kind: 'transfer',
75
+ asset: typeof a.token === 'string' ? a.token : 'native',
76
+ amountRaw: 0n,
77
+ }
78
+ case 'chain.write':
79
+ return { kind: 'transfer', asset: 'native', amountRaw: parseWeiLike(a.value) }
80
+ default:
81
+ return null
82
+ }
83
+ }
84
+
85
+ /**
86
+ * True when the policy requires human approval for this tool call (the gate
87
+ * should force a prompt regardless of mode). Returns false when no policy is
88
+ * configured or the call is not value-moving.
89
+ */
90
+ export function policyRequiresApprovalForCall(
91
+ name: string,
92
+ args: Record<string, unknown>,
93
+ policy: OnchainPolicy | undefined,
94
+ ): boolean {
95
+ if (!policy) return false
96
+ const action = actionForCall(name, args)
97
+ if (!action) return false
98
+ return evaluatePolicy(action, policy).requiresApproval
99
+ }
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Balance discovery + multicall reads.
3
+ *
4
+ * The "no curated list" rule from `phase-10-design-locked.md` means
5
+ * `chain.balance` (no token arg) needs to find every ERC-20 the agent has
6
+ * ever held WITHOUT a hardcoded list. We do this by scanning ERC-20 Transfer
7
+ * events keyed on the agent's address as topic2 (recipient) since iNFT mint.
8
+ * The set of distinct contract emitters IS the agent's token universe.
9
+ *
10
+ * Multicall3 then batches `balanceOf` reads on every discovered contract
11
+ * plus a `getEthBalance` for native Mantle — single round-trip.
12
+ */
13
+
14
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
15
+ import { dirname, join } from 'node:path'
16
+ import {
17
+ type Address,
18
+ type PublicClient,
19
+ decodeFunctionResult,
20
+ encodeFunctionData,
21
+ formatEther,
22
+ formatUnits,
23
+ getAddress,
24
+ pad,
25
+ } from 'viem'
26
+ import { ERC20_ABI, MULTICALL3_ABI } from './abis'
27
+ import {
28
+ LOG_SCAN_CHUNK_BLOCKS,
29
+ LOG_SCAN_MAX_CHUNKS,
30
+ MULTICALL3,
31
+ TRANSFER_TOPIC0,
32
+ } from './constants'
33
+ import { rawGetLogs } from './raw-logs'
34
+ import { fetchOnchainErc20Info, loadTokenCache, lookupFromList, rememberTokens } from './tokens'
35
+ import type { BalanceSnapshot, TokenInfo } from './types'
36
+
37
+ function lastScannedBlockPath(agentDir: string): string {
38
+ return join(agentDir, 'onchain', 'last-scanned-block.txt')
39
+ }
40
+
41
+ function readLastScannedBlock(agentDir: string): bigint | null {
42
+ const path = lastScannedBlockPath(agentDir)
43
+ if (!existsSync(path)) return null
44
+ try {
45
+ const raw = readFileSync(path, 'utf8').trim()
46
+ if (!raw) return null
47
+ return BigInt(raw)
48
+ } catch {
49
+ return null
50
+ }
51
+ }
52
+
53
+ function writeLastScannedBlock(agentDir: string, blockNumber: bigint): void {
54
+ const path = lastScannedBlockPath(agentDir)
55
+ mkdirSync(dirname(path), { recursive: true })
56
+ writeFileSync(path, blockNumber.toString())
57
+ }
58
+
59
+ /**
60
+ * Scan ERC-20 Transfer events where `to == address` for the given block range.
61
+ * Returns the unique set of token contract addresses (lowercase). Chunks to
62
+ * stay under the RPC's getLogs limits.
63
+ */
64
+ export async function discoverTokensByTransfers(opts: {
65
+ client: PublicClient
66
+ address: Address
67
+ fromBlock: bigint
68
+ toBlock: bigint
69
+ }): Promise<Address[]> {
70
+ const { client, address, fromBlock, toBlock } = opts
71
+ if (toBlock < fromBlock) return []
72
+ const padded = pad(address, { size: 32 })
73
+ const seen = new Set<string>()
74
+ let cursor = fromBlock
75
+ let chunks = 0
76
+ while (cursor <= toBlock && chunks < LOG_SCAN_MAX_CHUNKS) {
77
+ const chunkEnd = cursor + LOG_SCAN_CHUNK_BLOCKS - 1n
78
+ const end = chunkEnd > toBlock ? toBlock : chunkEnd
79
+ try {
80
+ const logs = await rawGetLogs({
81
+ client,
82
+ topics: [TRANSFER_TOPIC0, null, padded],
83
+ fromBlock: cursor,
84
+ toBlock: end,
85
+ })
86
+ for (const log of logs) {
87
+ seen.add(log.address.toLowerCase())
88
+ }
89
+ } catch {
90
+ // Some RPCs throttle on dense ranges; halve the chunk and continue
91
+ const half = (end - cursor + 1n) / 2n
92
+ if (half > 0n) {
93
+ try {
94
+ const logs = await rawGetLogs({
95
+ client,
96
+ topics: [TRANSFER_TOPIC0, null, padded],
97
+ fromBlock: cursor,
98
+ toBlock: cursor + half - 1n,
99
+ })
100
+ for (const log of logs) {
101
+ seen.add(log.address.toLowerCase())
102
+ }
103
+ } catch {
104
+ // give up on this chunk silently — won't miss balances since
105
+ // we'll still pick up via cache from a later run
106
+ }
107
+ }
108
+ }
109
+ cursor = end + 1n
110
+ chunks += 1
111
+ }
112
+ return Array.from(seen).map(a => getAddress(a) as Address)
113
+ }
114
+
115
+ /**
116
+ * Read native + ERC-20 balances for `address` via Multicall3 in one round-trip.
117
+ * Tokens with zero balance are still returned; caller filters.
118
+ */
119
+ export async function readBalancesMulticall(opts: {
120
+ client: PublicClient
121
+ address: Address
122
+ tokens: TokenInfo[]
123
+ }): Promise<{
124
+ blockNumber: number
125
+ native: bigint
126
+ perToken: Map<string, bigint>
127
+ }> {
128
+ const { client, address, tokens } = opts
129
+ const calls: Array<{
130
+ target: Address
131
+ allowFailure: boolean
132
+ callData: `0x${string}`
133
+ }> = []
134
+ // [0] native via Multicall3.getEthBalance
135
+ calls.push({
136
+ target: MULTICALL3,
137
+ allowFailure: false,
138
+ callData: encodeFunctionData({
139
+ abi: MULTICALL3_ABI,
140
+ functionName: 'getEthBalance',
141
+ args: [address],
142
+ }),
143
+ })
144
+ // [1..n] ERC-20 balanceOf
145
+ for (const t of tokens) {
146
+ calls.push({
147
+ target: t.address,
148
+ allowFailure: true,
149
+ callData: encodeFunctionData({
150
+ abi: ERC20_ABI,
151
+ functionName: 'balanceOf',
152
+ args: [address],
153
+ }),
154
+ })
155
+ }
156
+ const blockNumber = await client.getBlockNumber()
157
+ const results = (await client.readContract({
158
+ address: MULTICALL3,
159
+ abi: MULTICALL3_ABI,
160
+ functionName: 'aggregate3',
161
+ args: [calls],
162
+ })) as ReadonlyArray<{ success: boolean; returnData: `0x${string}` }>
163
+ const native = decodeFunctionResult({
164
+ abi: MULTICALL3_ABI,
165
+ functionName: 'getEthBalance',
166
+ data: results[0]!.returnData,
167
+ }) as bigint
168
+ const perToken = new Map<string, bigint>()
169
+ for (let i = 0; i < tokens.length; i++) {
170
+ const r = results[i + 1]
171
+ if (!r?.success) continue
172
+ try {
173
+ const bal = decodeFunctionResult({
174
+ abi: ERC20_ABI,
175
+ functionName: 'balanceOf',
176
+ data: r.returnData,
177
+ }) as bigint
178
+ perToken.set(tokens[i]!.address.toLowerCase(), bal)
179
+ } catch {
180
+ // ignore unparseable
181
+ }
182
+ }
183
+ return { blockNumber: Number(blockNumber), native, perToken }
184
+ }
185
+
186
+ /**
187
+ * High-level: discover tokens via Transfer scan + cached set, then multicall
188
+ * balances. Persists cache + last-scanned block.
189
+ */
190
+ export async function snapshotBalances(opts: {
191
+ client: PublicClient
192
+ agentDir: string
193
+ address: Address
194
+ mintBlock: bigint
195
+ includeZero?: boolean
196
+ refresh?: boolean
197
+ }): Promise<BalanceSnapshot> {
198
+ const { client, agentDir, address, mintBlock, includeZero, refresh } = opts
199
+ const cache = loadTokenCache(agentDir)
200
+ const cachedTokens: TokenInfo[] = Object.values(cache.byAddress)
201
+ const head = await client.getBlockNumber()
202
+ const lastScanned = refresh ? null : readLastScannedBlock(agentDir)
203
+ const fromBlock = lastScanned !== null ? lastScanned + 1n : mintBlock
204
+ const newAddrs =
205
+ fromBlock <= head
206
+ ? await discoverTokensByTransfers({
207
+ client,
208
+ address,
209
+ fromBlock,
210
+ toBlock: head,
211
+ })
212
+ : []
213
+
214
+ // Resolve metadata for any new addresses not in cache/list. Metadata reads
215
+ // are independent — fan out so a 20-token discovery doesn't serialize 20
216
+ // round-trips. List hits skip the network entirely.
217
+ const cachedSet = new Set(cachedTokens.map(t => t.address.toLowerCase()))
218
+ const toResolve = newAddrs.filter(a => !cachedSet.has(a.toLowerCase()))
219
+ const resolvedRaw = await Promise.all(
220
+ toResolve.map(a => {
221
+ const fromList = lookupFromList(a, cache)
222
+ return fromList !== null ? Promise.resolve(fromList) : fetchOnchainErc20Info(client, a)
223
+ }),
224
+ )
225
+ const resolved = resolvedRaw.filter((t): t is TokenInfo => t !== null)
226
+ if (resolved.length > 0) rememberTokens(agentDir, resolved)
227
+ // Skip the file write when the cursor didn't advance (multiple
228
+ // chain.balance calls in the same chat turn end up on the same head).
229
+ if (lastScanned === null || head > lastScanned) {
230
+ writeLastScannedBlock(agentDir, head)
231
+ }
232
+
233
+ const allTokens = [...cachedTokens, ...resolved]
234
+ const { blockNumber, native, perToken } = await readBalancesMulticall({
235
+ client,
236
+ address,
237
+ tokens: allTokens,
238
+ })
239
+ const tokenBalances = allTokens
240
+ .map(t => {
241
+ const raw = perToken.get(t.address.toLowerCase()) ?? 0n
242
+ return {
243
+ ...t,
244
+ raw: raw.toString(),
245
+ formatted: formatUnits(raw, t.decimals),
246
+ }
247
+ })
248
+ .filter(b => includeZero || b.raw !== '0')
249
+ .sort((a, b) => {
250
+ const ar = BigInt(a.raw)
251
+ const br = BigInt(b.raw)
252
+ if (ar > br) return -1
253
+ if (ar < br) return 1
254
+ return a.symbol.localeCompare(b.symbol)
255
+ })
256
+ return {
257
+ address,
258
+ native: { raw: native.toString(), formatted: formatEther(native) },
259
+ tokens: tokenBalances,
260
+ blockNumber,
261
+ }
262
+ }
package/src/bybit.ts ADDED
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Bybit V5 read-only client — account balance (portfolio view) only.
3
+ *
4
+ * DELIBERATELY READ-ONLY. CEX trading/transfers are out of scope: they execute
5
+ * off-chain and would bypass Nebula's on-chain policy -> simulate -> approval
6
+ * pipeline (the whole safety thesis). This only reads the Unified account
7
+ * balance so the agent can show a CEX + on-chain treasury picture.
8
+ *
9
+ * Auth: V5 HMAC-SHA256. Keys come from the ENVIRONMENT only
10
+ * (BYBIT_API_KEY / BYBIT_API_SECRET) — never committed.
11
+ */
12
+
13
+ import { createHmac } from 'node:crypto'
14
+
15
+ export const BYBIT_BASE = 'https://api.bybit.com'
16
+ const RECV_WINDOW = '5000'
17
+
18
+ /**
19
+ * V5 signature: HMAC_SHA256(timestamp + apiKey + recvWindow + payload, secret).
20
+ * For GET, payload is the (already-ordered) query string. Pure + unit-testable.
21
+ */
22
+ export function bybitSign(opts: {
23
+ secret: string
24
+ timestamp: string
25
+ apiKey: string
26
+ recvWindow: string
27
+ payload: string
28
+ }): string {
29
+ return createHmac('sha256', opts.secret)
30
+ .update(opts.timestamp + opts.apiKey + opts.recvWindow + opts.payload)
31
+ .digest('hex')
32
+ }
33
+
34
+ export interface BybitCoin {
35
+ coin: string
36
+ walletBalance: string
37
+ /** Bybit-reported USD value of the holding (exchange data, not Nebula pricing). */
38
+ usdValue: string
39
+ }
40
+
41
+ export interface BybitBalanceResult {
42
+ ok: boolean
43
+ error?: string
44
+ accountType?: string
45
+ /** Bybit-reported total equity in USD (exchange figure). */
46
+ totalEquityUsd?: string
47
+ coins: BybitCoin[]
48
+ }
49
+
50
+ /**
51
+ * Read the Unified Trading account balance. `now` is injectable for tests.
52
+ */
53
+ export async function fetchBybitBalance(opts: {
54
+ apiKey: string
55
+ apiSecret: string
56
+ fetchImpl?: typeof fetch
57
+ now?: () => number
58
+ }): Promise<BybitBalanceResult> {
59
+ const { apiKey, apiSecret, fetchImpl } = opts
60
+ const f = fetchImpl ?? fetch
61
+ const now = opts.now ?? (() => Date.now())
62
+ const timestamp = String(now())
63
+ const query = 'accountType=UNIFIED'
64
+ const sign = bybitSign({
65
+ secret: apiSecret,
66
+ timestamp,
67
+ apiKey,
68
+ recvWindow: RECV_WINDOW,
69
+ payload: query,
70
+ })
71
+
72
+ let res: Response
73
+ try {
74
+ res = await f(`${BYBIT_BASE}/v5/account/wallet-balance?${query}`, {
75
+ method: 'GET',
76
+ headers: {
77
+ 'X-BAPI-API-KEY': apiKey,
78
+ 'X-BAPI-TIMESTAMP': timestamp,
79
+ 'X-BAPI-RECV-WINDOW': RECV_WINDOW,
80
+ 'X-BAPI-SIGN': sign,
81
+ },
82
+ })
83
+ } catch (e) {
84
+ return {
85
+ ok: false,
86
+ error: `Bybit request failed: ${(e as Error).message.slice(0, 120)}`,
87
+ coins: [],
88
+ }
89
+ }
90
+ const j = (await res.json().catch(() => ({}))) as {
91
+ retCode?: number
92
+ retMsg?: string
93
+ result?: {
94
+ list?: Array<{
95
+ accountType?: string
96
+ totalEquity?: string
97
+ coin?: Array<{ coin?: string; walletBalance?: string; usdValue?: string }>
98
+ }>
99
+ }
100
+ }
101
+ if (j.retCode !== 0) {
102
+ return { ok: false, error: `Bybit: ${j.retMsg ?? `retCode ${j.retCode}`}`, coins: [] }
103
+ }
104
+ const acct = j.result?.list?.[0]
105
+ const coins = (acct?.coin ?? [])
106
+ .map(c => ({
107
+ coin: c.coin ?? '?',
108
+ walletBalance: c.walletBalance ?? '0',
109
+ usdValue: c.usdValue ?? '0',
110
+ }))
111
+ .filter(c => Number(c.walletBalance) > 0)
112
+ return {
113
+ ok: true,
114
+ accountType: acct?.accountType ?? 'UNIFIED',
115
+ totalEquityUsd: acct?.totalEquity ?? '0',
116
+ coins,
117
+ }
118
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Mainnet-verified contract addresses for Mantle Aristotle (chain 5000).
3
+ * All four core protocols below were probed live on May 1 2026 with successful
4
+ * txs; see memory `phase-10-design-locked.md` for the cast verifications.
5
+ */
6
+
7
+ import type { NebulaNetwork } from 'nebula-ai-core'
8
+ import type { Address } from 'viem'
9
+
10
+ /** Multicall3 universal address — same on every EVM chain that has it. */
11
+ export const MULTICALL3: Address = '0xcA11bde05977b3631167028862bE2a173976CA11'
12
+
13
+ /** AGNI protocol contracts (Uniswap V3 softfork on Mantle). */
14
+ export interface AgniAddresses {
15
+ factory: Address
16
+ swapRouter: Address
17
+ quoter: Address
18
+ weth9: Address
19
+ }
20
+
21
+ export const AGNI_BY_NETWORK: Record<NebulaNetwork, AgniAddresses | null> = {
22
+ 'mantle-mainnet': {
23
+ // Agni Finance (Uniswap V3 fork) on Mantle mainnet. Source: official
24
+ // agni-sdk HomeAddress.ts; factory + swapRouter cross-verified on-chain.
25
+ factory: '0x25780dc8Fc3cfBD75F33bFDAB65e969b603b2035',
26
+ swapRouter: '0x319B69888b0d11cEC22caA5034e25FfFBDc88421',
27
+ quoter: '0x9488C05a7b75a6FefdcAE4f11a33467bcBA60177', // QuoterV1 (5-arg quoteExactInputSingle)
28
+ weth9: '0x78c1b0C915c4FAA5FffA6CAbf0219DA63d7f4cb8', // WMNT
29
+ },
30
+ 'mantle-testnet': null, // Agni not wired for Mantle Sepolia testnet.
31
+ }
32
+
33
+ /** Aave V3 Pool on Mantle. Verified live (getReservesList returns 10 markets). */
34
+ export const AAVE_POOL_BY_NETWORK: Record<NebulaNetwork, Address | null> = {
35
+ 'mantle-mainnet': '0x458F293454fE0d67EC0655f3672301301DD51422',
36
+ 'mantle-testnet': null,
37
+ }
38
+
39
+ /** Merchant Moe Liquidity Book contracts (LFJ/Trader Joe LB fork on Mantle). */
40
+ export interface MoeLbAddresses {
41
+ router: Address
42
+ quoter: Address
43
+ factory: Address
44
+ }
45
+
46
+ /**
47
+ * Merchant Moe LB on Mantle mainnet. Source: official docs
48
+ * (docs.merchantmoe.com/resources/contracts); all three cross-verified on-chain
49
+ * (deployed bytecode) + the quoter live-verified (1 WMNT -> 0.55 USDC).
50
+ */
51
+ export const MOE_LB_BY_NETWORK: Record<NebulaNetwork, MoeLbAddresses | null> = {
52
+ 'mantle-mainnet': {
53
+ router: '0x013e138EF6008ae5FDFDE29700e3f2Bc61d21E3a',
54
+ quoter: '0x501b8AFd35df20f531fF45F6f695793AC3316c85',
55
+ factory: '0xa6630671775c4EA2743840F9A5016dCf2A104054',
56
+ },
57
+ 'mantle-testnet': null,
58
+ }
59
+
60
+ /** AGNI V3 fee tiers in increasing order (1 bp = 0.01%). */
61
+ export const FEE_TIERS = [500, 3000, 10000] as const
62
+ export type FeeTier = (typeof FEE_TIERS)[number]
63
+
64
+ /** Default swap deadline: 10 minutes. */
65
+ export const DEFAULT_DEADLINE_SECS = 600n
66
+
67
+ /** Default slippage tolerance (50 bps = 0.5%). */
68
+ export const DEFAULT_SLIPPAGE_BPS = 50
69
+
70
+ /** Block-range chunk size for `eth_getLogs`. 50k chunks safe on Mantle mainnet RPC. */
71
+ export const LOG_SCAN_CHUNK_BLOCKS = 50_000n
72
+
73
+ /** keccak256("Transfer(address,address,uint256)") — ERC-20/721 Transfer topic0. */
74
+ export const TRANSFER_TOPIC0 =
75
+ '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef' as const
76
+
77
+ /** Max chunks per `chain.balance` discovery scan = 1.5M block ceiling. */
78
+ export const LOG_SCAN_MAX_CHUNKS = 30
79
+
80
+ /** EIP-1967 implementation slot for proxy detection. */
81
+ export const EIP1967_IMPL_SLOT =
82
+ '0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc' as const
83
+
84
+ /** ERC-165 interface IDs nebula checks via `chain.contract`. */
85
+ export const ERC165_INTERFACES = {
86
+ ERC721: '0x80ac58cd',
87
+ ERC1155: '0xd9b67a26',
88
+ ERC721Metadata: '0x5b5e139f',
89
+ ERC721Enumerable: '0x780e9d63',
90
+ } as const
91
+
92
+ /** Symbols the brain may say in lieu of "native" / address. MNT is Mantle's gas token. */
93
+ export const NATIVE_ALIASES = new Set(['MNT', 'mnt', 'native', 'Mantle', 'mantle'])
94
+
95
+ /** Convenience guard that throws if the network has no AGNI deployment. */
96
+ export function requireMainnet(network: NebulaNetwork): asserts network is 'mantle-mainnet' {
97
+ if (network !== 'mantle-mainnet') {
98
+ throw new Error(
99
+ `plugin-onchain currently supports mantle-mainnet only (got ${network}). AGNI isn't deployed on testnet.`,
100
+ )
101
+ }
102
+ }