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/approval.ts
ADDED
|
@@ -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
|
+
}
|
package/src/balances.ts
ADDED
|
@@ -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
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -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
|
+
}
|