diff --git a/agents/meteora_launch_lp/routines/damm_v2_scanner.py b/agents/meteora_launch_lp/routines/damm_v2_scanner.py index 52ba7d534..e59df21bf 100644 --- a/agents/meteora_launch_lp/routines/damm_v2_scanner.py +++ b/agents/meteora_launch_lp/routines/damm_v2_scanner.py @@ -14,6 +14,7 @@ The scanner deliberately skips pools with an active fee scheduler (base fee often starts near 99% and decays — a token-launch trap) unless include_launch_pools is set. """ +import io import logging import aiohttp @@ -34,6 +35,66 @@ "USDT": "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", } _WINDOWS = {"1h", "2h", "4h", "12h", "24h"} +_WINDOW_HOURS = {"1h": 1, "2h": 2, "4h": 4, "12h": 12, "24h": 24} + + +def _apr(fee_yield: float, window: str) -> float: + """Annualize a window fee yield into a fee APR %. + + The Meteora API's fee_tvl_ratio is ALREADY a percentage per window + (verified: fees.24h / tvl × 100 == fee_tvl_ratio.24h), so only scale by + windows-per-year. + """ + return fee_yield * (8760 / _WINDOW_HOURS.get(window, 24)) + + +def _build_chart(ranked: list[dict], window: str) -> tuple[bytes | None, object]: + """Horizontal fee-APR bar chart of the ranked pools (PNG bytes + plotly fig).""" + try: + import plotly.graph_objects as go + except ImportError: + return None, None + + pools = list(reversed(ranked)) + n = len(pools) + labels, texts, vals, colors = [], [], [], [] + for i, c in enumerate(pools): + rank = n - i + detail = (f"TVL ${c['tvl']:,.0f} · vol {window} ${c['vol_win']:,.0f} · " + f"fee {c['base_fee_pct']:.2f}%") + labels.append(f"#{rank} {c['pair']}
" + f"{detail}") + apr = _apr(c["fee_yield"], window) + vals.append(apr) + texts.append(f"{apr:,.0f}%") + colors.append("#d4a017" if rank <= 3 else "#8664c6") + + fig = go.Figure(go.Bar( + y=labels, x=vals, orientation="h", + marker=dict(color=colors, line=dict(width=0)), + text=texts, textposition="outside", + textfont=dict(size=11, color="#ffffff", family="monospace"), + hovertemplate="%{y}
Fee APR: %{text}", + showlegend=False, + )) + fig.update_layout( + title=dict(text=f"Meteora DAMM v2 — Fee APR (annualized from {window} fees/TVL)", + font=dict(size=15, color="#ffffff"), x=0.5, xanchor="center"), + height=max(450, n * 52 + 130), width=1100, + paper_bgcolor="#1a1a2e", plot_bgcolor="#1a1a2e", + font=dict(size=11, color="#e0e0e0"), + margin=dict(l=300, r=90, t=70, b=40), bargap=0.3, + xaxis=dict(showticklabels=False, showgrid=False, zeroline=False, + range=[0, (max(vals) if vals else 1) * 1.18]), + yaxis=dict(showgrid=False, tickfont=dict(color="#e0e0e0", size=11)), + ) + try: + buf = io.BytesIO() + fig.write_image(buf, format="png", scale=2) + return buf.getvalue(), fig + except Exception as e: + logger.warning(f"damm_v2_scanner: PNG export failed: {e}") + return None, fig class Config(BaseModel): @@ -127,7 +188,7 @@ async def run(config: Config, context: ContextTypes.DEFAULT_TYPE) -> str: rows.append({ "#": i, "Pair": c["pair"], - "FeeYield": f"{c['fee_yield'] * 100:.3f}%", + "FeeYield": f"{c['fee_yield']:.3f}%", "BaseFee": f"{c['base_fee_pct']:.3f}%", "TVL": f"${c['tvl']:,.0f}", f"Vol{window}": f"${c['vol_win']:,.0f}", @@ -148,9 +209,44 @@ async def run(config: Config, context: ContextTypes.DEFAULT_TYPE) -> str: f"{'included' if config.include_launch_pools else 'excluded'}." ) + chart_bytes, plotly_fig = _build_chart(ranked, window) + + chat_id = getattr(context, "_chat_id", None) + if chart_bytes and chat_id and context.bot: + try: + await context.bot.send_photo( + chat_id=chat_id, photo=io.BytesIO(chart_bytes), + caption=f"DAMM v2 fee-APR ranking — top {len(ranked)} {quote}-quoted pools", + ) + except Exception as e: + logger.warning(f"damm_v2_scanner: failed to send chart to Telegram: {e}") + + try: + from condor.reports import ReportBuilder + + builder = ReportBuilder("DAMM v2 Fee-Yield Scanner") + builder.source("routine", "damm_v2_scanner").tags(["meteora", "damm-v2", "lp", "yield"]) + builder.kpi("Pools ranked", str(len(ranked))) + builder.kpi("Top fee APR", f"{_apr(ranked[0]['fee_yield'], window):,.0f}%") + builder.kpi("Top pool TVL", f"${ranked[0]['tvl']:,.0f}") + builder.markdown( + f"{quote}-quoted Meteora DAMM v2 pools ranked by {window} fees/TVL " + f"({len(candidates)} candidates passed filters; min TVL ${config.min_tvl_usd:,.0f}, " + f"verified_only={config.verified_only}, launch pools " + f"{'included' if config.include_launch_pools else 'excluded'})." + ) + if plotly_fig is not None: + builder.plotly(plotly_fig) + builder.table(rows) + builder.manual_order() + await builder.save() + except Exception as e: + logger.warning(f"damm_v2_scanner: report save failed: {e}") + try: from routines.base import RoutineResult - return RoutineResult(text=summary, table_data=rows, table_columns=columns) + return RoutineResult(text=summary, table_data=rows, table_columns=columns, + chart_image=chart_bytes) except Exception: lines = [summary, ""] for r in rows: diff --git a/agents/meteora_launch_lp/routines/easya_graduation_monitor.py b/agents/meteora_launch_lp/routines/easya_graduation_monitor.py index 1fb196159..33835b8ee 100644 --- a/agents/meteora_launch_lp/routines/easya_graduation_monitor.py +++ b/agents/meteora_launch_lp/routines/easya_graduation_monitor.py @@ -14,8 +14,10 @@ Note: early LP here is directional-long the token (you must buy the base token to pair it with SOL), so size small and capped. """ +import io import logging import time +from datetime import datetime, timezone import aiohttp from pydantic import BaseModel, Field @@ -32,6 +34,92 @@ EASYA_MINT_SUFFIX = "EASY" # EasyA vanity-suffix marker on graduated token mints +GECKO_OHLCV = "https://api.geckoterminal.com/api/v2/networks/solana/pools/{pool}/ohlcv/hour" + + +async def _fetch_ohlcv(session: aiohttp.ClientSession, pool: str, hours: float) -> tuple[list[dict], str]: + """Hourly OHLCV bars covering the pool's whole post-graduation life. + + Primary: GeckoTerminal (up to 1000 hourly bars, price in USD). Fallback: the Meteora + DAMM v2 data API's native OHLCV (price in SOL), which caps at ~10 bars per response, + so a coarse timeframe is chosen to still span the pool's age. + Returns (bars, price_unit) where bars are {timestamp, open, high, low, close, volume}. + """ + limit = max(12, min(int(hours) + 4, 1000)) + try: + async with session.get(GECKO_OHLCV.format(pool=pool), params={"limit": limit}, + timeout=aiohttp.ClientTimeout(total=15)) as resp: + if resp.status == 200: + ol = (await resp.json())["data"]["attributes"]["ohlcv_list"] + if ol: + bars = [{"timestamp": b[0], "open": b[1], "high": b[2], "low": b[3], + "close": b[4], "volume": b[5]} for b in reversed(ol)] + return bars, "USD" + except Exception as e: + logger.warning(f"easya_graduation_monitor: GeckoTerminal OHLCV failed for {pool}: {e}") + + # Fallback: Meteora native (≈10 bars max) — pick a timeframe wide enough to span the age. + timeframe = "1h" if hours <= 10 else "2h" if hours <= 20 else "4h" if hours <= 40 else "12h" + try: + async with session.get(f"{DAMM_V2_API}/{pool}/ohlcv", params={"timeframe": timeframe}, + timeout=aiohttp.ClientTimeout(total=15)) as resp: + if resp.status != 200: + return [], "" + return (await resp.json()).get("data", []), "SOL" + except Exception as e: + logger.warning(f"easya_graduation_monitor: Meteora OHLCV failed for {pool}: {e}") + return [], "" + + +def _build_chart(top: dict, bars: list[dict], price_unit: str = "SOL") -> tuple[bytes | None, object]: + """Price + volume panel for the top graduation — shows the post-graduation + dump/stabilization path the agent must wait out before LPing.""" + if not bars: + return None, None + try: + import plotly.graph_objects as go + from plotly.subplots import make_subplots + except ImportError: + return None, None + + ts = [datetime.fromtimestamp(b["timestamp"], tz=timezone.utc) for b in bars] + fig = make_subplots(rows=2, cols=1, shared_xaxes=True, + row_heights=[0.72, 0.28], vertical_spacing=0.04) + fig.add_trace(go.Candlestick( + x=ts, open=[b["open"] for b in bars], high=[b["high"] for b in bars], + low=[b["low"] for b in bars], close=[b["close"] for b in bars], + increasing_line_color="#26a69a", decreasing_line_color="#ef5350", + name=f"Price ({price_unit})", showlegend=False, + ), row=1, col=1) + fig.add_trace(go.Bar( + x=ts, y=[b["volume"] for b in bars], + marker_color="#8664c6", name="Volume", showlegend=False, + ), row=2, col=1) + fig.update_layout( + title=dict( + text=(f"{top['pair']} — post-graduation price path " + f"(age {top['age_h']:.0f}h · TVL ${top['tvl']:,.0f} · " + f"fee yield 24h {top['fee_yield24h']:.2f}%)"), + font=dict(size=14, color="#ffffff"), x=0.5, xanchor="center"), + height=560, width=1100, + paper_bgcolor="#1a1a2e", plot_bgcolor="#1a1a2e", + font=dict(size=11, color="#e0e0e0"), + margin=dict(l=70, r=30, t=70, b=40), + xaxis_rangeslider_visible=False, + yaxis=dict(title=f"Price ({price_unit})", showgrid=True, gridcolor="#2a2a3e", + tickformat=".2e"), + yaxis2=dict(title=f"Vol ({price_unit})", showgrid=False), + xaxis2=dict(showgrid=False), + ) + try: + buf = io.BytesIO() + fig.write_image(buf, format="png", scale=2) + return buf.getvalue(), fig + except Exception as e: + logger.warning(f"easya_graduation_monitor: PNG export failed: {e}") + return None, fig + + class Config(BaseModel): """Detect fresh EasyA graduations into Meteora DAMM v2, ranked by fee yield.""" @@ -119,7 +207,7 @@ async def run(config: Config, context: ContextTypes.DEFAULT_TYPE) -> str: "#": i, "Pair": c["pair"], "Age(h)": f"{c['age_h']:.1f}", - "FeeYield": f"{c['fee_yield24h'] * 100:.1f}%", + "FeeYield": f"{c['fee_yield24h']:.2f}%", "TVL": f"${c['tvl']:,.0f}", "Vol24h": f"${c['vol24h']:,.0f}", "Verified": "yes" if c["verified"] else "no", @@ -139,9 +227,48 @@ async def run(config: Config, context: ContextTypes.DEFAULT_TYPE) -> str: f"capped size (directional-long the token). Journal the position_address." ) + # Price/volume panel for the top graduation — the visual gate for "has the dump cleared?" + top_c = ranked[0] + async with aiohttp.ClientSession() as session: + bars, price_unit = await _fetch_ohlcv(session, top_c["pool"], top_c["age_h"]) + chart_bytes, plotly_fig = _build_chart(top_c, bars, price_unit) + + chat_id = getattr(context, "_chat_id", None) + if chart_bytes and chat_id and context.bot: + try: + await context.bot.send_photo( + chat_id=chat_id, photo=io.BytesIO(chart_bytes), + caption=f"{top_c['pair']} — post-graduation price path ({top_c['age_h']:.0f}h)", + ) + except Exception as e: + logger.warning(f"easya_graduation_monitor: failed to send chart to Telegram: {e}") + + try: + from condor.reports import ReportBuilder + + builder = ReportBuilder("EasyA Graduation Monitor") + builder.source("routine", "easya_graduation_monitor").tags( + ["meteora", "damm-v2", "easya", "launch"]) + builder.kpi("Graduations found", str(len(ranked))) + builder.kpi("Top fee yield 24h", top["FeeYield"]) + builder.kpi("Top age", f"{top['Age(h)']}h") + builder.markdown( + f"EasyA graduations into Meteora DAMM v2 in the last {config.max_age_hours:.0f}h " + f"(min TVL ${config.min_tvl_usd:,.0f}, min vol24h ${config.min_vol24h_usd:,.0f}). " + f"Gate each on sellability, safety, and post-dump demand before LPing." + ) + if plotly_fig is not None: + builder.plotly(plotly_fig) + builder.table(rows) + builder.manual_order() + await builder.save() + except Exception as e: + logger.warning(f"easya_graduation_monitor: report save failed: {e}") + try: from routines.base import RoutineResult - return RoutineResult(text=summary, table_data=rows, table_columns=columns) + return RoutineResult(text=summary, table_data=rows, table_columns=columns, + chart_image=chart_bytes) except Exception: lines = [summary, ""] for r in rows: diff --git a/agents/meteora_launch_lp/strategies/launch_lp_operator/strategy.md b/agents/meteora_launch_lp/strategies/launch_lp_operator/strategy.md index b3feb78e7..31975dbe5 100644 --- a/agents/meteora_launch_lp/strategies/launch_lp_operator/strategy.md +++ b/agents/meteora_launch_lp/strategies/launch_lp_operator/strategy.md @@ -80,7 +80,9 @@ For each open position, read its current value and decide exit. Value it in SOL: percentage_to_remove=100)`** if ANY fires: - `pnl_pct ≥ take_profit_pct` (TP) or `pnl_pct ≤ −stop_loss_pct` (SL); - **fee-APR decay**: pool `fee_tvl_ratio.24h` annualized < `min_fee_apr_pct` (the fee flow that - justified the IL exposure has dried up — from `easya_graduation_monitor`/the Meteora API); + justified the IL exposure has dried up — from `easya_graduation_monitor`/the Meteora API). + UNITS: `fee_tvl_ratio.24h` is ALREADY a percent per 24h (5.22 = 5.22%/day) — annualize as + `× 365` only, never `× 365 × 100`; - **drawdown**: token price down ≥ `drawdown_pct` from your entry price; - **max hold**: position age ≥ `max_hold_hours`; - **honeypot regression**: a fresh sellability quote (skill gate 1) now fails → exit at any price you diff --git a/condor/web/models.py b/condor/web/models.py index 107f5da48..89f967a9d 100644 --- a/condor/web/models.py +++ b/condor/web/models.py @@ -496,7 +496,7 @@ class UpdateServerRequest(BaseModel): class GatewayStartRequest(BaseModel): # The Hummingbot API always runs the Gateway secured (TLS + mTLS) and manages the # certificates/passphrase itself (hummingbot-api SEC-048), so only image/port are sent. - image: str = "hummingbot/gateway:latest" + image: str = "hummingbot/gateway:development" port: int = 15888 @@ -506,7 +506,13 @@ class CredentialInfo(BaseModel): class GatewayPullRequest(BaseModel): - image: str = "hummingbot/gateway:latest" + image: str = "hummingbot/gateway:development" + + +class AddGatewayWalletRequest(BaseModel): + chain: str + private_key: str + set_default: bool = True class AddCredentialRequest(BaseModel): diff --git a/condor/web/routes/settings.py b/condor/web/routes/settings.py index 63b0cdf2b..e0387a1b3 100644 --- a/condor/web/routes/settings.py +++ b/condor/web/routes/settings.py @@ -9,6 +9,7 @@ from condor.web.auth import get_current_user from condor.web.models import ( AddCredentialRequest, + AddGatewayWalletRequest, AddServerRequest, CredentialInfo, GatewayPullRequest, @@ -263,6 +264,79 @@ async def gateway_logs( raise HTTPException(status_code=500, detail=str(e)) +# ── Gateway Wallets ── + + +@router.get("/gateway/wallets") +async def list_gateway_wallets( + server: str = Query(...), + user: WebUser = Depends(get_current_user), +): + cm = get_config_manager() + if not cm.has_server_access(user.id, server): + raise HTTPException(status_code=403, detail="No access") + client = await _get_client(cm, server) + try: + wallets = await client.accounts.list_gateway_wallets() + return {"wallets": wallets} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/gateway/wallets") +async def add_gateway_wallet( + req: AddGatewayWalletRequest, + server: str = Query(...), + user: WebUser = Depends(get_current_user), +): + cm = get_config_manager() + if not cm.has_server_access(user.id, server): + raise HTTPException(status_code=403, detail="No access") + client = await _get_client(cm, server) + try: + # The SDK's add_gateway_wallet always sets the new wallet as the chain default + # (the API's set_default flag isn't exposed in the client), so to honor + # set_default=False we capture the current default and restore it after adding. + previous_default = None + if not req.set_default: + wallets = await client.accounts.list_gateway_wallets() + for group in wallets: + if group.get("chain") == req.chain: + previous_default = group.get("default_address") or None + break + + result = await client.accounts.add_gateway_wallet( + chain=req.chain, + private_key=req.private_key, + ) + + if previous_default and previous_default != result.get("address"): + await client.accounts.set_default_gateway_wallet( + chain=req.chain, address=previous_default + ) + return {"added": True, "wallet": result} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete("/gateway/wallets/{chain}/{address}") +async def remove_gateway_wallet( + chain: str, + address: str, + server: str = Query(...), + user: WebUser = Depends(get_current_user), +): + cm = get_config_manager() + if not cm.has_server_access(user.id, server): + raise HTTPException(status_code=403, detail="No access") + client = await _get_client(cm, server) + try: + result = await client.accounts.remove_gateway_wallet(chain=chain, address=address) + return {"deleted": True, "result": result} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + # ── Voice Preferences ── diff --git a/frontend/src/components/ConnectKeysOverlay.tsx b/frontend/src/components/ConnectKeysOverlay.tsx index cace8df33..571d23914 100644 --- a/frontend/src/components/ConnectKeysOverlay.tsx +++ b/frontend/src/components/ConnectKeysOverlay.tsx @@ -19,10 +19,11 @@ export function ConnectKeysOverlay() { {/* Copy */}

- Connect Your Exchange + Connect an Exchange or Wallet

- Add your exchange API keys to unlock portfolio tracking, trading, and bot management. + Add exchange API keys or a DEX wallet to unlock portfolio tracking, trading, and bot + management.

{/* CTA */} @@ -30,7 +31,7 @@ export function ConnectKeysOverlay() { onClick={() => navigate("/settings?tab=keys")} className="flex items-center gap-2 rounded-lg bg-[var(--color-primary)] px-6 py-2.5 text-sm font-semibold text-white transition-all hover:bg-[var(--color-primary-hover)] hover:shadow-lg hover:shadow-[var(--color-primary)]/20 active:scale-[0.98]" > - Connect API Keys + Connect Keys & Wallets diff --git a/frontend/src/components/settings/ApiKeysSettings.tsx b/frontend/src/components/settings/ApiKeysSettings.tsx index 920329054..101eab3f9 100644 --- a/frontend/src/components/settings/ApiKeysSettings.tsx +++ b/frontend/src/components/settings/ApiKeysSettings.tsx @@ -6,16 +6,25 @@ import { Key, Loader2, Plus, + Star, Trash2, + Wallet, X, } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { useServer } from "@/hooks/useServer"; -import { type ConnectorInfo, type CredentialInfo, api } from "@/lib/api"; +import { type ConnectorInfo, type CredentialInfo, type GatewayWalletGroup, api } from "@/lib/api"; import { ConnectHyperliquid } from "./ConnectHyperliquid"; +import { ImportGatewayWallet, type WalletChain } from "./ImportGatewayWallet"; -type Step = "list" | "select-type" | "select-exchange" | "fill-fields" | "connect-hyperliquid"; +type Step = + | "list" + | "select-type" + | "select-exchange" + | "fill-fields" + | "connect-hyperliquid" + | "import-wallet"; const isHyperliquid = (name: string) => name.startsWith("hyperliquid"); @@ -23,6 +32,7 @@ interface AddFlowState { step: Step; connectorType: string; connectorName: string; + walletChain: WalletChain | ""; fields: Record; values: Record; } @@ -31,10 +41,16 @@ const INITIAL_FLOW: AddFlowState = { step: "list", connectorType: "", connectorName: "", + walletChain: "", fields: {}, values: {}, }; +const WALLET_CHAINS: { chain: WalletChain; label: string }[] = [ + { chain: "solana", label: "Solana" }, + { chain: "ethereum", label: "Ethereum/EVM" }, +]; + // Substrings that mark a connector config field as a sensitive credential. // Connector keys vary (api_key, secret_key, passphrase, private_key, api_token, // mnemonic, seed, ...), so we match by substring on both the field name and type @@ -61,12 +77,31 @@ export function ApiKeysSettings() { const [flow, setFlow] = useState(INITIAL_FLOW); const [confirmDelete, setConfirmDelete] = useState(null); + const [confirmDeleteWallet, setConfirmDeleteWallet] = useState(null); + const { data: credsData, isLoading: loadingCreds } = useQuery({ queryKey: ["settings-credentials", server], queryFn: () => api.getCredentials(server!), enabled: !!server, }); + // Gateway wallets — errors (e.g. Gateway not running) render as a muted note, not a failure. + const { data: walletsData, error: walletsError } = useQuery({ + queryKey: ["gateway-wallets", server], + queryFn: () => api.getGatewayWallets(server!), + enabled: !!server, + retry: false, + }); + + // Same key as GatewaySettings so the status is shared/cached across tabs. + const { data: gatewayStatus } = useQuery({ + queryKey: ["gateway-status", server], + queryFn: () => api.getGatewayStatus(server!), + enabled: !!server, + retry: false, + }); + const gatewayRunning = gatewayStatus?.running === true; + const { data: connectorsData, isLoading: loadingConnectors } = useQuery({ queryKey: ["settings-connectors", server, flow.connectorType], queryFn: () => api.getAvailableConnectors(server!, flow.connectorType || undefined), @@ -110,6 +145,14 @@ export function ApiKeysSettings() { onSuccess: () => { invalidate(); setConfirmDelete(null); }, }); + const invalidateWallets = () => qc.invalidateQueries({ queryKey: ["gateway-wallets", server] }); + + const deleteWalletMut = useMutation({ + mutationFn: ({ chain, address }: { chain: string; address: string }) => + api.removeGatewayWallet(server!, chain, address), + onSuccess: () => { invalidateWallets(); setConfirmDeleteWallet(null); }, + }); + // Normalize credentials — API may return strings or objects const credentials: CredentialInfo[] = useMemo(() => { const raw = credsData?.credentials ?? []; @@ -232,6 +275,57 @@ export function ApiKeysSettings() { ); } + // ── Add wallet flow ── + + if (flow.step === "import-wallet" && flow.walletChain) { + return ( +
+ +
+

Add Wallet to Gateway

+

+ Gateway signs DEX transactions on your server, so it needs the wallet's private key. + Browser wallets never share private keys with apps — export the key manually and paste + it below. It is sent to your Hummingbot API server and stored encrypted by Gateway. +

+
+ + {/* Chain tabs */} +
+ {WALLET_CHAINS.map(({ chain, label }) => ( + + ))} +
+ + setFlow(INITIAL_FLOW)} + onDone={() => { + invalidateWallets(); + setFlow(INITIAL_FLOW); + }} + /> +
+ ); + } + if (flow.step === "select-exchange") { const connectors: ConnectorInfo[] = [...(connectorsData?.connectors ?? [])].sort((a, b) => a.name.localeCompare(b.name), @@ -380,12 +474,22 @@ export function ApiKeysSettings() {

{credentials.length} credential{credentials.length !== 1 ? "s" : ""} configured

- +
+ + +
{loadingCreds ? ( @@ -455,6 +559,91 @@ export function ApiKeysSettings() { ))} )} + + {/* ── Gateway wallets ── */} +
+

+ Wallets +

+ {walletsError ? ( +

+ Gateway is not reachable — start it from the Gateway tab to manage wallets. +

+ ) : !walletsData || + walletsData.wallets.every((g) => (g.walletAddresses ?? []).length === 0) ? ( +

+ No wallets in Gateway. Add one to trade on DEXs. +

+ ) : ( +
+ {walletsData.wallets.flatMap((group: GatewayWalletGroup) => + (group.walletAddresses ?? []).map((address) => { + const walletKey = `${group.chain}:${address}`; + const isDefault = group.default_address === address; + return ( +
+
+
+ +
+
+
+ + {group.chain} + + {isDefault && ( + + default + + )} +
+

+ {address} +

+
+
+ + {confirmDeleteWallet === walletKey ? ( +
+ + +
+ ) : ( + + )} +
+ ); + }), + )} +
+ )} +
); } diff --git a/frontend/src/components/settings/ConnectHyperliquid.tsx b/frontend/src/components/settings/ConnectHyperliquid.tsx index 82b980502..e91c28a2f 100644 --- a/frontend/src/components/settings/ConnectHyperliquid.tsx +++ b/frontend/src/components/settings/ConnectHyperliquid.tsx @@ -147,7 +147,7 @@ export function ConnectHyperliquid({ const reason = (failed[0].result as PromiseRejectedResult).reason as { message?: string }; setPartial( `Connected, but ${failed.map((f) => f.name).join(", ")} could not be saved ` + - `(${reason?.message || "validation failed"}). Retry it from the API Keys list — no re-signing needed.`, + `(${reason?.message || "validation failed"}). Retry it from the Keys and Wallets list — no re-signing needed.`, ); } diff --git a/frontend/src/components/settings/ImportGatewayWallet.tsx b/frontend/src/components/settings/ImportGatewayWallet.tsx new file mode 100644 index 000000000..e33743477 --- /dev/null +++ b/frontend/src/components/settings/ImportGatewayWallet.tsx @@ -0,0 +1,182 @@ +import { AlertCircle, Check, Loader2 } from "lucide-react"; +import { useMemo, useState } from "react"; +import { privateKeyToAccount } from "viem/accounts"; + +import { api } from "@/lib/api"; + +export type WalletChain = "solana" | "ethereum"; + +// Browser wallets (Phantom, MetaMask, Rabby) never expose private keys to websites — that's by +// design, and why this flow is a manual export + paste instead of a one-click "Connect". +const EXPORT_INSTRUCTIONS: Record = { + solana: [ + { + wallet: "Phantom", + steps: "Settings → Manage Accounts → select account → Show Private Key", + }, + ], + ethereum: [ + { wallet: "MetaMask", steps: "⋮ menu → Account details → Show private key" }, + { wallet: "Rabby", steps: "Click address → Address Detail → Backup Private Key" }, + ], +}; + +const EVM_KEY_RE = /^(0x)?[0-9a-fA-F]{64}$/; +// Phantom exports the keypair as base58, typically 87–88 chars. +const SOLANA_KEY_RE = /^[1-9A-HJ-NP-Za-km-z]{64,120}$/; + +export function ImportGatewayWallet({ + server, + chain, + onBack, + onDone, +}: { + server: string; + chain: WalletChain; + onBack: () => void; + onDone: () => void; +}) { + const [key, setKey] = useState(""); + const [setDefault, setSetDefault] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [importedAddress, setImportedAddress] = useState(null); + + const trimmed = key.trim(); + + // For Ethereum we can derive the address locally before importing, so the user sees exactly + // which account the pasted key controls. Solana derivation would need an ed25519 lib — the + // imported address is shown from Gateway's response instead. + const derived = useMemo(() => { + if (chain !== "ethereum" || !EVM_KEY_RE.test(trimmed)) return null; + try { + const hex = (trimmed.startsWith("0x") ? trimmed : `0x${trimmed}`) as `0x${string}`; + return privateKeyToAccount(hex).address; + } catch { + return null; + } + }, [chain, trimmed]); + + const keyLooksValid = + chain === "ethereum" ? derived !== null : SOLANA_KEY_RE.test(trimmed); + + async function handleImport() { + setError(null); + setSaving(true); + try { + const privateKey = + chain === "ethereum" && !trimmed.startsWith("0x") ? `0x${trimmed}` : trimmed; + const res = await api.addGatewayWallet(server, { + chain, + private_key: privateKey, + set_default: setDefault, + }); + setImportedAddress(res.wallet?.address ?? derived ?? ""); + setKey(""); + window.setTimeout(onDone, 1500); + } catch (e) { + const err = e as { message?: string }; + setError(err.message || "Failed to import wallet into Gateway."); + } finally { + setSaving(false); + } + } + + return ( +
+
+

+ Where to find your private key +

+
    + {EXPORT_INSTRUCTIONS[chain].map(({ wallet, steps }) => ( +
  • + {wallet}: {steps} +
  • + ))} +
+
+ + {importedAddress !== null ? ( +
+ + + Wallet imported into Gateway. + {importedAddress && ( + + {importedAddress} + + )} + +
+ ) : ( +
+
+ + setKey(e.target.value)} + disabled={saving} + placeholder={ + chain === "ethereum" ? "0x… (64 hex characters)" : "Base58 private key from Phantom" + } + className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-1.5 font-mono text-sm text-[var(--color-text)] focus:border-[var(--color-primary)] focus:outline-none disabled:opacity-50" + /> + {derived && ( +

+ Will import: {derived} +

+ )} + {trimmed.length > 0 && !keyLooksValid && ( +

+ {chain === "ethereum" + ? "Expected a 64-character hex private key (with or without 0x prefix)." + : "Expected a base58 private key (no 0x, no brackets)."} +

+ )} +
+ + + + {error && ( +
+ + {error} +
+ )} + +
+ + +
+
+ )} +
+ ); +} diff --git a/frontend/src/hooks/useCredentials.ts b/frontend/src/hooks/useCredentials.ts index 99753bc4d..210f67d81 100644 --- a/frontend/src/hooks/useCredentials.ts +++ b/frontend/src/hooks/useCredentials.ts @@ -6,15 +6,29 @@ import { api } from "@/lib/api"; export function useCredentials() { const { server } = useServer(); - const { data, isLoading } = useQuery({ + const { data, isLoading: credsLoading } = useQuery({ queryKey: ["settings-credentials", server], queryFn: () => api.getCredentials(server!), enabled: !!server, staleTime: 30000, }); + // A Gateway wallet (Solana/Ethereum) unlocks the portfolio just like a CEX key does. + // Errors (e.g. Gateway not running) simply count as "no wallets". + const { data: walletsData, isLoading: walletsLoading } = useQuery({ + queryKey: ["gateway-wallets", server], + queryFn: () => api.getGatewayWallets(server!), + enabled: !!server, + staleTime: 30000, + retry: false, + }); + const credentials = data?.credentials ?? []; - const hasKeys = credentials.length > 0; + const hasWallets = (walletsData?.wallets ?? []).some( + (g) => (g.walletAddresses ?? []).length > 0, + ); + const hasKeys = credentials.length > 0 || hasWallets; + const isLoading = credsLoading || walletsLoading; return { hasKeys, isLoading, credentials }; } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 409922fcb..e446e1c9c 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -752,6 +752,13 @@ export interface CredentialInfo { connector_type: string; } +export interface GatewayWalletGroup { + chain: string; + walletAddresses: string[]; + default_address?: string; + [key: string]: unknown; +} + export interface ConnectorInfo { name: string; type: string; @@ -1690,6 +1697,23 @@ export const api = { getGatewayLogs: (server: string) => apiFetch<{ logs: string }>(`/api/v1/settings/gateway/logs?server=${encodeURIComponent(server)}`), + getGatewayWallets: (server: string) => + apiFetch<{ wallets: GatewayWalletGroup[] }>( + `/api/v1/settings/gateway/wallets?server=${encodeURIComponent(server)}`, + ), + + addGatewayWallet: (server: string, data: { chain: string; private_key: string; set_default?: boolean }) => + apiFetch<{ added: boolean; wallet: { address?: string } }>( + `/api/v1/settings/gateway/wallets?server=${encodeURIComponent(server)}`, + { method: "POST", body: JSON.stringify(data) }, + ), + + removeGatewayWallet: (server: string, chain: string, address: string) => + apiFetch<{ deleted: boolean }>( + `/api/v1/settings/gateway/wallets/${encodeURIComponent(chain)}/${encodeURIComponent(address)}?server=${encodeURIComponent(server)}`, + { method: "DELETE" }, + ), + getCredentials: (server: string) => apiFetch<{ credentials: (CredentialInfo | string)[] }>(`/api/v1/settings/credentials?server=${encodeURIComponent(server)}`), diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 1d0677b0a..da9a97b4d 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -11,7 +11,7 @@ import { useAuth } from "@/lib/auth"; const TABS = [ { key: "servers", label: "Servers" }, { key: "gateway", label: "Gateway" }, - { key: "keys", label: "API Keys" }, + { key: "keys", label: "Keys and Wallets" }, { key: "llm", label: "LLM Endpoints" }, { key: "voice", label: "Voice & AI" }, ] as const; diff --git a/handlers/config/gateway/deployment.py b/handlers/config/gateway/deployment.py index b2f5733a8..cc26d1ca5 100644 --- a/handlers/config/gateway/deployment.py +++ b/handlers/config/gateway/deployment.py @@ -30,20 +30,20 @@ async def start_deploy_gateway(query, context: ContextTypes.DEFAULT_TYPE) -> Non message_text = ( header + "*Select Docker Image:*\n\n" "Choose which Gateway image to deploy\\.\n" - "The latest stable version is recommended\\." + "The development version is recommended\\." ) keyboard = [ [ InlineKeyboardButton( - "hummingbot/gateway:latest (recommended)", - callback_data="gateway_deploy_image_latest", + "hummingbot/gateway:development (recommended)", + callback_data="gateway_deploy_image_development", ) ], [ InlineKeyboardButton( - "hummingbot/gateway:development", - callback_data="gateway_deploy_image_development", + "hummingbot/gateway:latest", + callback_data="gateway_deploy_image_latest", ) ], [ diff --git a/mcp_servers/hummingbot_api/schemas.py b/mcp_servers/hummingbot_api/schemas.py index edcb26aad..21ac9757b 100644 --- a/mcp_servers/hummingbot_api/schemas.py +++ b/mcp_servers/hummingbot_api/schemas.py @@ -220,11 +220,11 @@ class GatewayContainerRequest(BaseModel): "The Hummingbot API runs Gateway secured (TLS + mTLS) and manages the " "certificates/passphrase itself using its own CONFIG_PASSWORD (hummingbot-api " "SEC-048), so no passphrase is needed here. " - "Fields: image (Docker image, default: hummingbot/gateway:latest), " + "Fields: image (Docker image, default: hummingbot/gateway:development), " "port (exposed port, default: 15888).", examples=[ { - "image": "hummingbot/gateway:latest", + "image": "hummingbot/gateway:development", "port": 15888, } ],