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 === "ethereum"
+ ? "Expected a 64-character hex private key (with or without 0x prefix)."
+ : "Expected a base58 private key (no 0x, no brackets)."}
+