@finchagentic/mcp 4.6.1 → 4.6.2
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/package.json +5 -6
- package/dist/_http-cache.js +0 -96
- package/dist/_text-search.js +0 -39
- package/dist/agent-loop.js +0 -301
- package/dist/annotations.js +0 -122
- package/dist/cli.js +0 -1391
- package/dist/clink-input.js +0 -15
- package/dist/config.js +0 -132
- package/dist/convex.js +0 -175
- package/dist/dex-pair.js +0 -54
- package/dist/enrichment-router.js +0 -315
- package/dist/index.js +0 -258
- package/dist/llm.js +0 -298
- package/dist/local-memory-file.js +0 -150
- package/dist/local-memory.js +0 -135
- package/dist/local-vault.js +0 -456
- package/dist/output-schemas.js +0 -605
- package/dist/project.js +0 -36
- package/dist/prompts.js +0 -111
- package/dist/public-url.js +0 -107
- package/dist/resources.js +0 -111
- package/dist/server.js +0 -322
- package/dist/signal-gate.js +0 -57
- package/dist/token-decimals.js +0 -26
- package/dist/token-gate.js +0 -88
- package/dist/tool-filter.js +0 -53
- package/dist/tools/_solidity-scan.js +0 -313
- package/dist/tools/agents.js +0 -441
- package/dist/tools/automation.js +0 -354
- package/dist/tools/base-mcp.js +0 -466
- package/dist/tools/base.js +0 -283
- package/dist/tools/chronicle.js +0 -268
- package/dist/tools/coder.js +0 -94
- package/dist/tools/deep-research.js +0 -1421
- package/dist/tools/defi.js +0 -292
- package/dist/tools/equity.js +0 -372
- package/dist/tools/events.js +0 -182
- package/dist/tools/github.js +0 -564
- package/dist/tools/insider.js +0 -264
- package/dist/tools/insight.js +0 -630
- package/dist/tools/market.js +0 -555
- package/dist/tools/memory.js +0 -1059
- package/dist/tools/miroshark.js +0 -350
- package/dist/tools/monitor.js +0 -319
- package/dist/tools/os.js +0 -236
- package/dist/tools/packets.js +0 -296
- package/dist/tools/research-chain.js +0 -226
- package/dist/tools/research-compare.js +0 -280
- package/dist/tools/research.js +0 -188
- package/dist/tools/rh-bridge.js +0 -148
- package/dist/tools/rh-mcp.js +0 -1448
- package/dist/tools/rh-orders.js +0 -556
- package/dist/tools/scanner.js +0 -564
- package/dist/tools/stake.js +0 -369
- package/dist/tools/vault.js +0 -1020
- package/dist/tools/wallet.js +0 -200
- package/dist/types.js +0 -2
- package/dist/wallet.js +0 -372
package/dist/tools/rh-mcp.js
DELETED
|
@@ -1,1448 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
// Robinhood Chain MCP — `rh_mcp_*` namespace (parity with `base_mcp_*`).
|
|
3
|
-
// Tokenized equities on chainId 4663 via Finch Uni V4 (NOT 0x equities,
|
|
4
|
-
// NOT Robinhood Agentic brokerage MCP at agent.robinhood.com).
|
|
5
|
-
//
|
|
6
|
-
// Quote path: Convex `walletActions.zeroXQuote({ chainId: 4663 })` → Uni V4
|
|
7
|
-
// (direct ETH↔stock or multi-hop ETH↔USDG↔stock for thin pairs like BE).
|
|
8
|
-
//
|
|
9
|
-
// Swap execute: local MCP wallet signs on RH RPC. Buys (ETH→stock) work in one
|
|
10
|
-
// tx. Sells need 2-step Permit2 (ERC20→Permit2, Permit2→Universal Router).
|
|
11
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
-
exports.RH_MCP_TOOLS = exports.RH_STOCKS = exports.RH_EXPLORER = exports.RH_RPC = exports.RH_CHAIN_ID = void 0;
|
|
13
|
-
exports.dexTokenBest = dexTokenBest;
|
|
14
|
-
exports.resolveTokenSmart = resolveTokenSmart;
|
|
15
|
-
exports.buildRhAnalysis = buildRhAnalysis;
|
|
16
|
-
exports.buildRhStocksList = buildRhStocksList;
|
|
17
|
-
exports.rhProviderAsync = rhProviderAsync;
|
|
18
|
-
exports.rhErc20Balance = rhErc20Balance;
|
|
19
|
-
exports.rhPriceUsd = rhPriceUsd;
|
|
20
|
-
exports.buildRhSafetyStructured = buildRhSafetyStructured;
|
|
21
|
-
exports.handleRhMcpTool = handleRhMcpTool;
|
|
22
|
-
const ethers_1 = require("ethers");
|
|
23
|
-
const convex_js_1 = require("../convex.js");
|
|
24
|
-
const config_js_1 = require("../config.js");
|
|
25
|
-
const wallet_js_1 = require("../wallet.js");
|
|
26
|
-
const dex_pair_js_1 = require("../dex-pair.js");
|
|
27
|
-
const wallet_js_2 = require("./wallet.js");
|
|
28
|
-
exports.RH_CHAIN_ID = 4663;
|
|
29
|
-
exports.RH_RPC = process.env.ROBINHOOD_RPC_URL ??
|
|
30
|
-
process.env.RH_RPC_URL ??
|
|
31
|
-
process.env.FINCH_RH_RPC_URL ??
|
|
32
|
-
"https://rpc.mainnet.chain.robinhood.com";
|
|
33
|
-
/** Host only, for display in tool output - never the full RH_RPC. An
|
|
34
|
-
* operator can point ROBINHOOD_RPC_URL/RH_RPC_URL at a keyed provider
|
|
35
|
-
* (same pattern app/convex just fixed for its own Alchemy-key leak risk -
|
|
36
|
-
* a `.../v2/<key>`-style URL echoed verbatim into a tool response lands the
|
|
37
|
-
* key in chat transcripts/client logs). Falls back to the raw value only
|
|
38
|
-
* if it isn't a parseable URL, which should never happen in practice. */
|
|
39
|
-
function rhRpcDisplay() {
|
|
40
|
-
try {
|
|
41
|
-
return new URL(exports.RH_RPC).host;
|
|
42
|
-
}
|
|
43
|
-
catch {
|
|
44
|
-
return "(configured)";
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
exports.RH_EXPLORER = "https://robinhoodchain.blockscout.com";
|
|
48
|
-
const NATIVE_ETH = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE";
|
|
49
|
-
const RH_PERMIT2 = "0x000000000022D473030F116dDEE9F6B43aC78BA3";
|
|
50
|
-
const RH_UNIVERSAL_ROUTER = "0x8876789976decbfcbbbe364623c63652db8c0904";
|
|
51
|
-
/**
|
|
52
|
-
* Canonical Global Dollar (USDG) — RH chain's settlement stablecoin (same
|
|
53
|
-
* address as app/convex/_settlement.ts). Hardcoded rather than resolved via
|
|
54
|
-
* DexScreener ticker search: "USDG" is the exact symbol an imposter contract
|
|
55
|
-
* would spoof, and dexSearchByTicker ranks by pool liquidity, not authenticity
|
|
56
|
-
* — a fake pool with inflated liquidity could otherwise outrank the real token.
|
|
57
|
-
*/
|
|
58
|
-
const USDG_ADDRESS = "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168";
|
|
59
|
-
const USDG_DECIMALS = 6;
|
|
60
|
-
/** ClawHood / Finch catalog (22 tokenized stocks — official "Robinhood Token" contracts). */
|
|
61
|
-
exports.RH_STOCKS = [
|
|
62
|
-
{ address: "0xaf3d76f1834a1d425780943c99ea8a608f8a93f9", symbol: "AAPL", name: "Apple" },
|
|
63
|
-
{ address: "0x86923f96303d656e4aa86d9d42d1e57ad2023fdc", symbol: "AMD", name: "AMD" },
|
|
64
|
-
{ address: "0x12f190a9f9d7d37a250758b26824b97ce941bf54", symbol: "AMZN", name: "Amazon" },
|
|
65
|
-
{ address: "0x822cc93ffd030293e9842c30bbd678f530701867", symbol: "BE", name: "Bloom Energy" },
|
|
66
|
-
{ address: "0x6330d8c3178a418788df01a47479c0ce7ccf450b", symbol: "COIN", name: "Coinbase" },
|
|
67
|
-
{ address: "0x5f10a1c971b69e47e059e1dc91901b59b3fb49c3", symbol: "CRWV", name: "CoreWeave" },
|
|
68
|
-
{ address: "0x1b0e319c6a659f002271b69db8a7df2f911c153e", symbol: "GME", name: "GameStop" },
|
|
69
|
-
{ address: "0x2e0847e8910a9732eb3fb1bb4b70a580adad4fe3", symbol: "GOOGL", name: "Alphabet" },
|
|
70
|
-
{ address: "0xc72b96e0e48ecd4dc75e1e45396e26300bc39681", symbol: "INTC", name: "Intel" },
|
|
71
|
-
{ address: "0xc0d6457c16cc70d6790dd43521c899c87ce02f35", symbol: "META", name: "Meta" },
|
|
72
|
-
{ address: "0xe93237c50d904957cf27e7b1133b510c669c2e74", symbol: "MSFT", name: "Microsoft" },
|
|
73
|
-
{ address: "0xff080c8ce2e5feadaca0da81314ae59d232d4afd", symbol: "MU", name: "Micron" },
|
|
74
|
-
{ address: "0xe0444ef8bf4ed74f74fd73686e2ddf4c1c5591e8", symbol: "NFLX", name: "Netflix" },
|
|
75
|
-
{ address: "0xd0601ce157db5bdc3162bbac2a2c8af5320d9eec", symbol: "NVDA", name: "NVIDIA" },
|
|
76
|
-
{ address: "0xb0992820e760d836549ba69bc7598b4af75dee03", symbol: "ORCL", name: "Oracle" },
|
|
77
|
-
{ address: "0x894e1ec2d74ffe5aef8dc8a9e84686accb964f2a", symbol: "PLTR", name: "Palantir" },
|
|
78
|
-
{ address: "0xd5f3879160bc7c32ebb4dc785f8a4f505888de68", symbol: "QQQ", name: "Invesco QQQ" },
|
|
79
|
-
{ address: "0xb90a19ff0af67f7779aff50a882a9cff42446400", symbol: "SNDK", name: "Sandisk" },
|
|
80
|
-
{ address: "0x4a0e65a3eccec6dbe60ae065f2e7bb85fae35eea", symbol: "SPCX", name: "SPACEX" },
|
|
81
|
-
{ address: "0x117cc2133c37b721f49de2a7a74833232b3b4c0c", symbol: "SPY", name: "SPDR S&P 500" },
|
|
82
|
-
{ address: "0x322f0929c4625ed5bad873c95208d54e1c003b2d", symbol: "TSLA", name: "Tesla" },
|
|
83
|
-
{ address: "0xd917b029c761d264c6a312bbbcda868658ef86a6", symbol: "USAR", name: "USA Rare Earth" },
|
|
84
|
-
];
|
|
85
|
-
exports.RH_MCP_TOOLS = [
|
|
86
|
-
{
|
|
87
|
-
name: "rh_mcp_status",
|
|
88
|
-
description: "Robinhood Chain MCP - status of RH rail (chainId 4663): wallet address, RPC, " +
|
|
89
|
-
"ETH gas balance on RH, explorer. Use at the start of any tokenized-stock session. " +
|
|
90
|
-
"NOT Robinhood Agentic brokerage (agent.robinhood.com).",
|
|
91
|
-
inputSchema: { type: "object", properties: {}, required: [] },
|
|
92
|
-
},
|
|
93
|
-
{
|
|
94
|
-
name: "rh_mcp_list_stocks",
|
|
95
|
-
description: "Robinhood Chain MCP - list the 22 Finch/ClawHood tokenized stock tickers " +
|
|
96
|
-
"(symbol, name, contract address) tradeable via Uniswap V4 on chain 4663.",
|
|
97
|
-
inputSchema: {
|
|
98
|
-
type: "object",
|
|
99
|
-
properties: {
|
|
100
|
-
query: {
|
|
101
|
-
type: "string",
|
|
102
|
-
description: "Optional filter: symbol or name substring (e.g. 'NVDA', 'Apple')",
|
|
103
|
-
},
|
|
104
|
-
},
|
|
105
|
-
required: [],
|
|
106
|
-
},
|
|
107
|
-
},
|
|
108
|
-
{
|
|
109
|
-
name: "rh_mcp_balance",
|
|
110
|
-
description: "Robinhood Chain MCP - ETH + tokenized stock balances on RH (chain 4663) for your " +
|
|
111
|
-
"Finch MCP wallet (same address as Base). Reads RH RPC directly (not Alchemy Base).",
|
|
112
|
-
inputSchema: {
|
|
113
|
-
type: "object",
|
|
114
|
-
properties: {
|
|
115
|
-
address: {
|
|
116
|
-
type: "string",
|
|
117
|
-
description: "Optional 0x address (default: local Finch MCP wallet)",
|
|
118
|
-
},
|
|
119
|
-
},
|
|
120
|
-
required: [],
|
|
121
|
-
},
|
|
122
|
-
},
|
|
123
|
-
{
|
|
124
|
-
name: "rh_mcp_estimate",
|
|
125
|
-
description: "Robinhood Chain MCP - preview ETH↔token swap quote via Uniswap V4 (direct or " +
|
|
126
|
-
"multi-hop via USDG). Does NOT execute. Always call before rh_mcp_swap. " +
|
|
127
|
-
"fromToken/toToken: 'ETH' or ANY RH-chain asset — a catalog stock symbol " +
|
|
128
|
-
"(NVDA, AAPL…), a crypto ticker, or a 0x contract address. Non-catalog tickers/CAs " +
|
|
129
|
-
"resolve via DexScreener. Route is always ETH↔token (buy with ETH or sell for ETH).",
|
|
130
|
-
inputSchema: {
|
|
131
|
-
type: "object",
|
|
132
|
-
properties: {
|
|
133
|
-
fromToken: { type: "string", description: "Sell asset: ETH, ticker, or 0x contract address" },
|
|
134
|
-
toToken: { type: "string", description: "Buy asset: ETH, ticker, or 0x contract address" },
|
|
135
|
-
amount: { type: "string", description: "Human amount, e.g. '0.01' ETH or '1000' TOKEN" },
|
|
136
|
-
maxSlippagePct: {
|
|
137
|
-
type: "number",
|
|
138
|
-
description: "Slippage tolerance % (default 2.0; raise for thin crypto pairs)",
|
|
139
|
-
},
|
|
140
|
-
},
|
|
141
|
-
required: ["fromToken", "toToken", "amount"],
|
|
142
|
-
},
|
|
143
|
-
},
|
|
144
|
-
{
|
|
145
|
-
name: "rh_mcp_swap",
|
|
146
|
-
description: "Robinhood Chain MCP - execute ETH↔token swap on chain 4663. Quotes Uniswap V2, V3 and V4 " +
|
|
147
|
-
"and routes through whichever returns the most output. Works for catalog stocks AND " +
|
|
148
|
-
"arbitrary RH-chain crypto (by ticker or 0x address; resolved via DexScreener). Use " +
|
|
149
|
-
"rh_mcp_estimate first — and rh_analyze / rh_safety_check for unknown crypto. " +
|
|
150
|
-
"Buys (ETH→token) are 1 tx. Sells approve automatically, and the approval differs by route: " +
|
|
151
|
-
"V4 uses the 2-step Permit2 flow, V2/V3 use a single ERC-20 approve to SwapRouter02. " +
|
|
152
|
-
"Refuses to broadcast when the quote is far below DexScreener spot (override: acceptBadPrice). " +
|
|
153
|
-
"Reports the mined receipt — status, block, gas and the ACTUAL amount received. " +
|
|
154
|
-
"Gas paid in ETH on RH. NOT brokerage orders — for Robinhood Agentic brokerage use official MCP separately.",
|
|
155
|
-
inputSchema: {
|
|
156
|
-
type: "object",
|
|
157
|
-
properties: {
|
|
158
|
-
fromToken: { type: "string", description: "Sell asset: ETH, ticker, or 0x contract address" },
|
|
159
|
-
toToken: { type: "string", description: "Buy asset: ETH, ticker, or 0x contract address" },
|
|
160
|
-
amount: { type: "string", description: "Human amount" },
|
|
161
|
-
maxSlippagePct: { type: "number", description: "Slippage % (default 2.0)" },
|
|
162
|
-
confirm: {
|
|
163
|
-
type: "boolean",
|
|
164
|
-
description: "Must be true to broadcast. Prevents accidental live swaps.",
|
|
165
|
-
},
|
|
166
|
-
acceptBadPrice: {
|
|
167
|
-
type: "boolean",
|
|
168
|
-
description: "Override the spot-price safety stop. Only set if you deliberately accept " +
|
|
169
|
-
"receiving far less than DexScreener spot value (near-empty V4 pool).",
|
|
170
|
-
},
|
|
171
|
-
},
|
|
172
|
-
required: ["fromToken", "toToken", "amount", "confirm"],
|
|
173
|
-
},
|
|
174
|
-
},
|
|
175
|
-
{
|
|
176
|
-
name: "rh_token_resolve",
|
|
177
|
-
description: "Robinhood Chain MCP - resolve a crypto ticker OR 0x contract address to a tradeable " +
|
|
178
|
-
"token on chain 4663 via DexScreener. Ticker search returns candidates ranked by " +
|
|
179
|
-
"liquidity (tickers can be spoofed — always trade by the confirmed contract address). " +
|
|
180
|
-
"Use before rh_mcp_estimate/rh_mcp_swap for non-catalog crypto.",
|
|
181
|
-
inputSchema: {
|
|
182
|
-
type: "object",
|
|
183
|
-
properties: {
|
|
184
|
-
query: { type: "string", description: "Crypto ticker (e.g. 'PEPE') or 0x contract address" },
|
|
185
|
-
},
|
|
186
|
-
required: ["query"],
|
|
187
|
-
},
|
|
188
|
-
},
|
|
189
|
-
{
|
|
190
|
-
name: "rh_analyze",
|
|
191
|
-
description: "Robinhood Chain MCP - market + risk pre-screen for any RH-chain token (ticker or 0x " +
|
|
192
|
-
"address). Pulls DexScreener data (price, liquidity, 24h volume, buy/sell txns, pair age, " +
|
|
193
|
-
"FDV/MCap) and returns a 0-100 risk score with reasoning flags. Combine with rh_safety_check " +
|
|
194
|
-
"(onchain) and deep_research (X sentiment) for a full verdict. Not financial advice.",
|
|
195
|
-
inputSchema: {
|
|
196
|
-
type: "object",
|
|
197
|
-
properties: {
|
|
198
|
-
token: { type: "string", description: "Crypto ticker or 0x contract address on RH chain 4663" },
|
|
199
|
-
},
|
|
200
|
-
required: ["token"],
|
|
201
|
-
},
|
|
202
|
-
},
|
|
203
|
-
{
|
|
204
|
-
name: "rh_safety_check",
|
|
205
|
-
description: "Robinhood Chain MCP - free onchain safety scan for a token (ticker or 0x address). Reads " +
|
|
206
|
-
"Blockscout (contract verified?, explorer reputation, holder count, launchpad-style contract " +
|
|
207
|
-
"name) + DexScreener (sellable? honeypot signal from buys-with-0-sells). No LLM, no paid API. " +
|
|
208
|
-
"Returns red/yellow/green safety flags + a risk score. Pair with rh_analyze (market/liquidity).",
|
|
209
|
-
inputSchema: {
|
|
210
|
-
type: "object",
|
|
211
|
-
properties: {
|
|
212
|
-
token: { type: "string", description: "Crypto ticker or 0x contract address on RH chain 4663" },
|
|
213
|
-
},
|
|
214
|
-
required: ["token"],
|
|
215
|
-
},
|
|
216
|
-
},
|
|
217
|
-
];
|
|
218
|
-
function textResult(text, isError = false) {
|
|
219
|
-
return { content: [{ type: "text", text }], isError };
|
|
220
|
-
}
|
|
221
|
-
function isAddress(s) {
|
|
222
|
-
return /^0x[a-fA-F0-9]{40}$/.test(s);
|
|
223
|
-
}
|
|
224
|
-
function isEth(sym) {
|
|
225
|
-
const s = sym.trim().toUpperCase();
|
|
226
|
-
return s === "ETH" || s === "WETH" || s === "NATIVE" || s === NATIVE_ETH.toUpperCase();
|
|
227
|
-
}
|
|
228
|
-
function resolveStock(input) {
|
|
229
|
-
const t = input.trim();
|
|
230
|
-
if (isEth(t))
|
|
231
|
-
return null;
|
|
232
|
-
if (isAddress(t)) {
|
|
233
|
-
const hit = exports.RH_STOCKS.find((s) => s.address.toLowerCase() === t.toLowerCase());
|
|
234
|
-
return hit ?? { address: t, symbol: t.slice(0, 8), name: "Unknown token" };
|
|
235
|
-
}
|
|
236
|
-
const up = t.toUpperCase();
|
|
237
|
-
return exports.RH_STOCKS.find((s) => s.symbol === up) ?? null;
|
|
238
|
-
}
|
|
239
|
-
// ─── DexScreener (Robinhood chain) — arbitrary token resolution + market data ──
|
|
240
|
-
const DEXSCREENER = "https://api.dexscreener.com";
|
|
241
|
-
const RH_DEX_CHAIN = "robinhood";
|
|
242
|
-
async function dexFetch(path) {
|
|
243
|
-
const res = await fetch(`${DEXSCREENER}${path}`, {
|
|
244
|
-
headers: { accept: "application/json" },
|
|
245
|
-
signal: AbortSignal.timeout(12000),
|
|
246
|
-
});
|
|
247
|
-
if (!res.ok)
|
|
248
|
-
throw new Error(`DexScreener HTTP ${res.status}`);
|
|
249
|
-
return res.json();
|
|
250
|
-
}
|
|
251
|
-
function rhOnly(pairs) {
|
|
252
|
-
return (pairs ?? []).filter((p) => p.chainId === RH_DEX_CHAIN);
|
|
253
|
-
}
|
|
254
|
-
/** Highest-liquidity RH pair that contains `address`. */
|
|
255
|
-
async function dexTokenBest(address) {
|
|
256
|
-
try {
|
|
257
|
-
const data = await dexFetch(`/latest/dex/tokens/${address}`);
|
|
258
|
-
// Only base-side pairs price our token; see pickTokenPair for why the
|
|
259
|
-
// deepest pair overall is the wrong answer. That price feeds TP/SL triggers.
|
|
260
|
-
return (0, dex_pair_js_1.pickTokenPair)(rhOnly(data?.pairs), address);
|
|
261
|
-
}
|
|
262
|
-
catch {
|
|
263
|
-
return null;
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
/**
|
|
267
|
-
* Sanity-check an on-chain quote against DexScreener spot value.
|
|
268
|
-
*
|
|
269
|
-
* The RH engine routes Uniswap V4 pools only. When a token's real depth lives
|
|
270
|
-
* elsewhere (e.g. a V2/V3 WETH pair) the V4 path can be empty and still return a
|
|
271
|
-
* quote — at a catastrophic rate. Returns the fraction of spot value received,
|
|
272
|
-
* or null when it can't be determined (no ETH-quoted pair → no false alarms).
|
|
273
|
-
*/
|
|
274
|
-
async function quoteValueRatio(from, to, amountInHuman, amountOutHuman) {
|
|
275
|
-
const tokenSide = from.kind === "token" ? from : to;
|
|
276
|
-
const amtIn = Number(amountInHuman);
|
|
277
|
-
if (!isFinite(amtIn) || amtIn <= 0 || !isFinite(amountOutHuman) || amountOutHuman <= 0) {
|
|
278
|
-
return null;
|
|
279
|
-
}
|
|
280
|
-
try {
|
|
281
|
-
const data = await dexFetch(`/latest/dex/tokens/${tokenSide.address}`);
|
|
282
|
-
const pairs = rhOnly(data?.pairs);
|
|
283
|
-
if (!pairs.length)
|
|
284
|
-
return null;
|
|
285
|
-
// Derive ETH/USD from any ETH/WETH-quoted pair: priceUsd / priceNative.
|
|
286
|
-
const ethPair = pairs.find((p) => {
|
|
287
|
-
const q = p.quoteToken?.symbol?.toUpperCase();
|
|
288
|
-
return (q === "WETH" || q === "ETH") && Number(p.priceNative) > 0 && Number(p.priceUsd) > 0;
|
|
289
|
-
});
|
|
290
|
-
if (!ethPair)
|
|
291
|
-
return null;
|
|
292
|
-
const ethUsd = Number(ethPair.priceUsd) / Number(ethPair.priceNative);
|
|
293
|
-
// Only base-side pairs price OUR token — `priceUsd` always belongs to the
|
|
294
|
-
// base. Sorting across every pair could hand back a completely different
|
|
295
|
-
// token's price, and this value decides whether a swap is blocked.
|
|
296
|
-
const want = tokenSide.address.toLowerCase();
|
|
297
|
-
const ourPairs = pairs.filter((p) => p.baseToken?.address?.toLowerCase() === want);
|
|
298
|
-
const deepest = [...ourPairs].sort((a, b) => (b.liquidity?.usd ?? 0) - (a.liquidity?.usd ?? 0))[0];
|
|
299
|
-
const tokenUsd = Number(deepest?.priceUsd);
|
|
300
|
-
if (!isFinite(ethUsd) || ethUsd <= 0 || !isFinite(tokenUsd) || tokenUsd <= 0)
|
|
301
|
-
return null;
|
|
302
|
-
const inUsd = from.kind === "eth" ? amtIn * ethUsd : amtIn * tokenUsd;
|
|
303
|
-
const outUsd = to.kind === "eth" ? amountOutHuman * ethUsd : amountOutHuman * tokenUsd;
|
|
304
|
-
if (!isFinite(inUsd) || inUsd <= 0 || !isFinite(outUsd))
|
|
305
|
-
return null;
|
|
306
|
-
const expectedOut = to.kind === "eth" ? inUsd / ethUsd : inUsd / tokenUsd;
|
|
307
|
-
return { ratio: outUsd / inUsd, expectedOut };
|
|
308
|
-
}
|
|
309
|
-
catch {
|
|
310
|
-
return null;
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
/** Human-readable warning when a quote is far below spot value, else null. */
|
|
314
|
-
function badQuoteWarning(check, toSymbol) {
|
|
315
|
-
if (!check || check.ratio >= 0.75)
|
|
316
|
-
return null;
|
|
317
|
-
const pct = (check.ratio * 100).toFixed(1);
|
|
318
|
-
const lost = ((1 - check.ratio) * 100).toFixed(1);
|
|
319
|
-
return (`🔴 **BAD PRICE — you would receive only ${pct}% of spot value (~${lost}% instant loss).**\n` +
|
|
320
|
-
`Expected ≈ **${check.expectedOut.toLocaleString(undefined, { maximumFractionDigits: 6 })} ${toSymbol}** at DexScreener spot.\n` +
|
|
321
|
-
`Cause: this engine routes **Uniswap V4 pools only**. This token's real depth is likely in a ` +
|
|
322
|
-
`V2/V3 pair the router cannot reach, so it is quoting a near-empty V4 path. **Do not swap.**`);
|
|
323
|
-
}
|
|
324
|
-
/** RH tokens whose base symbol matches `ticker`, deduped by address, ranked by liquidity. */
|
|
325
|
-
async function dexSearchByTicker(ticker) {
|
|
326
|
-
const data = await dexFetch(`/latest/dex/search/?q=${encodeURIComponent(ticker)}`);
|
|
327
|
-
const up = ticker.trim().toUpperCase();
|
|
328
|
-
const byToken = new Map();
|
|
329
|
-
for (const p of rhOnly(data?.pairs)) {
|
|
330
|
-
if (p.baseToken?.symbol?.toUpperCase() !== up)
|
|
331
|
-
continue;
|
|
332
|
-
const key = p.baseToken.address.toLowerCase();
|
|
333
|
-
const prev = byToken.get(key);
|
|
334
|
-
if (!prev || (p.liquidity?.usd ?? 0) > (prev.liquidity?.usd ?? 0))
|
|
335
|
-
byToken.set(key, p);
|
|
336
|
-
}
|
|
337
|
-
return [...byToken.values()].sort((a, b) => (b.liquidity?.usd ?? 0) - (a.liquidity?.usd ?? 0));
|
|
338
|
-
}
|
|
339
|
-
/** ERC-20 decimals() via RH RPC; falls back to 18. */
|
|
340
|
-
async function erc20Decimals(addr) {
|
|
341
|
-
try {
|
|
342
|
-
const hex = await rhRpc("eth_call", [{ to: addr, data: "0x313ce567" }, "latest"]);
|
|
343
|
-
if (hex && hex !== "0x") {
|
|
344
|
-
const d = Number(BigInt(hex));
|
|
345
|
-
if (d > 0 && d <= 36)
|
|
346
|
-
return d;
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
catch {
|
|
350
|
-
/* default 18 */
|
|
351
|
-
}
|
|
352
|
-
return 18;
|
|
353
|
-
}
|
|
354
|
-
/**
|
|
355
|
-
* Resolve ETH, a catalog stock, a crypto ticker, or a raw 0x address to a
|
|
356
|
-
* tradeable RH-chain token. Tickers resolve via DexScreener (highest liquidity
|
|
357
|
-
* wins) — callers should confirm the contract address with the user first.
|
|
358
|
-
*/
|
|
359
|
-
async function resolveTokenSmart(input) {
|
|
360
|
-
if (isEth(input))
|
|
361
|
-
return { kind: "eth", address: NATIVE_ETH, symbol: "ETH", decimals: 18 };
|
|
362
|
-
const t = input.trim();
|
|
363
|
-
if (t.toUpperCase() === "USDG") {
|
|
364
|
-
return { kind: "token", address: USDG_ADDRESS, symbol: "USDG", name: "Global Dollar", decimals: USDG_DECIMALS };
|
|
365
|
-
}
|
|
366
|
-
// 1) catalog symbol fast-path (22 tokenized stocks, all 18-decimals)
|
|
367
|
-
const cat = exports.RH_STOCKS.find((s) => s.symbol === t.toUpperCase());
|
|
368
|
-
if (cat)
|
|
369
|
-
return { kind: "token", address: cat.address, symbol: cat.symbol, name: cat.name, decimals: 18 };
|
|
370
|
-
// 2) raw contract address
|
|
371
|
-
if (isAddress(t)) {
|
|
372
|
-
const dex = await dexTokenBest(t);
|
|
373
|
-
const decimals = await erc20Decimals(t);
|
|
374
|
-
const known = resolveStock(t);
|
|
375
|
-
const symbol = dex?.baseToken?.address?.toLowerCase() === t.toLowerCase()
|
|
376
|
-
? dex.baseToken.symbol
|
|
377
|
-
: known?.symbol ?? dex?.quoteToken?.symbol ?? `${t.slice(0, 6)}…`;
|
|
378
|
-
return { kind: "token", address: t, symbol, name: dex?.baseToken?.name ?? known?.name, decimals, dex };
|
|
379
|
-
}
|
|
380
|
-
// 3) crypto ticker via DexScreener
|
|
381
|
-
const cands = await dexSearchByTicker(t);
|
|
382
|
-
if (cands.length === 0) {
|
|
383
|
-
throw new Error(`Unknown RH asset "${input}". Not in the stock catalog and no Robinhood-chain token matched on DexScreener. Paste the 0x contract address instead.`);
|
|
384
|
-
}
|
|
385
|
-
const best = cands[0];
|
|
386
|
-
const decimals = await erc20Decimals(best.baseToken.address);
|
|
387
|
-
return {
|
|
388
|
-
kind: "token",
|
|
389
|
-
address: best.baseToken.address,
|
|
390
|
-
symbol: best.baseToken.symbol,
|
|
391
|
-
name: best.baseToken.name,
|
|
392
|
-
decimals,
|
|
393
|
-
dex: best,
|
|
394
|
-
};
|
|
395
|
-
}
|
|
396
|
-
// ─── Risk pre-screen (heuristic, market data only) ───────────────────────────
|
|
397
|
-
function fmtUsd(n) {
|
|
398
|
-
const v = Number(n ?? 0);
|
|
399
|
-
if (!isFinite(v))
|
|
400
|
-
return "0";
|
|
401
|
-
if (v >= 1e9)
|
|
402
|
-
return (v / 1e9).toFixed(2) + "B";
|
|
403
|
-
if (v >= 1e6)
|
|
404
|
-
return (v / 1e6).toFixed(2) + "M";
|
|
405
|
-
if (v >= 1e3)
|
|
406
|
-
return (v / 1e3).toFixed(1) + "K";
|
|
407
|
-
return v.toFixed(2);
|
|
408
|
-
}
|
|
409
|
-
function fmtPct(n) {
|
|
410
|
-
if (n == null || !isFinite(n))
|
|
411
|
-
return "n/a";
|
|
412
|
-
return (n > 0 ? "+" : "") + Number(n).toFixed(1) + "%";
|
|
413
|
-
}
|
|
414
|
-
function assessRisk(dex) {
|
|
415
|
-
let score = 0;
|
|
416
|
-
const flags = [];
|
|
417
|
-
const liq = dex.liquidity?.usd ?? 0;
|
|
418
|
-
if (liq < 10000) {
|
|
419
|
-
score += 40;
|
|
420
|
-
flags.push(`🔴 Very low liquidity ($${fmtUsd(liq)}) — high slippage & rug risk`);
|
|
421
|
-
}
|
|
422
|
-
else if (liq < 25000) {
|
|
423
|
-
score += 28;
|
|
424
|
-
flags.push(`🟠 Low liquidity ($${fmtUsd(liq)})`);
|
|
425
|
-
}
|
|
426
|
-
else if (liq < 100000) {
|
|
427
|
-
score += 15;
|
|
428
|
-
flags.push(`🟡 Moderate liquidity ($${fmtUsd(liq)})`);
|
|
429
|
-
}
|
|
430
|
-
else {
|
|
431
|
-
flags.push(`🟢 Liquidity $${fmtUsd(liq)}`);
|
|
432
|
-
}
|
|
433
|
-
if (dex.pairCreatedAt) {
|
|
434
|
-
const ageH = (Date.now() - dex.pairCreatedAt) / 3600000;
|
|
435
|
-
if (ageH < 24) {
|
|
436
|
-
score += 25;
|
|
437
|
-
flags.push(`🔴 Very new pair (${ageH.toFixed(0)}h old)`);
|
|
438
|
-
}
|
|
439
|
-
else if (ageH < 24 * 7) {
|
|
440
|
-
score += 12;
|
|
441
|
-
flags.push(`🟠 New pair (${(ageH / 24).toFixed(1)}d old)`);
|
|
442
|
-
}
|
|
443
|
-
else {
|
|
444
|
-
flags.push(`🟢 Pair age ${(ageH / 24).toFixed(0)}d`);
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
else {
|
|
448
|
-
flags.push("⚪ Pair age unknown");
|
|
449
|
-
}
|
|
450
|
-
const vol = dex.volume?.h24 ?? 0;
|
|
451
|
-
const ratio = liq > 0 ? vol / liq : 0;
|
|
452
|
-
if (ratio > 20) {
|
|
453
|
-
score += 15;
|
|
454
|
-
flags.push(`🔴 24h volume ${ratio.toFixed(1)}× liquidity — possible wash/hype spike`);
|
|
455
|
-
}
|
|
456
|
-
else if (ratio > 5) {
|
|
457
|
-
score += 6;
|
|
458
|
-
flags.push(`🟠 High turnover (${ratio.toFixed(1)}× liq)`);
|
|
459
|
-
}
|
|
460
|
-
const buys = dex.txns?.h24?.buys ?? 0;
|
|
461
|
-
const sells = dex.txns?.h24?.sells ?? 0;
|
|
462
|
-
if (buys + sells < 10) {
|
|
463
|
-
score += 8;
|
|
464
|
-
flags.push(`🟠 Thin activity (${buys + sells} txns/24h)`);
|
|
465
|
-
}
|
|
466
|
-
else if (sells > buys * 2) {
|
|
467
|
-
score += 10;
|
|
468
|
-
flags.push(`🟠 Sell-heavy (${sells} sells vs ${buys} buys/24h) — distribution`);
|
|
469
|
-
}
|
|
470
|
-
const ch = dex.priceChange?.h24;
|
|
471
|
-
if (ch != null && Math.abs(ch) > 50) {
|
|
472
|
-
score += 8;
|
|
473
|
-
flags.push(`🟠 Volatile: ${fmtPct(ch)} 24h`);
|
|
474
|
-
}
|
|
475
|
-
const hasSocials = ((dex.info?.socials?.length ?? 0) + (dex.info?.websites?.length ?? 0)) > 0;
|
|
476
|
-
if (!hasSocials) {
|
|
477
|
-
score += 6;
|
|
478
|
-
flags.push("🟡 No website/socials listed on DexScreener");
|
|
479
|
-
}
|
|
480
|
-
// ── Implausibility checks ────────────────────────────────────────────────
|
|
481
|
-
// A launchpad whitelist ages badly — new platforms appear constantly. These
|
|
482
|
-
// look instead at whether the numbers are internally coherent, which a
|
|
483
|
-
// bundled or honeypot token cannot fake without giving up the illusion.
|
|
484
|
-
// Exit ratio: notional value vs the pool that would have to absorb a sale.
|
|
485
|
-
// A $35M "market cap" sitting on $17K of liquidity is not a market cap —
|
|
486
|
-
// it is a quote nobody can realise.
|
|
487
|
-
const fdv = dex.fdv ?? dex.marketCap ?? 0;
|
|
488
|
-
if (fdv > 0 && liq > 0) {
|
|
489
|
-
const exitRatio = fdv / liq;
|
|
490
|
-
if (exitRatio > 500) {
|
|
491
|
-
score += 22;
|
|
492
|
-
flags.push(`🔴 FDV is ${exitRatio.toFixed(0)}× liquidity ($${fmtUsd(fdv)} vs $${fmtUsd(liq)}) — ` +
|
|
493
|
-
`the quoted valuation cannot be exited; treat the price as notional`);
|
|
494
|
-
}
|
|
495
|
-
else if (exitRatio > 100) {
|
|
496
|
-
score += 12;
|
|
497
|
-
flags.push(`🟠 FDV ${exitRatio.toFixed(0)}× liquidity — thin float behind the headline value`);
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
// Wash pattern: heavy volume relative to the pool, with buys and sells almost
|
|
501
|
-
// perfectly matched. Organic trading is lopsided; round-tripping is not.
|
|
502
|
-
if (buys > 40 && sells > 40 && liq > 0) {
|
|
503
|
-
const symmetry = Math.abs(buys - sells) / (buys + sells); // 0 = perfectly matched
|
|
504
|
-
if (symmetry < 0.06 && ratio > 3) {
|
|
505
|
-
score += 15;
|
|
506
|
-
flags.push(`🔴 Buy/sell almost perfectly matched (${buys}/${sells}) on ${ratio.toFixed(1)}× turnover — ` +
|
|
507
|
-
`consistent with wash trading or a bundled wallet set`);
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
// Implausibly uniform trade size: many transactions, negligible average value.
|
|
511
|
-
const txCount = buys + sells;
|
|
512
|
-
if (txCount > 100 && vol > 0) {
|
|
513
|
-
const avgTrade = vol / txCount;
|
|
514
|
-
if (avgTrade < 25) {
|
|
515
|
-
score += 10;
|
|
516
|
-
flags.push(`🟠 Average trade only $${avgTrade.toFixed(2)} across ${txCount} txns — ` +
|
|
517
|
-
`bot-like churn rather than real participation`);
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
// Buys with no exits at all is the clearest honeypot tell available here.
|
|
521
|
-
if (buys > 10 && sells === 0) {
|
|
522
|
-
score += 30;
|
|
523
|
-
flags.push(`🔴 ${buys} buys and ZERO sells in 24h — nobody has exited; possible honeypot`);
|
|
524
|
-
}
|
|
525
|
-
score = Math.min(100, score);
|
|
526
|
-
const tier = score >= 70 ? "🔴 EXTREME" : score >= 45 ? "🟠 HIGH" : score >= 25 ? "🟡 MEDIUM" : "🟢 LOWER";
|
|
527
|
-
return { score, tier, flags };
|
|
528
|
-
}
|
|
529
|
-
function renderResolveList(query, cands) {
|
|
530
|
-
const rows = cands.slice(0, 8).map((p, i) => `| ${i === 0 ? "⭐" : " "} | \`${p.baseToken.address}\` | $${fmtUsd(p.liquidity?.usd ?? 0)} | $${fmtUsd(p.volume?.h24 ?? 0)} | ${p.priceUsd ? "$" + p.priceUsd : "?"} |`);
|
|
531
|
-
return [
|
|
532
|
-
`## 🔎 RH resolve — "${query}" (${cands.length} match${cands.length === 1 ? "" : "es"})`,
|
|
533
|
-
"",
|
|
534
|
-
"| | Contract | Liquidity | Vol 24h | Price |",
|
|
535
|
-
"|--|----------|-----------|---------|-------|",
|
|
536
|
-
...rows,
|
|
537
|
-
"",
|
|
538
|
-
`⭐ = highest liquidity. ⚠️ **Tickers can be spoofed** — several tokens may share "${query}". Trade using the exact contract address, never the ticker alone.`,
|
|
539
|
-
"",
|
|
540
|
-
`Next: \`rh_analyze\` the contract, then \`rh_mcp_estimate\` / \`rh_mcp_swap\` with the CA.`,
|
|
541
|
-
].join("\n");
|
|
542
|
-
}
|
|
543
|
-
function renderResolveOne(address, dex) {
|
|
544
|
-
const isBase = dex.baseToken.address.toLowerCase() === address.toLowerCase();
|
|
545
|
-
const tok = isBase ? dex.baseToken : dex.quoteToken;
|
|
546
|
-
return [
|
|
547
|
-
`## 🔎 RH token — ${tok.symbol}${tok.name ? ` (${tok.name})` : ""}`,
|
|
548
|
-
`\`${tok.address}\` · chain 4663`,
|
|
549
|
-
"",
|
|
550
|
-
`**Price**: ${dex.priceUsd ? "$" + dex.priceUsd : "?"} · **24h**: ${fmtPct(dex.priceChange?.h24)}`,
|
|
551
|
-
`**Liquidity**: $${fmtUsd(dex.liquidity?.usd ?? 0)} · **Vol 24h**: $${fmtUsd(dex.volume?.h24 ?? 0)}`,
|
|
552
|
-
`**Pair**: ${dex.dexId ?? "uniswap"} · ${exports.RH_EXPLORER}/address/${dex.pairAddress}`,
|
|
553
|
-
"",
|
|
554
|
-
`Next: \`rh_analyze\` for a risk read, then \`rh_mcp_estimate\` / \`rh_mcp_swap\`.`,
|
|
555
|
-
].join("\n");
|
|
556
|
-
}
|
|
557
|
-
// structuredContent payload for rh_analyze - mirrors buildRhSafetyStructured:
|
|
558
|
-
// `tier` is the assessRisk label with its leading emoji stripped
|
|
559
|
-
// ("EXTREME"/"HIGH"/"MEDIUM"/"LOWER"), single source, no thresholds duplicated.
|
|
560
|
-
function buildRhAnalysis(r, dex, assessment) {
|
|
561
|
-
return {
|
|
562
|
-
address: r.address,
|
|
563
|
-
symbol: r.symbol ?? null,
|
|
564
|
-
name: r.name ?? null,
|
|
565
|
-
priceUsd: dex.priceUsd ? Number(dex.priceUsd) : null,
|
|
566
|
-
change1hPct: dex.priceChange?.h1 ?? null,
|
|
567
|
-
change24hPct: dex.priceChange?.h24 ?? null,
|
|
568
|
-
liquidityUsd: dex.liquidity?.usd ?? null,
|
|
569
|
-
volume24hUsd: dex.volume?.h24 ?? null,
|
|
570
|
-
fdvUsd: dex.fdv ?? null,
|
|
571
|
-
marketCapUsd: dex.marketCap ?? null,
|
|
572
|
-
buys24h: dex.txns?.h24?.buys ?? null,
|
|
573
|
-
sells24h: dex.txns?.h24?.sells ?? null,
|
|
574
|
-
pairAgeDays: dex.pairCreatedAt ? Math.floor((Date.now() - dex.pairCreatedAt) / 86400000) : null,
|
|
575
|
-
riskScore: assessment.score,
|
|
576
|
-
tier: assessment.tier.replace(/^\S+\s+/, ""),
|
|
577
|
-
flags: assessment.flags,
|
|
578
|
-
};
|
|
579
|
-
}
|
|
580
|
-
// structuredContent payload for rh_mcp_list_stocks - a machine-readable
|
|
581
|
-
// mirror of the human table (already filtered by the caller's query).
|
|
582
|
-
function buildRhStocksList(rows) {
|
|
583
|
-
return {
|
|
584
|
-
count: rows.length,
|
|
585
|
-
stocks: rows.map((s) => ({ symbol: s.symbol, name: s.name, address: s.address })),
|
|
586
|
-
};
|
|
587
|
-
}
|
|
588
|
-
function renderAnalysis(r, dex, assessment) {
|
|
589
|
-
const { score, tier, flags } = assessment;
|
|
590
|
-
const ageLine = dex.pairCreatedAt
|
|
591
|
-
? `**Age**: ${((Date.now() - dex.pairCreatedAt) / 86400000).toFixed(1)}d`
|
|
592
|
-
: "**Age**: unknown";
|
|
593
|
-
return [
|
|
594
|
-
`## 🔬 RH Token Analysis — ${r.symbol}${r.name ? ` (${r.name})` : ""}`,
|
|
595
|
-
`\`${r.address}\` · chain 4663`,
|
|
596
|
-
"",
|
|
597
|
-
`**Price**: ${dex.priceUsd ? "$" + dex.priceUsd : "?"} · **24h**: ${fmtPct(dex.priceChange?.h24)} · **1h**: ${fmtPct(dex.priceChange?.h1)}`,
|
|
598
|
-
`**Liquidity**: $${fmtUsd(dex.liquidity?.usd ?? 0)} · **Vol 24h**: $${fmtUsd(dex.volume?.h24 ?? 0)}`,
|
|
599
|
-
`**FDV**: $${fmtUsd(dex.fdv ?? 0)} · **MCap**: $${fmtUsd(dex.marketCap ?? 0)}`,
|
|
600
|
-
`**Txns 24h**: ${dex.txns?.h24?.buys ?? 0} buys / ${dex.txns?.h24?.sells ?? 0} sells · ${ageLine}`,
|
|
601
|
-
"",
|
|
602
|
-
`### Risk: ${tier} — ${score}/100`,
|
|
603
|
-
...flags.map((f) => `- ${f}`),
|
|
604
|
-
"",
|
|
605
|
-
`**Market data only (DexScreener).** For a full verdict also run:`,
|
|
606
|
-
`- \`audit_contract\` — honeypot / mint authority / owner privileges`,
|
|
607
|
-
`- \`deep_research\` or \`web_scrape\` — X/Twitter sentiment & narrative`,
|
|
608
|
-
"",
|
|
609
|
-
`**Pair chart**: ${exports.RH_EXPLORER}/address/${dex.pairAddress}`,
|
|
610
|
-
"",
|
|
611
|
-
`_Heuristic pre-screen, not financial advice. Thin RH-chain liquidity = elevated rug risk. Verify the contract address before trading._`,
|
|
612
|
-
].join("\n");
|
|
613
|
-
}
|
|
614
|
-
function parseHumanToWei(amount, decimals = 18) {
|
|
615
|
-
const a = amount.trim();
|
|
616
|
-
if (!/^\d+(\.\d+)?$/.test(a))
|
|
617
|
-
throw new Error(`Invalid amount: ${amount}`);
|
|
618
|
-
return ethers_1.ethers.parseUnits(a, decimals).toString();
|
|
619
|
-
}
|
|
620
|
-
function formatWei(raw, decimals = 18, maxFrac = 6) {
|
|
621
|
-
try {
|
|
622
|
-
const n = Number(ethers_1.ethers.formatUnits(raw, decimals));
|
|
623
|
-
return n.toLocaleString(undefined, { maximumFractionDigits: maxFrac });
|
|
624
|
-
}
|
|
625
|
-
catch {
|
|
626
|
-
return raw;
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
// Some networks can't reach the RH RPC at all (e.g. ISPs that DNS-block
|
|
630
|
-
// robinhood.com), which would break balances, approvals and broadcasting. Probe
|
|
631
|
-
// once, then transparently relay through Convex — which can reach it — instead.
|
|
632
|
-
const RH_RPC_RELAY = `${convex_js_1.CONVEX_SITE}/mcp/rh/rpc`;
|
|
633
|
-
let _rhRpcMode = null;
|
|
634
|
-
async function rhRpcMode() {
|
|
635
|
-
if (_rhRpcMode)
|
|
636
|
-
return _rhRpcMode;
|
|
637
|
-
try {
|
|
638
|
-
const res = await fetch(exports.RH_RPC, {
|
|
639
|
-
method: "POST",
|
|
640
|
-
headers: { "Content-Type": "application/json" },
|
|
641
|
-
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_chainId", params: [] }),
|
|
642
|
-
signal: AbortSignal.timeout(6000),
|
|
643
|
-
});
|
|
644
|
-
if (res.ok) {
|
|
645
|
-
_rhRpcMode = "direct";
|
|
646
|
-
return _rhRpcMode;
|
|
647
|
-
}
|
|
648
|
-
}
|
|
649
|
-
catch {
|
|
650
|
-
/* blocked or unreachable → relay */
|
|
651
|
-
}
|
|
652
|
-
_rhRpcMode = "relay";
|
|
653
|
-
return _rhRpcMode;
|
|
654
|
-
}
|
|
655
|
-
/** Auth header for the Convex relay (session token or API key). */
|
|
656
|
-
function rhRelayHeaders() {
|
|
657
|
-
const token = (0, config_js_1.getSavedToken)();
|
|
658
|
-
return {
|
|
659
|
-
"Content-Type": "application/json",
|
|
660
|
-
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
661
|
-
};
|
|
662
|
-
}
|
|
663
|
-
/** Provider that works on both paths — relays via Convex when RPC is blocked. */
|
|
664
|
-
async function rhProviderAsync() {
|
|
665
|
-
if ((await rhRpcMode()) === "direct") {
|
|
666
|
-
return new ethers_1.ethers.JsonRpcProvider(exports.RH_RPC, exports.RH_CHAIN_ID);
|
|
667
|
-
}
|
|
668
|
-
const req = new ethers_1.ethers.FetchRequest(RH_RPC_RELAY);
|
|
669
|
-
for (const [k, v] of Object.entries(rhRelayHeaders()))
|
|
670
|
-
req.setHeader(k, v);
|
|
671
|
-
return new ethers_1.ethers.JsonRpcProvider(req, exports.RH_CHAIN_ID);
|
|
672
|
-
}
|
|
673
|
-
async function rhRpc(method, params) {
|
|
674
|
-
const relay = (await rhRpcMode()) === "relay";
|
|
675
|
-
const url = relay ? RH_RPC_RELAY : exports.RH_RPC;
|
|
676
|
-
const res = await fetch(url, {
|
|
677
|
-
method: "POST",
|
|
678
|
-
headers: relay ? rhRelayHeaders() : { "Content-Type": "application/json" },
|
|
679
|
-
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
|
|
680
|
-
signal: AbortSignal.timeout(20000),
|
|
681
|
-
});
|
|
682
|
-
if (!res.ok)
|
|
683
|
-
throw new Error(`RH RPC HTTP ${res.status}${relay ? " (via relay)" : ""}`);
|
|
684
|
-
const data = (await res.json());
|
|
685
|
-
if (data.error)
|
|
686
|
-
throw new Error(data.error.message ?? "RH RPC error");
|
|
687
|
-
return data.result;
|
|
688
|
-
}
|
|
689
|
-
function encodeBalanceOf(addr) {
|
|
690
|
-
return "0x70a08231" + addr.toLowerCase().replace("0x", "").padStart(64, "0");
|
|
691
|
-
}
|
|
692
|
-
/** Raw ERC-20 balanceOf(owner) on RH chain (wei). 0n on any failure. */
|
|
693
|
-
async function rhErc20Balance(token, owner) {
|
|
694
|
-
try {
|
|
695
|
-
const hex = await rhRpc("eth_call", [{ to: token, data: encodeBalanceOf(owner) }, "latest"]);
|
|
696
|
-
if (hex && hex !== "0x")
|
|
697
|
-
return BigInt(hex);
|
|
698
|
-
}
|
|
699
|
-
catch {
|
|
700
|
-
/* 0 */
|
|
701
|
-
}
|
|
702
|
-
return 0n;
|
|
703
|
-
}
|
|
704
|
-
/** Current USD price of a token on RH chain via DexScreener, or null. */
|
|
705
|
-
async function rhPriceUsd(address) {
|
|
706
|
-
const dex = await dexTokenBest(address);
|
|
707
|
-
const p = dex?.priceUsd != null ? Number(dex.priceUsd) : NaN;
|
|
708
|
-
return isFinite(p) ? p : null;
|
|
709
|
-
}
|
|
710
|
-
async function fetchRhBalances(address) {
|
|
711
|
-
if (!isAddress(address))
|
|
712
|
-
return "Invalid address.";
|
|
713
|
-
let ethStr = "?";
|
|
714
|
-
try {
|
|
715
|
-
const hex = await rhRpc("eth_getBalance", [address, "latest"]);
|
|
716
|
-
ethStr = (Number(BigInt(hex)) / 1e18).toFixed(6);
|
|
717
|
-
}
|
|
718
|
-
catch (e) {
|
|
719
|
-
ethStr = `error: ${e?.message ?? "rpc"}`;
|
|
720
|
-
}
|
|
721
|
-
// Blockscout lists EVERY token held, not just the 18-stock catalog — the old
|
|
722
|
-
// per-catalog RPC loop was blind to arbitrary crypto we can now trade. It's
|
|
723
|
-
// also 1 request instead of 18, and stays reachable when the RPC is blocked.
|
|
724
|
-
const holdings = [];
|
|
725
|
-
try {
|
|
726
|
-
const res = await fetch(`${RH_BLOCKSCOUT_V2}/addresses/${address}/token-balances`, {
|
|
727
|
-
headers: { accept: "application/json" },
|
|
728
|
-
signal: AbortSignal.timeout(15000),
|
|
729
|
-
});
|
|
730
|
-
if (res.ok) {
|
|
731
|
-
const data = await res.json();
|
|
732
|
-
const items = Array.isArray(data) ? data : (data?.items ?? []);
|
|
733
|
-
for (const it of items) {
|
|
734
|
-
const tk = it?.token ?? {};
|
|
735
|
-
if (tk.type && String(tk.type).toUpperCase() !== "ERC-20")
|
|
736
|
-
continue;
|
|
737
|
-
const dec = Number(tk.decimals ?? 18);
|
|
738
|
-
const raw = String(it?.value ?? "0");
|
|
739
|
-
let bal = 0;
|
|
740
|
-
try {
|
|
741
|
-
bal = Number(ethers_1.ethers.formatUnits(raw, isFinite(dec) ? dec : 18));
|
|
742
|
-
}
|
|
743
|
-
catch {
|
|
744
|
-
continue;
|
|
745
|
-
}
|
|
746
|
-
if (bal > 1e-9) {
|
|
747
|
-
holdings.push({
|
|
748
|
-
symbol: tk.symbol ?? "?",
|
|
749
|
-
bal: bal.toLocaleString(undefined, { maximumFractionDigits: 6 }),
|
|
750
|
-
address: tk.address_hash ?? tk.address ?? "",
|
|
751
|
-
});
|
|
752
|
-
}
|
|
753
|
-
}
|
|
754
|
-
holdings.sort((a, b) => a.symbol.localeCompare(b.symbol));
|
|
755
|
-
}
|
|
756
|
-
}
|
|
757
|
-
catch {
|
|
758
|
-
/* fall through — ETH balance still reported */
|
|
759
|
-
}
|
|
760
|
-
const lines = [
|
|
761
|
-
`**Wallet**: \`${address}\``,
|
|
762
|
-
``,
|
|
763
|
-
`**Balances on Robinhood Chain (${exports.RH_CHAIN_ID}):**`,
|
|
764
|
-
`- ETH (gas): ${ethStr}`,
|
|
765
|
-
];
|
|
766
|
-
if (holdings.length === 0)
|
|
767
|
-
lines.push(`- (no token balances)`);
|
|
768
|
-
else
|
|
769
|
-
for (const h of holdings)
|
|
770
|
-
lines.push(`- ${h.symbol}: ${h.bal} \`${h.address}\``);
|
|
771
|
-
lines.push(``, `_RPC: ${rhRpcDisplay()}_`, `_Explorer: ${exports.RH_EXPLORER}/address/${address}_`);
|
|
772
|
-
return lines.join("\n");
|
|
773
|
-
}
|
|
774
|
-
// ─── Onchain safety scan (Blockscout + DexScreener; free, no LLM) ─────────────
|
|
775
|
-
const RH_BLOCKSCOUT_V2 = "https://robinhoodchain.blockscout.com/api/v2";
|
|
776
|
-
// Factory address → launchpad. Keyed lowercase. Only put an entry here once the
|
|
777
|
-
// factory has been read off-chain on 4663 — a wrong mapping silently mislabels
|
|
778
|
-
// every token a factory ever deployed.
|
|
779
|
-
// Observed empirically by sampling recent deployments, not taken from docs.
|
|
780
|
-
// Add an entry only after seeing the factory actually deploy a live token.
|
|
781
|
-
const LAUNCHPAD_FACTORIES = {
|
|
782
|
-
"0xa5aab3f0c6eeadf30ef1d3eb997108e976351feb": {
|
|
783
|
-
label: "Pons",
|
|
784
|
-
note: "PonsLaunchFactory — the dominant launchpad on this chain",
|
|
785
|
-
},
|
|
786
|
-
"0x1b37d3a72082029c44b35b604ea473617580b69a": {
|
|
787
|
-
label: "Doppler (Whetstone)",
|
|
788
|
-
note: "Bankr routes launches through Doppler — a Doppler factory alone does not prove a Bankr launch",
|
|
789
|
-
},
|
|
790
|
-
"0x120caef934797423479fcd0c5d71d36b0282736e": {
|
|
791
|
-
label: "Unverified factory (MemeSoft tokens)",
|
|
792
|
-
note: "Deploys contracts named MemeSoft; operator not yet identified — treat as unbranded",
|
|
793
|
-
},
|
|
794
|
-
};
|
|
795
|
-
// Fallback when the factory address is unknown but its contract is verified and named.
|
|
796
|
-
const LAUNCHPAD_NAME_HINTS = [
|
|
797
|
-
[/pons/i, "Pons"],
|
|
798
|
-
[/doppler/i, "Doppler (Whetstone)"],
|
|
799
|
-
[/bankr/i, "Bankr"],
|
|
800
|
-
[/\bnox\b|noxa/i, "Noxa"],
|
|
801
|
-
[/clanker/i, "Clanker"],
|
|
802
|
-
[/virtuals?/i, "Virtuals"],
|
|
803
|
-
[/flap/i, "Flap"],
|
|
804
|
-
[/memesoft/i, "MemeSoft"],
|
|
805
|
-
];
|
|
806
|
-
async function detectLaunchpad(creator) {
|
|
807
|
-
if (!creator)
|
|
808
|
-
return null;
|
|
809
|
-
const known = LAUNCHPAD_FACTORIES[creator.toLowerCase()];
|
|
810
|
-
if (known) {
|
|
811
|
-
return { label: known.label, factory: creator, factoryName: null, confidence: "confirmed", note: known.note };
|
|
812
|
-
}
|
|
813
|
-
let isContract = false;
|
|
814
|
-
let factoryName = null;
|
|
815
|
-
try {
|
|
816
|
-
const r = await fetch(`${RH_BLOCKSCOUT_V2}/addresses/${creator}`, {
|
|
817
|
-
headers: { accept: "application/json" },
|
|
818
|
-
signal: AbortSignal.timeout(12000),
|
|
819
|
-
});
|
|
820
|
-
if (r.ok) {
|
|
821
|
-
const c = await r.json();
|
|
822
|
-
isContract = c?.is_contract === true;
|
|
823
|
-
factoryName = c?.name ?? null;
|
|
824
|
-
}
|
|
825
|
-
}
|
|
826
|
-
catch {
|
|
827
|
-
/* fall through to unknown */
|
|
828
|
-
}
|
|
829
|
-
if (!isContract) {
|
|
830
|
-
return { label: "None — self-deployed", factory: creator, factoryName: null, confidence: "self-deployed" };
|
|
831
|
-
}
|
|
832
|
-
const hit = factoryName ? LAUNCHPAD_NAME_HINTS.find(([re]) => re.test(factoryName)) : undefined;
|
|
833
|
-
if (hit)
|
|
834
|
-
return { label: hit[1], factory: creator, factoryName, confidence: "likely" };
|
|
835
|
-
return { label: "Unrecognized factory", factory: creator, factoryName, confidence: "unknown" };
|
|
836
|
-
}
|
|
837
|
-
async function blockscoutSafety(address) {
|
|
838
|
-
const out = {
|
|
839
|
-
verified: null,
|
|
840
|
-
contractName: null,
|
|
841
|
-
reputation: null,
|
|
842
|
-
holdersCount: null,
|
|
843
|
-
creator: null,
|
|
844
|
-
launchpad: null,
|
|
845
|
-
};
|
|
846
|
-
try {
|
|
847
|
-
const r = await fetch(`${RH_BLOCKSCOUT_V2}/tokens/${address}`, {
|
|
848
|
-
headers: { accept: "application/json" },
|
|
849
|
-
signal: AbortSignal.timeout(12000),
|
|
850
|
-
});
|
|
851
|
-
if (r.ok) {
|
|
852
|
-
const t = await r.json();
|
|
853
|
-
out.reputation = t?.reputation ?? null;
|
|
854
|
-
out.holdersCount = t?.holders_count != null ? Number(t.holders_count) : null;
|
|
855
|
-
}
|
|
856
|
-
}
|
|
857
|
-
catch {
|
|
858
|
-
/* ignore */
|
|
859
|
-
}
|
|
860
|
-
// Use /addresses/{ca} (~1KB) NOT /smart-contracts/{ca} — the latter ships the
|
|
861
|
-
// entire Solidity source and intermittently returns a truncated body on HTTP 200,
|
|
862
|
-
// which silently nulled contractName and skipped the launchpad/privilege flags.
|
|
863
|
-
for (let attempt = 0; attempt < 2; attempt++) {
|
|
864
|
-
try {
|
|
865
|
-
const r = await fetch(`${RH_BLOCKSCOUT_V2}/addresses/${address}`, {
|
|
866
|
-
headers: { accept: "application/json" },
|
|
867
|
-
signal: AbortSignal.timeout(12000),
|
|
868
|
-
});
|
|
869
|
-
if (r.ok) {
|
|
870
|
-
const c = await r.json();
|
|
871
|
-
out.verified = c?.is_verified ?? null;
|
|
872
|
-
out.contractName = c?.name ?? null;
|
|
873
|
-
out.creator = c?.creator_address_hash ?? null;
|
|
874
|
-
break;
|
|
875
|
-
}
|
|
876
|
-
if (r.status === 404) {
|
|
877
|
-
out.verified = false; // address has no contract record
|
|
878
|
-
break;
|
|
879
|
-
}
|
|
880
|
-
}
|
|
881
|
-
catch {
|
|
882
|
-
/* retry once */
|
|
883
|
-
}
|
|
884
|
-
}
|
|
885
|
-
out.launchpad = await detectLaunchpad(out.creator);
|
|
886
|
-
return out;
|
|
887
|
-
}
|
|
888
|
-
function assessSafety(safety, dex) {
|
|
889
|
-
let score = 0;
|
|
890
|
-
const flags = [];
|
|
891
|
-
if (safety.verified === false) {
|
|
892
|
-
score += 20;
|
|
893
|
-
flags.push("🔴 Contract NOT verified on explorer");
|
|
894
|
-
}
|
|
895
|
-
else if (safety.verified === true) {
|
|
896
|
-
flags.push("🟢 Contract verified");
|
|
897
|
-
}
|
|
898
|
-
else {
|
|
899
|
-
flags.push("⚪ Verification unknown");
|
|
900
|
-
}
|
|
901
|
-
const rep = (safety.reputation ?? "").toLowerCase();
|
|
902
|
-
if (rep && rep !== "ok" && rep !== "neutral") {
|
|
903
|
-
score += 30;
|
|
904
|
-
flags.push(`🔴 Explorer reputation: "${safety.reputation}"`);
|
|
905
|
-
}
|
|
906
|
-
else if (rep) {
|
|
907
|
-
flags.push(`🟢 Explorer reputation: ${safety.reputation}`);
|
|
908
|
-
}
|
|
909
|
-
if (safety.holdersCount != null) {
|
|
910
|
-
if (safety.holdersCount < 25) {
|
|
911
|
-
score += 25;
|
|
912
|
-
flags.push(`🔴 Very few holders (${safety.holdersCount}) — high concentration / rug risk`);
|
|
913
|
-
}
|
|
914
|
-
else if (safety.holdersCount < 200) {
|
|
915
|
-
score += 12;
|
|
916
|
-
flags.push(`🟠 Few holders (${safety.holdersCount})`);
|
|
917
|
-
}
|
|
918
|
-
else {
|
|
919
|
-
flags.push(`🟢 Holders: ${safety.holdersCount.toLocaleString()}`);
|
|
920
|
-
}
|
|
921
|
-
}
|
|
922
|
-
else {
|
|
923
|
-
flags.push("⚪ Holder count unavailable");
|
|
924
|
-
}
|
|
925
|
-
const nm = (safety.contractName ?? "").toLowerCase();
|
|
926
|
-
if (/mint|burn|blacklist|pausable|pause|ownable|\btax\b|\bfee\b/.test(nm)) {
|
|
927
|
-
score += 14;
|
|
928
|
-
flags.push(`🟠 Contract name implies owner privileges ("${safety.contractName}") — possible mint/burn/blacklist/tax; verify source before trusting`);
|
|
929
|
-
}
|
|
930
|
-
// On Robinhood Chain essentially every token ships through a launchpad
|
|
931
|
-
// (Pons, Bankr/Doppler, Flap, Virtuals…), so a launchpad deploy is the NORM,
|
|
932
|
-
// not a red flag — scoring it as risk penalised every normal token and made
|
|
933
|
-
// the number stop discriminating. The anomaly here is the opposite: a token
|
|
934
|
-
// deployed straight from an EOA answers to no launchpad rules, and the
|
|
935
|
-
// deployer keeps full control of supply and liquidity.
|
|
936
|
-
const lp = safety.launchpad;
|
|
937
|
-
if (lp?.confidence === "confirmed" || lp?.confidence === "likely") {
|
|
938
|
-
flags.push(`🟢 Launched via ${lp.label}${lp.confidence === "likely" ? " (probable)" : ""} — standard for this chain` +
|
|
939
|
-
(lp.note ? ` · ${lp.note}` : ""));
|
|
940
|
-
}
|
|
941
|
-
else if (lp?.confidence === "unknown") {
|
|
942
|
-
score += 10;
|
|
943
|
-
flags.push(`🟠 Deployed by an unrecognized factory (\`${lp.factory}\`) — not a known launchpad on this chain; verify who runs it`);
|
|
944
|
-
}
|
|
945
|
-
else if (lp?.confidence === "self-deployed") {
|
|
946
|
-
score += 18;
|
|
947
|
-
flags.push(`🔴 Self-deployed from an EOA (\`${lp.factory}\`) — no launchpad rules apply. ` +
|
|
948
|
-
`Unusual on this chain: the deployer retains full control over supply and liquidity`);
|
|
949
|
-
}
|
|
950
|
-
if (dex) {
|
|
951
|
-
const buys = dex.txns?.h24?.buys ?? 0;
|
|
952
|
-
const sells = dex.txns?.h24?.sells ?? 0;
|
|
953
|
-
if (buys > 5 && sells === 0) {
|
|
954
|
-
score += 30;
|
|
955
|
-
flags.push(`🔴 Honeypot suspicion: ${buys} buys but 0 sells in 24h — may be unsellable`);
|
|
956
|
-
}
|
|
957
|
-
else if (sells > 0) {
|
|
958
|
-
flags.push(`🟢 Sellable: ${sells} sell(s) in 24h — holders can exit`);
|
|
959
|
-
}
|
|
960
|
-
}
|
|
961
|
-
score = Math.min(100, score);
|
|
962
|
-
const tier = score >= 60 ? "🔴 DANGER" : score >= 35 ? "🟠 CAUTION" : score >= 15 ? "🟡 SOME RISK" : "🟢 LOOKS OK";
|
|
963
|
-
return { score, tier, flags };
|
|
964
|
-
}
|
|
965
|
-
// structuredContent payload for rh_safety_check. `tier` is the assessSafety
|
|
966
|
-
// label with its leading emoji stripped so consumers get a clean enum
|
|
967
|
-
// ("DANGER"/"CAUTION"/"SOME RISK"/"LOOKS OK") - single source, no threshold
|
|
968
|
-
// logic duplicated here.
|
|
969
|
-
function buildRhSafetyStructured(r, safety, assessment) {
|
|
970
|
-
return {
|
|
971
|
-
address: r.address,
|
|
972
|
-
symbol: r.symbol ?? null,
|
|
973
|
-
name: r.name ?? null,
|
|
974
|
-
riskScore: assessment.score,
|
|
975
|
-
tier: assessment.tier.replace(/^\S+\s+/, ""),
|
|
976
|
-
verified: safety.verified,
|
|
977
|
-
contractName: safety.contractName,
|
|
978
|
-
reputation: safety.reputation,
|
|
979
|
-
holdersCount: safety.holdersCount,
|
|
980
|
-
creator: safety.creator,
|
|
981
|
-
launchpad: safety.launchpad,
|
|
982
|
-
flags: assessment.flags,
|
|
983
|
-
};
|
|
984
|
-
}
|
|
985
|
-
function renderSafety(r, safety, assessment) {
|
|
986
|
-
const { score, tier, flags } = assessment;
|
|
987
|
-
return [
|
|
988
|
-
`## 🛡️ RH Safety Check — ${r.symbol}${r.name ? ` (${r.name})` : ""}`,
|
|
989
|
-
`\`${r.address}\` · chain 4663${safety.contractName ? ` · contract \`${safety.contractName}\`` : ""}`,
|
|
990
|
-
"",
|
|
991
|
-
`### Safety: ${tier} — ${score}/100 risk`,
|
|
992
|
-
...flags.map((f) => `- ${f}`),
|
|
993
|
-
"",
|
|
994
|
-
`**Onchain data only (Blockscout + DexScreener) — no LLM, no paid API.**`,
|
|
995
|
-
`For market/liquidity read run \`rh_analyze\`; for X/narrative add \`web_search\` or \`deep_research\`.`,
|
|
996
|
-
`**Explorer**: ${exports.RH_EXPLORER}/token/${r.address}`,
|
|
997
|
-
"",
|
|
998
|
-
`_Heuristic — not a full audit. A verified, high-holder, sellable token can still dump. Verify the CA._`,
|
|
999
|
-
].join("\n");
|
|
1000
|
-
}
|
|
1001
|
-
async function quoteRh(args) {
|
|
1002
|
-
const from = await resolveTokenSmart(args.fromToken);
|
|
1003
|
-
const to = await resolveTokenSmart(args.toToken);
|
|
1004
|
-
if (from.kind === to.kind) {
|
|
1005
|
-
throw new Error("RH swaps route ETH ↔ token. Buy a token with ETH, or sell a token for ETH.");
|
|
1006
|
-
}
|
|
1007
|
-
const sellAmount = parseHumanToWei(args.amount, from.decimals);
|
|
1008
|
-
const slippagePct = args.maxSlippagePct ?? 2.0;
|
|
1009
|
-
// Same bound defi.ts's SwapSchema already enforces for Base swaps
|
|
1010
|
-
// (.positive().max(50)) - this file had no equivalent check anywhere, so a
|
|
1011
|
-
// negative or absurd value flowed straight through to the backend as
|
|
1012
|
-
// slippageBps, both for direct rh_mcp_swap calls and for every unattended
|
|
1013
|
-
// rh_orders_tick execution of a DCA/bracket order created with a bad value.
|
|
1014
|
-
if (!(slippagePct > 0) || slippagePct > 50) {
|
|
1015
|
-
throw new Error(`maxSlippagePct must be greater than 0 and at most 50 (got ${args.maxSlippagePct}).`);
|
|
1016
|
-
}
|
|
1017
|
-
const slippageBps = Math.round(slippagePct * 100);
|
|
1018
|
-
const result = await (0, convex_js_1.callConvex)("/mcp/rh/quote", "POST", {
|
|
1019
|
-
sellToken: from.address,
|
|
1020
|
-
buyToken: to.address,
|
|
1021
|
-
sellAmount,
|
|
1022
|
-
taker: args.taker,
|
|
1023
|
-
slippageBps,
|
|
1024
|
-
fromSymbol: from.symbol,
|
|
1025
|
-
toSymbol: to.symbol,
|
|
1026
|
-
}, args.toolName ?? "rh_mcp_estimate");
|
|
1027
|
-
if (result.error)
|
|
1028
|
-
throw new Error(result.error);
|
|
1029
|
-
return { ...result, from, to, sellAmount, slippageBps };
|
|
1030
|
-
}
|
|
1031
|
-
/** Plain ERC-20 approval for routers that pull via transferFrom (SwapRouter02). */
|
|
1032
|
-
async function ensureDirectApproval(wallet, token, spender, amountWei) {
|
|
1033
|
-
const provider = await rhProviderAsync();
|
|
1034
|
-
const signer = wallet.connect(provider);
|
|
1035
|
-
const erc20 = new ethers_1.ethers.Contract(token, [
|
|
1036
|
-
"function allowance(address,address) view returns (uint256)",
|
|
1037
|
-
"function approve(address,uint256) returns (bool)",
|
|
1038
|
-
], signer);
|
|
1039
|
-
const allowance = await erc20.allowance(wallet.address, spender);
|
|
1040
|
-
if (allowance >= amountWei)
|
|
1041
|
-
return [];
|
|
1042
|
-
const tx = await erc20.approve(spender, ethers_1.ethers.MaxUint256);
|
|
1043
|
-
await tx.wait();
|
|
1044
|
-
return [tx.hash];
|
|
1045
|
-
}
|
|
1046
|
-
async function ensureSellApprovals(wallet, token, amountWei) {
|
|
1047
|
-
const provider = await rhProviderAsync();
|
|
1048
|
-
const signer = wallet.connect(provider);
|
|
1049
|
-
const hashes = [];
|
|
1050
|
-
const erc20 = new ethers_1.ethers.Contract(token, [
|
|
1051
|
-
"function allowance(address,address) view returns (uint256)",
|
|
1052
|
-
"function approve(address,uint256) returns (bool)",
|
|
1053
|
-
], signer);
|
|
1054
|
-
const allowance = await erc20.allowance(wallet.address, RH_PERMIT2);
|
|
1055
|
-
if (allowance < amountWei) {
|
|
1056
|
-
const tx = await erc20.approve(RH_PERMIT2, ethers_1.ethers.MaxUint256);
|
|
1057
|
-
await tx.wait();
|
|
1058
|
-
hashes.push(tx.hash);
|
|
1059
|
-
}
|
|
1060
|
-
// Permit2.approve(token, spender, amount, expiration) selector 0x87517c45
|
|
1061
|
-
const permit2 = new ethers_1.ethers.Contract(RH_PERMIT2, [
|
|
1062
|
-
"function approve(address token, address spender, uint160 amount, uint48 expiration)",
|
|
1063
|
-
"function allowance(address user, address token, address spender) view returns (uint160 amount, uint48 expiration, uint48 nonce)",
|
|
1064
|
-
], signer);
|
|
1065
|
-
try {
|
|
1066
|
-
const al = await permit2.allowance(wallet.address, token, RH_UNIVERSAL_ROUTER);
|
|
1067
|
-
const amt = BigInt(al.amount ?? al[0] ?? 0);
|
|
1068
|
-
const exp = Number(al.expiration ?? al[1] ?? 0);
|
|
1069
|
-
const now = Math.floor(Date.now() / 1000);
|
|
1070
|
-
if (amt >= amountWei && exp > now + 60)
|
|
1071
|
-
return hashes;
|
|
1072
|
-
}
|
|
1073
|
-
catch {
|
|
1074
|
-
/* re-approve */
|
|
1075
|
-
}
|
|
1076
|
-
const expiration = Math.floor(Date.now() / 1000) + 30 * 24 * 3600; // 30d
|
|
1077
|
-
// uint160 max for amount
|
|
1078
|
-
const max160 = (1n << 160n) - 1n;
|
|
1079
|
-
const tx2 = await permit2.approve(token, RH_UNIVERSAL_ROUTER, max160, expiration);
|
|
1080
|
-
await tx2.wait();
|
|
1081
|
-
hashes.push(tx2.hash);
|
|
1082
|
-
return hashes;
|
|
1083
|
-
}
|
|
1084
|
-
/**
|
|
1085
|
-
* Wait for the swap receipt and read back what actually happened on-chain.
|
|
1086
|
-
*
|
|
1087
|
-
* A quote is a prediction; a receipt is proof. Reporting only the quoted amount
|
|
1088
|
-
* leaves the user unable to tell a real fill from a fabricated one, so we
|
|
1089
|
-
* surface the mined status, block, gas, and the ACTUAL amount credited —
|
|
1090
|
-
* parsed from the token's Transfer logs (buys) or the ETH balance delta (sells).
|
|
1091
|
-
*/
|
|
1092
|
-
async function confirmRhSwap(txHash, walletAddress, to, ethBefore) {
|
|
1093
|
-
try {
|
|
1094
|
-
const provider = await rhProviderAsync();
|
|
1095
|
-
const receipt = await provider.waitForTransaction(txHash, 1, 90000);
|
|
1096
|
-
if (!receipt)
|
|
1097
|
-
return { mined: false };
|
|
1098
|
-
let received;
|
|
1099
|
-
if (to.kind === "token") {
|
|
1100
|
-
// Sum Transfer(_, me, value) emitted by the bought token.
|
|
1101
|
-
const TRANSFER_TOPIC = ethers_1.ethers.id("Transfer(address,address,uint256)");
|
|
1102
|
-
const me = walletAddress.toLowerCase();
|
|
1103
|
-
let total = 0n;
|
|
1104
|
-
for (const log of receipt.logs ?? []) {
|
|
1105
|
-
if (log.address?.toLowerCase() !== to.address.toLowerCase())
|
|
1106
|
-
continue;
|
|
1107
|
-
if (log.topics?.[0] !== TRANSFER_TOPIC || log.topics.length < 3)
|
|
1108
|
-
continue;
|
|
1109
|
-
const dest = "0x" + log.topics[2].slice(-40);
|
|
1110
|
-
if (dest.toLowerCase() !== me)
|
|
1111
|
-
continue;
|
|
1112
|
-
try {
|
|
1113
|
-
total += BigInt(log.data);
|
|
1114
|
-
}
|
|
1115
|
-
catch {
|
|
1116
|
-
/* skip malformed */
|
|
1117
|
-
}
|
|
1118
|
-
}
|
|
1119
|
-
if (total > 0n)
|
|
1120
|
-
received = ethers_1.ethers.formatUnits(total, to.decimals);
|
|
1121
|
-
}
|
|
1122
|
-
else if (ethBefore != null) {
|
|
1123
|
-
// Selling for native ETH: credited amount = delta + gas actually burned.
|
|
1124
|
-
try {
|
|
1125
|
-
const after = BigInt(await rhRpc("eth_getBalance", [walletAddress, "latest"]));
|
|
1126
|
-
const fee = BigInt(receipt.gasUsed ?? 0n) * BigInt(receipt.gasPrice ?? 0n);
|
|
1127
|
-
const delta = after - ethBefore + fee;
|
|
1128
|
-
if (delta > 0n)
|
|
1129
|
-
received = ethers_1.ethers.formatEther(delta);
|
|
1130
|
-
}
|
|
1131
|
-
catch {
|
|
1132
|
-
/* balance read optional */
|
|
1133
|
-
}
|
|
1134
|
-
}
|
|
1135
|
-
return {
|
|
1136
|
-
mined: true,
|
|
1137
|
-
ok: receipt.status === 1,
|
|
1138
|
-
block: receipt.blockNumber,
|
|
1139
|
-
gasUsed: receipt.gasUsed?.toString(),
|
|
1140
|
-
received,
|
|
1141
|
-
};
|
|
1142
|
-
}
|
|
1143
|
-
catch {
|
|
1144
|
-
return { mined: false };
|
|
1145
|
-
}
|
|
1146
|
-
}
|
|
1147
|
-
async function broadcastRhSwap(wallet, tx) {
|
|
1148
|
-
const provider = await rhProviderAsync();
|
|
1149
|
-
const signer = wallet.connect(provider);
|
|
1150
|
-
// "pending" (not "latest") so sequential swaps in one orders-tick get
|
|
1151
|
-
// incrementing nonces instead of colliding on the same unmined nonce.
|
|
1152
|
-
const nonce = await provider.getTransactionCount(wallet.address, "pending");
|
|
1153
|
-
const fee = await provider.getFeeData();
|
|
1154
|
-
const signed = await signer.signTransaction({
|
|
1155
|
-
to: tx.to,
|
|
1156
|
-
data: tx.data,
|
|
1157
|
-
value: BigInt(tx.value || "0"),
|
|
1158
|
-
gasLimit: BigInt(tx.gas || "650000"),
|
|
1159
|
-
maxFeePerGas: fee.maxFeePerGas ?? fee.gasPrice ?? 1000000000n,
|
|
1160
|
-
maxPriorityFeePerGas: fee.maxPriorityFeePerGas ?? 100000000n,
|
|
1161
|
-
nonce,
|
|
1162
|
-
chainId: exports.RH_CHAIN_ID,
|
|
1163
|
-
type: 2,
|
|
1164
|
-
});
|
|
1165
|
-
const resp = await rhRpc("eth_sendRawTransaction", [signed]);
|
|
1166
|
-
return resp;
|
|
1167
|
-
}
|
|
1168
|
-
async function handleRhMcpTool(name, args) {
|
|
1169
|
-
const a = (args ?? {});
|
|
1170
|
-
switch (name) {
|
|
1171
|
-
case "rh_mcp_status": {
|
|
1172
|
-
const walletRes = await (0, wallet_js_2.handleWalletTool)("get_wallet_address", {}).catch(() => null);
|
|
1173
|
-
const text = walletRes?.content?.[0]?.text ?? "";
|
|
1174
|
-
const addr = text.match(/0x[a-fA-F0-9]{40}/)?.[0];
|
|
1175
|
-
let eth = "?";
|
|
1176
|
-
if (addr) {
|
|
1177
|
-
try {
|
|
1178
|
-
const hex = await rhRpc("eth_getBalance", [addr, "latest"]);
|
|
1179
|
-
eth = (Number(BigInt(hex)) / 1e18).toFixed(6);
|
|
1180
|
-
}
|
|
1181
|
-
catch (e) {
|
|
1182
|
-
eth = `error: ${e?.message ?? "rpc"}`;
|
|
1183
|
-
}
|
|
1184
|
-
}
|
|
1185
|
-
return textResult([
|
|
1186
|
-
"## 🟢 Robinhood Chain MCP - Status",
|
|
1187
|
-
"",
|
|
1188
|
-
"**Rail**: Robinhood Chain · chainId **4663** · Uniswap V4 tokenized stocks",
|
|
1189
|
-
"**Not**: Robinhood Agentic brokerage (`agent.robinhood.com/mcp/trading`)",
|
|
1190
|
-
"**Not**: Base `base_mcp_*` (chain 8453)",
|
|
1191
|
-
"",
|
|
1192
|
-
`**Wallet**: \`${addr ?? "(not configured — run finch login / first tool)"}\``,
|
|
1193
|
-
`**ETH on RH (gas)**: ${eth}`,
|
|
1194
|
-
`**RPC**: ${rhRpcDisplay()}`,
|
|
1195
|
-
`**Explorer**: ${exports.RH_EXPLORER}`,
|
|
1196
|
-
`**Catalog**: ${exports.RH_STOCKS.length} stocks (AAPL…USAR) + any RH-chain crypto via DexScreener`,
|
|
1197
|
-
"",
|
|
1198
|
-
"Tools: `rh_token_resolve` · `rh_analyze` · `rh_safety_check` · `rh_mcp_estimate` · `rh_mcp_swap` · `rh_dca_create` · `rh_bracket_create` · `rh_orders_tick`",
|
|
1199
|
-
].join("\n"));
|
|
1200
|
-
}
|
|
1201
|
-
case "rh_mcp_list_stocks": {
|
|
1202
|
-
const q = (a.query ?? "").toString().trim().toLowerCase();
|
|
1203
|
-
const rows = exports.RH_STOCKS.filter((s) => !q ||
|
|
1204
|
-
s.symbol.toLowerCase().includes(q) ||
|
|
1205
|
-
s.name.toLowerCase().includes(q) ||
|
|
1206
|
-
s.address.toLowerCase().includes(q));
|
|
1207
|
-
const lines = [
|
|
1208
|
-
`## 🟢 RH stocks (${rows.length}/${exports.RH_STOCKS.length})`,
|
|
1209
|
-
"",
|
|
1210
|
-
"| Symbol | Name | Address |",
|
|
1211
|
-
"|--------|------|---------|",
|
|
1212
|
-
...rows.map((s) => `| **${s.symbol}** | ${s.name} | \`${s.address}\` |`),
|
|
1213
|
-
"",
|
|
1214
|
-
"Quote/swap: `rh_mcp_estimate` / `rh_mcp_swap` with fromToken=ETH toToken=NVDA (etc).",
|
|
1215
|
-
"Thin pairs (e.g. BE) auto multi-hop via USDG.",
|
|
1216
|
-
];
|
|
1217
|
-
return {
|
|
1218
|
-
content: [{ type: "text", text: lines.join("\n") }],
|
|
1219
|
-
structuredContent: buildRhStocksList(rows),
|
|
1220
|
-
};
|
|
1221
|
-
}
|
|
1222
|
-
case "rh_mcp_balance": {
|
|
1223
|
-
let address = (a.address ?? "").toString();
|
|
1224
|
-
if (!address || !isAddress(address)) {
|
|
1225
|
-
const walletRes = await (0, wallet_js_2.handleWalletTool)("get_wallet_address", {}).catch(() => null);
|
|
1226
|
-
const text = walletRes?.content?.[0]?.text ?? "";
|
|
1227
|
-
const m = text.match(/0x[a-fA-F0-9]{40}/);
|
|
1228
|
-
if (!m)
|
|
1229
|
-
return textResult("Wallet not configured. Run finch login first.", true);
|
|
1230
|
-
address = m[0];
|
|
1231
|
-
}
|
|
1232
|
-
const body = await fetchRhBalances(address);
|
|
1233
|
-
return textResult(`## 🟢 Robinhood Chain MCP - Balance\n\n${body}`);
|
|
1234
|
-
}
|
|
1235
|
-
case "rh_mcp_estimate": {
|
|
1236
|
-
if (!a.fromToken || !a.toToken || !a.amount) {
|
|
1237
|
-
return textResult("Required: fromToken, toToken, amount", true);
|
|
1238
|
-
}
|
|
1239
|
-
try {
|
|
1240
|
-
const wallet = await (0, wallet_js_1.getOrCreateWallet)();
|
|
1241
|
-
const q = await quoteRh({
|
|
1242
|
-
fromToken: a.fromToken,
|
|
1243
|
-
toToken: a.toToken,
|
|
1244
|
-
amount: String(a.amount),
|
|
1245
|
-
maxSlippagePct: a.maxSlippagePct,
|
|
1246
|
-
taker: wallet.address,
|
|
1247
|
-
});
|
|
1248
|
-
const buyHuman = formatWei(q.quote?.buyAmount ?? q.buyAmount ?? "0", q.to.decimals);
|
|
1249
|
-
const route = q.quote?.route ?? q.route ?? "uniswap-v4";
|
|
1250
|
-
const pool = q.quote?.pool ?? q.pool;
|
|
1251
|
-
const buyNum = Number(ethers_1.ethers.formatUnits(q.quote?.buyAmount ?? q.buyAmount ?? "0", q.to.decimals));
|
|
1252
|
-
const warn = badQuoteWarning(await quoteValueRatio(q.from, q.to, String(a.amount), buyNum), q.to.symbol);
|
|
1253
|
-
return textResult([
|
|
1254
|
-
`**RH Swap Estimate** (not executed) · chainId ${exports.RH_CHAIN_ID}`,
|
|
1255
|
-
``,
|
|
1256
|
-
warn ? warn + "\n" : "",
|
|
1257
|
-
`You sell: **${a.amount} ${q.from.symbol}**`,
|
|
1258
|
-
`You get: **~${buyHuman} ${q.to.symbol}**`,
|
|
1259
|
-
`Token: \`${q.from.kind === "token" ? q.from.address : q.to.address}\` ⚠️ verify this is the intended contract`,
|
|
1260
|
-
`Route: \`${route}\`${pool?.via ? ` via ${pool.via}` : ""}${pool?.quote ? ` · pair quote ${pool.quote}` : ""}`,
|
|
1261
|
-
(() => {
|
|
1262
|
-
const rc = q.quote?.routesConsidered ?? q.routesConsidered;
|
|
1263
|
-
if (!rc)
|
|
1264
|
-
return "";
|
|
1265
|
-
const fmt = (v) => v ? formatWei(v, q.to.decimals) : "no pool";
|
|
1266
|
-
return `Compared: V2 ${fmt(rc.v2)} · V3 ${fmt(rc.v3)} · V4 ${fmt(rc.v4)} → best wins`;
|
|
1267
|
-
})(),
|
|
1268
|
-
`Slippage: ${a.maxSlippagePct ?? 2.0}%`,
|
|
1269
|
-
pool?.liquidityUsd != null ? `Pool liq (approx): $${Number(pool.liquidityUsd).toLocaleString()}` : "",
|
|
1270
|
-
``,
|
|
1271
|
-
`Run \`rh_mcp_swap\` with same params + \`confirm: true\` to execute.`,
|
|
1272
|
-
`Explorer quote context: ${exports.RH_EXPLORER}`,
|
|
1273
|
-
]
|
|
1274
|
-
.filter(Boolean)
|
|
1275
|
-
.join("\n"));
|
|
1276
|
-
}
|
|
1277
|
-
catch (e) {
|
|
1278
|
-
return textResult(`Estimate failed: ${e?.message ?? e}`, true);
|
|
1279
|
-
}
|
|
1280
|
-
}
|
|
1281
|
-
case "rh_mcp_swap": {
|
|
1282
|
-
if (!a.fromToken || !a.toToken || !a.amount) {
|
|
1283
|
-
return textResult("Required: fromToken, toToken, amount, confirm", true);
|
|
1284
|
-
}
|
|
1285
|
-
if (a.confirm !== true) {
|
|
1286
|
-
return textResult("Refusing to broadcast: pass `confirm: true` after reviewing `rh_mcp_estimate`.", true);
|
|
1287
|
-
}
|
|
1288
|
-
try {
|
|
1289
|
-
const wallet = await (0, wallet_js_1.getOrCreateWallet)();
|
|
1290
|
-
const q = await quoteRh({
|
|
1291
|
-
fromToken: a.fromToken,
|
|
1292
|
-
toToken: a.toToken,
|
|
1293
|
-
amount: String(a.amount),
|
|
1294
|
-
maxSlippagePct: a.maxSlippagePct,
|
|
1295
|
-
taker: wallet.address,
|
|
1296
|
-
toolName: "rh_mcp_swap",
|
|
1297
|
-
});
|
|
1298
|
-
const quote = q.quote ?? q;
|
|
1299
|
-
const tx = quote.transaction ?? {
|
|
1300
|
-
to: quote.to,
|
|
1301
|
-
data: quote.data,
|
|
1302
|
-
value: quote.value,
|
|
1303
|
-
gas: quote.gas,
|
|
1304
|
-
};
|
|
1305
|
-
if (!tx?.to || !tx?.data) {
|
|
1306
|
-
return textResult("Quote missing transaction calldata.", true);
|
|
1307
|
-
}
|
|
1308
|
-
// Hard stop on catastrophic pricing (empty V4 path). Requires an explicit
|
|
1309
|
-
// acceptBadPrice override so a value-destroying swap can never be a default.
|
|
1310
|
-
const buyNum = Number(ethers_1.ethers.formatUnits(quote.buyAmount ?? "0", q.to.decimals));
|
|
1311
|
-
const priceWarn = badQuoteWarning(await quoteValueRatio(q.from, q.to, String(a.amount), buyNum), q.to.symbol);
|
|
1312
|
-
if (priceWarn && a.acceptBadPrice !== true) {
|
|
1313
|
-
return textResult(`${priceWarn}\n\n**Refusing to broadcast.** If you truly intend to accept this ` +
|
|
1314
|
-
`rate, re-run with \`acceptBadPrice: true\`.`, true);
|
|
1315
|
-
}
|
|
1316
|
-
const approveHashes = [];
|
|
1317
|
-
if (q.from.kind === "token") {
|
|
1318
|
-
// Approval target depends on the route the quoter picked:
|
|
1319
|
-
// V4 → Permit2 two-step (ERC20→Permit2, Permit2→UniversalRouter)
|
|
1320
|
-
// V2/V3 → plain ERC-20 approve to SwapRouter02 (it uses transferFrom)
|
|
1321
|
-
// Hardcoding the V4 path here silently broke every V2/V3 sell.
|
|
1322
|
-
const spender = quote.allowanceTarget ?? quote.issues?.allowance?.spender ?? RH_PERMIT2;
|
|
1323
|
-
const hashes = spender.toLowerCase() === RH_PERMIT2.toLowerCase()
|
|
1324
|
-
? await ensureSellApprovals(wallet, q.from.address, BigInt(q.sellAmount))
|
|
1325
|
-
: await ensureDirectApproval(wallet, q.from.address, spender, BigInt(q.sellAmount));
|
|
1326
|
-
approveHashes.push(...hashes);
|
|
1327
|
-
}
|
|
1328
|
-
// Snapshot ETH before broadcasting so a sell can report what actually landed.
|
|
1329
|
-
let ethBefore = null;
|
|
1330
|
-
if (q.to.kind === "eth") {
|
|
1331
|
-
try {
|
|
1332
|
-
ethBefore = BigInt(await rhRpc("eth_getBalance", [wallet.address, "latest"]));
|
|
1333
|
-
}
|
|
1334
|
-
catch {
|
|
1335
|
-
/* optional */
|
|
1336
|
-
}
|
|
1337
|
-
}
|
|
1338
|
-
const txHash = await broadcastRhSwap(wallet, tx);
|
|
1339
|
-
const buyHuman = formatWei(quote.buyAmount ?? "0", q.to.decimals);
|
|
1340
|
-
const route = quote.route ?? "uniswap-v4";
|
|
1341
|
-
const rc = await confirmRhSwap(txHash, wallet.address, q.to, ethBefore);
|
|
1342
|
-
const header = !rc.mined
|
|
1343
|
-
? `⏳ RH swap broadcast — not yet confirmed`
|
|
1344
|
-
: rc.ok
|
|
1345
|
-
? `✅ RH swap CONFIRMED on-chain`
|
|
1346
|
-
: `❌ RH swap REVERTED on-chain`;
|
|
1347
|
-
const actual = rc.received != null
|
|
1348
|
-
? `**Actually received: ${Number(rc.received).toLocaleString(undefined, {
|
|
1349
|
-
maximumFractionDigits: 6,
|
|
1350
|
-
})} ${q.to.symbol}** (quoted ~${buyHuman})`
|
|
1351
|
-
: `Quoted: ~${buyHuman} ${q.to.symbol}`;
|
|
1352
|
-
return textResult([
|
|
1353
|
-
header,
|
|
1354
|
-
``,
|
|
1355
|
-
`**Sent:** ${a.amount} ${q.from.symbol}`,
|
|
1356
|
-
actual,
|
|
1357
|
-
`**Token:** \`${q.from.kind === "token" ? q.from.address : q.to.address}\``,
|
|
1358
|
-
`**Route:** \`${route}\``,
|
|
1359
|
-
approveHashes.length
|
|
1360
|
-
? `**Approval tx:** ${approveHashes.map((h) => `\`${h}\``).join(", ")}`
|
|
1361
|
-
: "",
|
|
1362
|
-
``,
|
|
1363
|
-
`### Proof`,
|
|
1364
|
-
`**Tx hash:** \`${txHash}\``,
|
|
1365
|
-
rc.mined ? `**Status:** ${rc.ok ? "success (status 1)" : "reverted (status 0)"}` : "",
|
|
1366
|
-
rc.block != null ? `**Block:** ${rc.block}` : "",
|
|
1367
|
-
rc.gasUsed ? `**Gas used:** ${Number(rc.gasUsed).toLocaleString()}` : "",
|
|
1368
|
-
`**Verify:** ${exports.RH_EXPLORER}/tx/${txHash}`,
|
|
1369
|
-
``,
|
|
1370
|
-
!rc.mined
|
|
1371
|
-
? `_Receipt not seen within 90s — the tx may still be pending. Check the explorer link; do not re-send._`
|
|
1372
|
-
: rc.ok
|
|
1373
|
-
? `_Figures above are read from the mined receipt, not the quote._`
|
|
1374
|
-
: `_The transaction reverted. Funds stay in your wallet minus gas._`,
|
|
1375
|
-
]
|
|
1376
|
-
.filter(Boolean)
|
|
1377
|
-
.join("\n"));
|
|
1378
|
-
}
|
|
1379
|
-
catch (e) {
|
|
1380
|
-
return textResult(`Swap failed: ${e?.message ?? e}`, true);
|
|
1381
|
-
}
|
|
1382
|
-
}
|
|
1383
|
-
case "rh_token_resolve": {
|
|
1384
|
-
const input = (a.query ?? a.token ?? a.ticker ?? "").toString().trim();
|
|
1385
|
-
if (!input)
|
|
1386
|
-
return textResult("Required: query (crypto ticker or 0x contract address)", true);
|
|
1387
|
-
try {
|
|
1388
|
-
if (isAddress(input)) {
|
|
1389
|
-
const dex = await dexTokenBest(input);
|
|
1390
|
-
if (!dex) {
|
|
1391
|
-
return textResult(`No Robinhood-chain pair found for \`${input}\` — it may not be tradeable on chain 4663.`, true);
|
|
1392
|
-
}
|
|
1393
|
-
return textResult(renderResolveOne(input, dex));
|
|
1394
|
-
}
|
|
1395
|
-
const cands = await dexSearchByTicker(input);
|
|
1396
|
-
if (!cands.length) {
|
|
1397
|
-
return textResult(`No Robinhood-chain token matched ticker "${input}". Paste the 0x contract address instead.`, true);
|
|
1398
|
-
}
|
|
1399
|
-
return textResult(renderResolveList(input, cands));
|
|
1400
|
-
}
|
|
1401
|
-
catch (e) {
|
|
1402
|
-
return textResult(`Resolve failed: ${e?.message ?? e}`, true);
|
|
1403
|
-
}
|
|
1404
|
-
}
|
|
1405
|
-
case "rh_analyze": {
|
|
1406
|
-
const input = (a.token ?? a.query ?? a.ticker ?? "").toString().trim();
|
|
1407
|
-
if (!input)
|
|
1408
|
-
return textResult("Required: token (crypto ticker or 0x contract address)", true);
|
|
1409
|
-
try {
|
|
1410
|
-
const resolved = await resolveTokenSmart(input);
|
|
1411
|
-
const dex = resolved.dex ?? (await dexTokenBest(resolved.address));
|
|
1412
|
-
if (!dex) {
|
|
1413
|
-
return textResult(`No DexScreener market data on Robinhood chain for \`${resolved.address}\`.`, true);
|
|
1414
|
-
}
|
|
1415
|
-
const assessment = assessRisk(dex);
|
|
1416
|
-
return {
|
|
1417
|
-
content: [{ type: "text", text: renderAnalysis(resolved, dex, assessment) }],
|
|
1418
|
-
structuredContent: buildRhAnalysis(resolved, dex, assessment),
|
|
1419
|
-
};
|
|
1420
|
-
}
|
|
1421
|
-
catch (e) {
|
|
1422
|
-
return textResult(`Analyze failed: ${e?.message ?? e}`, true);
|
|
1423
|
-
}
|
|
1424
|
-
}
|
|
1425
|
-
case "rh_safety_check": {
|
|
1426
|
-
const input = (a.token ?? a.query ?? a.ticker ?? "").toString().trim();
|
|
1427
|
-
if (!input)
|
|
1428
|
-
return textResult("Required: token (crypto ticker or 0x contract address)", true);
|
|
1429
|
-
try {
|
|
1430
|
-
const resolved = await resolveTokenSmart(input);
|
|
1431
|
-
const [safety, dex] = await Promise.all([
|
|
1432
|
-
blockscoutSafety(resolved.address),
|
|
1433
|
-
resolved.dex ? Promise.resolve(resolved.dex) : dexTokenBest(resolved.address),
|
|
1434
|
-
]);
|
|
1435
|
-
const assessment = assessSafety(safety, dex);
|
|
1436
|
-
return {
|
|
1437
|
-
content: [{ type: "text", text: renderSafety(resolved, safety, assessment) }],
|
|
1438
|
-
structuredContent: buildRhSafetyStructured(resolved, safety, assessment),
|
|
1439
|
-
};
|
|
1440
|
-
}
|
|
1441
|
-
catch (e) {
|
|
1442
|
-
return textResult(`Safety check failed: ${e?.message ?? e}`, true);
|
|
1443
|
-
}
|
|
1444
|
-
}
|
|
1445
|
-
default:
|
|
1446
|
-
return null;
|
|
1447
|
-
}
|
|
1448
|
-
}
|