From 551977a88c34edd541e1fbc0b0a424b6720fe085 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Mon, 10 Aug 2026 14:03:52 -0700 Subject: [PATCH 1/7] feat(gateway): default Gateway image to hummingbot/gateway:development Meteora DAMM v2 (AMM router) support only ships in the development Gateway image; the published latest image has no /amm routes for meteora, which breaks the meteora_launch_lp agent's manage_amm tool. Update web API defaults, MCP schema docs, and the Telegram deploy menu recommendation to match. Co-Authored-By: Claude Fable 5 --- condor/web/models.py | 4 ++-- handlers/config/gateway/deployment.py | 10 +++++----- mcp_servers/hummingbot_api/schemas.py | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/condor/web/models.py b/condor/web/models.py index 107f5da48..6f8da4a8e 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,7 @@ class CredentialInfo(BaseModel): class GatewayPullRequest(BaseModel): - image: str = "hummingbot/gateway:latest" + image: str = "hummingbot/gateway:development" class AddCredentialRequest(BaseModel): 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, } ], From 551dbbce88012e3a4dc2e4bc2c1434e89b83d4ab Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Mon, 10 Aug 2026 14:26:44 -0700 Subject: [PATCH 2/7] feat(web): Keys and Wallets page with Gateway wallet management Rename the API Keys settings tab to Keys and Wallets, list Gateway wallets (chain, address, default badge, confirm-to-delete), and add an Add Wallet flow with Connect Solana / Connect Ethereum pages. Browser wallets (Phantom, MetaMask, Rabby) never expose private keys to web pages, so the flow guides a manual export + paste; the key is imported into Gateway via the API and stored encrypted there. Ethereum keys are address-previewed client-side with viem before import. Backend: /settings/gateway/wallets GET/POST + DELETE proxying to the API's accounts gateway-wallet endpoints. Co-Authored-By: Claude Fable 5 --- condor/web/models.py | 5 + condor/web/routes/settings.py | 58 +++++ .../components/settings/ApiKeysSettings.tsx | 192 ++++++++++++++++- .../settings/ConnectHyperliquid.tsx | 2 +- .../settings/ImportGatewayWallet.tsx | 198 ++++++++++++++++++ frontend/src/lib/api.ts | 24 +++ frontend/src/pages/Settings.tsx | 2 +- 7 files changed, 471 insertions(+), 10 deletions(-) create mode 100644 frontend/src/components/settings/ImportGatewayWallet.tsx diff --git a/condor/web/models.py b/condor/web/models.py index 6f8da4a8e..84d819141 100644 --- a/condor/web/models.py +++ b/condor/web/models.py @@ -509,6 +509,11 @@ class GatewayPullRequest(BaseModel): image: str = "hummingbot/gateway:development" +class AddGatewayWalletRequest(BaseModel): + chain: str + private_key: str + + class AddCredentialRequest(BaseModel): connector_name: str credentials: dict[str, Any] diff --git a/condor/web/routes/settings.py b/condor/web/routes/settings.py index 63b0cdf2b..402a0b6dc 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,63 @@ 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: + result = await client.accounts.add_gateway_wallet( + chain=req.chain, + private_key=req.private_key, + ) + 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/settings/ApiKeysSettings.tsx b/frontend/src/components/settings/ApiKeysSettings.tsx index 920329054..16a88312a 100644 --- a/frontend/src/components/settings/ApiKeysSettings.tsx +++ b/frontend/src/components/settings/ApiKeysSettings.tsx @@ -6,16 +6,26 @@ 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" + | "select-wallet-chain" + | "import-wallet"; const isHyperliquid = (name: string) => name.startsWith("hyperliquid"); @@ -23,6 +33,7 @@ interface AddFlowState { step: Step; connectorType: string; connectorName: string; + walletChain: WalletChain | ""; fields: Record; values: Record; } @@ -31,10 +42,16 @@ const INITIAL_FLOW: AddFlowState = { step: "list", connectorType: "", connectorName: "", + walletChain: "", fields: {}, values: {}, }; +const WALLET_CHAINS: { chain: WalletChain; label: string; hint: string }[] = [ + { chain: "solana", label: "Connect Solana", hint: "Import a key exported from Phantom" }, + { chain: "ethereum", label: "Connect Ethereum", hint: "Import a key exported from MetaMask or Rabby" }, +]; + // 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 +78,22 @@ 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, + }); + const { data: connectorsData, isLoading: loadingConnectors } = useQuery({ queryKey: ["settings-connectors", server, flow.connectorType], queryFn: () => api.getAvailableConnectors(server!, flow.connectorType || undefined), @@ -110,6 +137,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 +267,54 @@ export function ApiKeysSettings() { ); } + // ── Add wallet flow ── + + if (flow.step === "select-wallet-chain") { + return ( +
+ +

Add Wallet to Gateway

+

+ Gateway holds the wallet key on your server so agents and strategies can sign DEX + transactions. Pick the chain to connect. +

+
+ {WALLET_CHAINS.map(({ chain, label, hint }) => ( + + ))} +
+
+ ); + } + + if (flow.step === "import-wallet" && flow.walletChain) { + return ( + setFlow({ ...INITIAL_FLOW, step: "select-wallet-chain" })} + 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 +463,20 @@ export function ApiKeysSettings() {

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

- +
+ + +
{loadingCreds ? ( @@ -455,6 +546,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..eff422b21 --- /dev/null +++ b/frontend/src/components/settings/ImportGatewayWallet.tsx @@ -0,0 +1,198 @@ +import { AlertCircle, ArrowLeft, Check, Loader2, ShieldAlert } from "lucide-react"; +import { useMemo, useState } from "react"; +import { privateKeyToAccount } from "viem/accounts"; + +import { api } from "@/lib/api"; + +export type WalletChain = "solana" | "ethereum"; + +const CHAIN_LABEL: Record = { + solana: "Solana", + ethereum: "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 [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 }); + 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 ( +
+ + +
+

+ Connect {CHAIN_LABEL[chain]} Wallet +

+

+ 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. +

+
+ +
+

+ 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)."} +

+ )} +
+ +
+ + + Prefer a dedicated trading wallet funded with only what you intend to trade, rather + than your main wallet. + +
+ + {error && ( +
+ + {error} +
+ )} + +
+ + +
+
+ )} +
+ ); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 409922fcb..092aeed9e 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 }) => + 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; From 5fae52925c607eeda28e5fc78a3530890a2dfd7c Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Mon, 10 Aug 2026 15:01:39 -0700 Subject: [PATCH 3/7] feat(web): chain tabs on Add Wallet page; gate Add Wallet on Gateway status Replace the Solana/Ethereum chain-picker cards with a Solana | Ethereum/EVM tab bar (Solana first) directly on the Add Wallet page, and disable the Add Wallet button with a tooltip when Gateway is not running (status shared via the gateway-status query cache). Co-Authored-By: Claude Fable 5 --- .../components/settings/ApiKeysSettings.tsx | 81 +++++++++++-------- .../settings/ImportGatewayWallet.tsx | 26 +----- 2 files changed, 48 insertions(+), 59 deletions(-) diff --git a/frontend/src/components/settings/ApiKeysSettings.tsx b/frontend/src/components/settings/ApiKeysSettings.tsx index 16a88312a..101eab3f9 100644 --- a/frontend/src/components/settings/ApiKeysSettings.tsx +++ b/frontend/src/components/settings/ApiKeysSettings.tsx @@ -24,7 +24,6 @@ type Step = | "select-exchange" | "fill-fields" | "connect-hyperliquid" - | "select-wallet-chain" | "import-wallet"; const isHyperliquid = (name: string) => name.startsWith("hyperliquid"); @@ -47,9 +46,9 @@ const INITIAL_FLOW: AddFlowState = { values: {}, }; -const WALLET_CHAINS: { chain: WalletChain; label: string; hint: string }[] = [ - { chain: "solana", label: "Connect Solana", hint: "Import a key exported from Phantom" }, - { chain: "ethereum", label: "Connect Ethereum", hint: "Import a key exported from MetaMask or Rabby" }, +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. @@ -94,6 +93,15 @@ export function ApiKeysSettings() { 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), @@ -269,7 +277,7 @@ export function ApiKeysSettings() { // ── Add wallet flow ── - if (flow.step === "select-wallet-chain") { + if (flow.step === "import-wallet" && flow.walletChain) { return (
-

Add Wallet to Gateway

-

- Gateway holds the wallet key on your server so agents and strategies can sign DEX - transactions. Pick the chain to connect. -

-
- {WALLET_CHAINS.map(({ chain, label, hint }) => ( +
+

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 }) => ( ))}
-
- ); - } - if (flow.step === "import-wallet" && flow.walletChain) { - return ( - setFlow({ ...INITIAL_FLOW, step: "select-wallet-chain" })} - onDone={() => { - invalidateWallets(); - setFlow(INITIAL_FLOW); - }} - /> + setFlow(INITIAL_FLOW)} + onDone={() => { + invalidateWallets(); + setFlow(INITIAL_FLOW); + }} + /> +
); } @@ -465,8 +476,10 @@ export function ApiKeysSettings() {

diff --git a/frontend/src/components/settings/ImportGatewayWallet.tsx b/frontend/src/components/settings/ImportGatewayWallet.tsx index eff422b21..e9bb786a1 100644 --- a/frontend/src/components/settings/ImportGatewayWallet.tsx +++ b/frontend/src/components/settings/ImportGatewayWallet.tsx @@ -1,4 +1,4 @@ -import { AlertCircle, ArrowLeft, Check, Loader2, ShieldAlert } from "lucide-react"; +import { AlertCircle, Check, Loader2, ShieldAlert } from "lucide-react"; import { useMemo, useState } from "react"; import { privateKeyToAccount } from "viem/accounts"; @@ -6,11 +6,6 @@ import { api } from "@/lib/api"; export type WalletChain = "solana" | "ethereum"; -const CHAIN_LABEL: Record = { - solana: "Solana", - ethereum: "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 = { @@ -84,25 +79,6 @@ export function ImportGatewayWallet({ return (
- - -
-

- Connect {CHAIN_LABEL[chain]} Wallet -

-

- 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. -

-
-

Where to find your private key From 53d1cfc608bee5ee0f3af2a438c9448d9189b13f Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Mon, 10 Aug 2026 16:44:26 -0700 Subject: [PATCH 4/7] feat(web): set-default checkbox on wallet import Every added Gateway wallet silently became the chain default (the API's set_default defaults to true), so a 2nd wallet would steal the default. Add a "Set as default wallet for this chain" checkbox (checked by default). The SDK client doesn't expose set_default, so when unchecked the backend captures the previous default before adding and restores it after. Also drop the dedicated-trading-wallet note. Co-Authored-By: Claude Fable 5 --- condor/web/models.py | 1 + condor/web/routes/settings.py | 16 ++++++++++++ .../settings/ImportGatewayWallet.tsx | 26 ++++++++++++------- frontend/src/lib/api.ts | 2 +- 4 files changed, 35 insertions(+), 10 deletions(-) diff --git a/condor/web/models.py b/condor/web/models.py index 84d819141..89f967a9d 100644 --- a/condor/web/models.py +++ b/condor/web/models.py @@ -512,6 +512,7 @@ class GatewayPullRequest(BaseModel): 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 402a0b6dc..e0387a1b3 100644 --- a/condor/web/routes/settings.py +++ b/condor/web/routes/settings.py @@ -294,10 +294,26 @@ async def add_gateway_wallet( 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)) diff --git a/frontend/src/components/settings/ImportGatewayWallet.tsx b/frontend/src/components/settings/ImportGatewayWallet.tsx index e9bb786a1..e33743477 100644 --- a/frontend/src/components/settings/ImportGatewayWallet.tsx +++ b/frontend/src/components/settings/ImportGatewayWallet.tsx @@ -1,4 +1,4 @@ -import { AlertCircle, Check, Loader2, ShieldAlert } from "lucide-react"; +import { AlertCircle, Check, Loader2 } from "lucide-react"; import { useMemo, useState } from "react"; import { privateKeyToAccount } from "viem/accounts"; @@ -37,6 +37,7 @@ export function ImportGatewayWallet({ 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); @@ -65,7 +66,11 @@ export function ImportGatewayWallet({ try { const privateKey = chain === "ethereum" && !trimmed.startsWith("0x") ? `0x${trimmed}` : trimmed; - const res = await api.addGatewayWallet(server, { chain, private_key: privateKey }); + const res = await api.addGatewayWallet(server, { + chain, + private_key: privateKey, + set_default: setDefault, + }); setImportedAddress(res.wallet?.address ?? derived ?? ""); setKey(""); window.setTimeout(onDone, 1500); @@ -135,13 +140,16 @@ export function ImportGatewayWallet({ )}

-
- - - Prefer a dedicated trading wallet funded with only what you intend to trade, rather - than your main wallet. - -
+ {error && (
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 092aeed9e..e446e1c9c 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1702,7 +1702,7 @@ export const api = { `/api/v1/settings/gateway/wallets?server=${encodeURIComponent(server)}`, ), - addGatewayWallet: (server: string, data: { chain: string; private_key: string }) => + 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) }, From 4781221da32647679fc1c2f719b30e1297d801ea Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Mon, 10 Aug 2026 17:55:13 -0700 Subject: [PATCH 5/7] feat(meteora_launch_lp): fix fee-yield units 100x, add charts/reports, add Fee Decay Harvester strategy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Units fix: the Meteora DAMM v2 API's fee_tvl_ratio is ALREADY a percent per window (verified fees.24h/tvl == fee_tvl_ratio.24h), so the x100 display in both scanners overstated yields 100x. Display raw percent and add a units warning to both strategies (annualize by x365 only). - Visuals: both scanners now emit a plotly chart (PNG to Telegram via send_photo, interactive in a saved ReportBuilder report with KPI cards and table). easya_graduation_monitor renders a candlestick+volume panel of the pool's full post-graduation life via GeckoTerminal OHLCV (Meteora's native OHLCV caps at ~10 bars; kept as fallback with an adaptive timeframe). - New strategy fee_decay_harvester: customization demo on the same agent + routines — enters established verified pools on a dual-window (24h+4h) fee-APR persistence screen instead of fresh graduations, and manages liquidity with tranche-based exits (50% scale-out on APR decay past half, 100% on APR floor, symmetric IL/price deviation, volume collapse, 7-day max hold). Dry-run tick verified: rejected one-candle yield spikes, gated and sized a would-be entry with zero writes. Co-Authored-By: Claude Fable 5 --- .../routines/damm_v2_scanner.py | 100 ++++++++++++- .../routines/easya_graduation_monitor.py | 131 ++++++++++++++++- .../fee_decay_harvester/strategy.md | 136 ++++++++++++++++++ .../strategies/launch_lp_operator/strategy.md | 4 +- 4 files changed, 366 insertions(+), 5 deletions(-) create mode 100644 agents/meteora_launch_lp/strategies/fee_decay_harvester/strategy.md 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/fee_decay_harvester/strategy.md b/agents/meteora_launch_lp/strategies/fee_decay_harvester/strategy.md new file mode 100644 index 000000000..038c54e57 --- /dev/null +++ b/agents/meteora_launch_lp/strategies/fee_decay_harvester/strategy.md @@ -0,0 +1,136 @@ +--- +name: Fee Decay Harvester +description: 'Customized variant of the launch LP loop: enters established, verified + DAMM v2 pools on persistent (dual-window) fee yield instead of fresh graduations, + and manages liquidity with tranche-based exits keyed to fee-APR decay half-life, + IL/price deviation, volume collapse, and max hold time.' +agent_key: null +skills: +- launch_safety_check +default_config: + frequency_sec: 3600 + execution_mode: loop + quote_asset: SOL + total_amount_quote: 0.8 + max_positions: 2 + base_pct: 50 + entry: + min_tvl_usd: 50000 + verified_only: true + min_fee_apr_pct_24h: 40 + min_fee_apr_pct_4h: 25 + min_vol_persistence: 0.5 + max_top10_holder_pct: 50 + min_lock_pct: 50 + require_mint_renounced: true + require_freeze_disabled: true + exit: + apr_decay_scaleout_ratio: 0.5 + apr_floor_pct: 15 + il_price_deviation_pct: 20 + vol_collapse_ratio: 0.3 + max_hold_hours: 168 + risk_limits: + min_wallet_sol_reserve: 0.3 + max_positions: 2 + max_quote_per_position: 0.4 + rpc_url: '' +default_trading_context: '' +created_by: 456181693 +created_at: '2026-08-11T00:31:34.670699+00:00' +--- + +# Fee Decay Harvester + +You are the Meteora Launch LP agent's **established-pool fee-yield strategy** — a customized variant +of `launch_lp_operator`. You do NOT chase fresh graduations. You enter **established, verified DAMM +v2 pools whose fee yield is persistent**, and you manage each position with **tranche-based exits** +keyed to fee-APR decay, IL/price deviation, volume collapse, and max hold. Positions are DAMM v2 NFT +positions via `manage_amm` — never executors, never controllers. + +## HARD TICK BUDGET +~1-hour tick. Aim for ≤ 12 tool calls. Detect with the **`damm_v2_scanner` routine** (two calls, two +windows) — do NOT hand-scan. + +## Constants +`connector` = `meteora` · `network` = `solana-mainnet-beta` · quote = SOL. Full-range AMM: no ranges, +no bins, no rebalancing. + +## Fee-APR units (get this right) +The Meteora API's `fee_tvl_ratio` is **already a percentage per window** (e.g. `24h: 5.22` means +fees over the last 24h were 5.22% of TVL). Annualize by windows-per-year ONLY: +`fee_apr_pct = fee_tvl_ratio.24h × 365` (or `× 2190` for the 4h window). Do NOT multiply by 100. +The `damm_v2_scanner` FeeYield column is the raw window percentage. + +## Each Tick + +### 1. Load state — adopt every live position +`manage_amm(action="positions_owned", …)`; every non-zero-liquidity position is open. Cross-reference +the journal for each position's `entry_fee_apr`, `entry_price`, `entry_vol24h`, `sol_in`, entry time, +and `scaled_out` flag. Adopt any live position missing from the journal (reconstruct best-effort from +`position_info`, mark `entry_fee_apr` unknown → decay exits fall back to the absolute `apr_floor_pct`). + +### 2. Manage open positions — TRANCHE-BASED exit brain (this is the variant's core) +For each open position, read `position_info` + `pool_info`, and the pool's current row from +`damm_v2_scanner` output (fee yield windows, volume). Compute: +- `fee_apr_now` = pool `fee_tvl_ratio.24h` × 365 (as %; see units note above) +- `price_dev_pct` = |price_now − entry_price| / entry_price × 100 (IL proxy — full-range IL is + symmetric, so deviation in EITHER direction counts) +- `vol_persistence_now` = (vol_4h × 6) / entry_vol24h + +Apply in order: +1. **Fee-APR decay scale-out** — if `fee_apr_now < entry_fee_apr × apr_decay_scaleout_ratio` and not + yet `scaled_out`: `remove_liquidity(position_address, percentage_to_remove=50)`, journal + `scaled_out=true`. The yield that justified the position has halved — de-risk half, keep half + earning. +2. **Fee-APR floor** — if `fee_apr_now < apr_floor_pct`: exit 100%. +3. **IL / price deviation** — if `price_dev_pct ≥ il_price_deviation_pct`: exit 100% (fees can no + longer be assumed to outrun IL). +4. **Volume collapse** — if `vol_persistence_now < vol_collapse_ratio`: exit 100% (flow is gone; + fee APR lags volume, act on volume first). +5. **Max hold** — position age ≥ `max_hold_hours`: exit 100%, re-qualify from scratch if still + attractive. + +After any 100% exit, swap the returned base token to SOL (`execute_swap`, side=SELL). Journal every +exit with which trigger fired and realized pnl_pct. + +### 3. Detect — dual-window persistence screen (only if free slots) +Run `damm_v2_scanner` TWICE via `manage_routines(action="run", name="damm_v2_scanner", +strategy_id="meteora_launch_lp.fee_decay_harvester", config={…})`: +- Pass A: `{"ranking_window": "24h", "min_tvl_usd": , "verified_only": true, + "top_n": 10, "exclude_pools": []}` +- Pass B: same but `"ranking_window": "4h"`. + +A candidate qualifies only if it appears in BOTH passes with: +- annualized fee APR ≥ `min_fee_apr_pct_24h` on the 24h window (FeeYield% × 365) AND + ≥ `min_fee_apr_pct_4h` on the 4h window (FeeYield% × 2190) — yield is persistent, not one candle; +- `vol_4h × 6 ≥ min_vol_persistence × vol_24h` (intra-day flow is holding up). +Rank qualifiers by 24h fee APR; take the top one not already held. + +### 4. Gate (stricter than the launch variant) +1. Sellability round-trip: `quote_swap` SELL and BUY both sane (impact ≈ pool fee). +2. `launch_safety_check` routine with `{"pool_address":

, "rpc_url": , + "max_top10_holder_pct": , "min_lock_pct": , + "require_verified": true}` → must be PASS. + +### 5. Open ONE position +Size = min(`total_amount_quote / max_positions`, `max_quote_per_position`), respecting +`min_wallet_sol_reserve`. Acquire base side via `execute_swap` (side=BUY, haircut received ×0.995), +`quote_liquidity`, then `add_liquidity` WITHOUT `position_address` (new NFT). Journal +`position_address`, `sol_in`, `entry_price`, `entry_fee_apr` (annualized 24h yield at entry), +`entry_vol24h`, `scaled_out=false`. `send_notification` with pool + signature. + +### 6. Journal +One `action` entry per tick: open positions with fee_apr_now vs entry, trigger states, any +scale-out/exit, free slots. `learning` only when a trigger's behavior teaches something new. + +## Risk rules +- Never exceed `max_quote_per_position`; keep `min_wallet_sol_reserve` SOL free. +- One position per pool; one open per tick. +- The scale-out (trigger 1) fires at most once per position — never average back in after it. +- On a failed add after a landed swap: retry the add once with the true wallet balance, else swap + back to SOL. Never leave acquired base tokens unmanaged. + +## Error recovery +On any tool failure: journal it, hold, retry next tick. If `damm_v2_scanner` errors, skip detection +this tick (manage existing positions only). 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 From 574b1a251c53007e37831d3f445f17f4fafbe89e Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Mon, 10 Aug 2026 18:37:48 -0700 Subject: [PATCH 6/7] chore(meteora_launch_lp): drop fee_decay_harvester strategy file from repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kept out of the repo intentionally — it is the live-demo artifact for "customize your own strategy on the same agent"; shipping it pre-made would defeat the demo. The dry-run-validated playbook lives in the PR description and the operator's local copy. Co-Authored-By: Claude Fable 5 --- .../fee_decay_harvester/strategy.md | 136 ------------------ 1 file changed, 136 deletions(-) delete mode 100644 agents/meteora_launch_lp/strategies/fee_decay_harvester/strategy.md diff --git a/agents/meteora_launch_lp/strategies/fee_decay_harvester/strategy.md b/agents/meteora_launch_lp/strategies/fee_decay_harvester/strategy.md deleted file mode 100644 index 038c54e57..000000000 --- a/agents/meteora_launch_lp/strategies/fee_decay_harvester/strategy.md +++ /dev/null @@ -1,136 +0,0 @@ ---- -name: Fee Decay Harvester -description: 'Customized variant of the launch LP loop: enters established, verified - DAMM v2 pools on persistent (dual-window) fee yield instead of fresh graduations, - and manages liquidity with tranche-based exits keyed to fee-APR decay half-life, - IL/price deviation, volume collapse, and max hold time.' -agent_key: null -skills: -- launch_safety_check -default_config: - frequency_sec: 3600 - execution_mode: loop - quote_asset: SOL - total_amount_quote: 0.8 - max_positions: 2 - base_pct: 50 - entry: - min_tvl_usd: 50000 - verified_only: true - min_fee_apr_pct_24h: 40 - min_fee_apr_pct_4h: 25 - min_vol_persistence: 0.5 - max_top10_holder_pct: 50 - min_lock_pct: 50 - require_mint_renounced: true - require_freeze_disabled: true - exit: - apr_decay_scaleout_ratio: 0.5 - apr_floor_pct: 15 - il_price_deviation_pct: 20 - vol_collapse_ratio: 0.3 - max_hold_hours: 168 - risk_limits: - min_wallet_sol_reserve: 0.3 - max_positions: 2 - max_quote_per_position: 0.4 - rpc_url: '' -default_trading_context: '' -created_by: 456181693 -created_at: '2026-08-11T00:31:34.670699+00:00' ---- - -# Fee Decay Harvester - -You are the Meteora Launch LP agent's **established-pool fee-yield strategy** — a customized variant -of `launch_lp_operator`. You do NOT chase fresh graduations. You enter **established, verified DAMM -v2 pools whose fee yield is persistent**, and you manage each position with **tranche-based exits** -keyed to fee-APR decay, IL/price deviation, volume collapse, and max hold. Positions are DAMM v2 NFT -positions via `manage_amm` — never executors, never controllers. - -## HARD TICK BUDGET -~1-hour tick. Aim for ≤ 12 tool calls. Detect with the **`damm_v2_scanner` routine** (two calls, two -windows) — do NOT hand-scan. - -## Constants -`connector` = `meteora` · `network` = `solana-mainnet-beta` · quote = SOL. Full-range AMM: no ranges, -no bins, no rebalancing. - -## Fee-APR units (get this right) -The Meteora API's `fee_tvl_ratio` is **already a percentage per window** (e.g. `24h: 5.22` means -fees over the last 24h were 5.22% of TVL). Annualize by windows-per-year ONLY: -`fee_apr_pct = fee_tvl_ratio.24h × 365` (or `× 2190` for the 4h window). Do NOT multiply by 100. -The `damm_v2_scanner` FeeYield column is the raw window percentage. - -## Each Tick - -### 1. Load state — adopt every live position -`manage_amm(action="positions_owned", …)`; every non-zero-liquidity position is open. Cross-reference -the journal for each position's `entry_fee_apr`, `entry_price`, `entry_vol24h`, `sol_in`, entry time, -and `scaled_out` flag. Adopt any live position missing from the journal (reconstruct best-effort from -`position_info`, mark `entry_fee_apr` unknown → decay exits fall back to the absolute `apr_floor_pct`). - -### 2. Manage open positions — TRANCHE-BASED exit brain (this is the variant's core) -For each open position, read `position_info` + `pool_info`, and the pool's current row from -`damm_v2_scanner` output (fee yield windows, volume). Compute: -- `fee_apr_now` = pool `fee_tvl_ratio.24h` × 365 (as %; see units note above) -- `price_dev_pct` = |price_now − entry_price| / entry_price × 100 (IL proxy — full-range IL is - symmetric, so deviation in EITHER direction counts) -- `vol_persistence_now` = (vol_4h × 6) / entry_vol24h - -Apply in order: -1. **Fee-APR decay scale-out** — if `fee_apr_now < entry_fee_apr × apr_decay_scaleout_ratio` and not - yet `scaled_out`: `remove_liquidity(position_address, percentage_to_remove=50)`, journal - `scaled_out=true`. The yield that justified the position has halved — de-risk half, keep half - earning. -2. **Fee-APR floor** — if `fee_apr_now < apr_floor_pct`: exit 100%. -3. **IL / price deviation** — if `price_dev_pct ≥ il_price_deviation_pct`: exit 100% (fees can no - longer be assumed to outrun IL). -4. **Volume collapse** — if `vol_persistence_now < vol_collapse_ratio`: exit 100% (flow is gone; - fee APR lags volume, act on volume first). -5. **Max hold** — position age ≥ `max_hold_hours`: exit 100%, re-qualify from scratch if still - attractive. - -After any 100% exit, swap the returned base token to SOL (`execute_swap`, side=SELL). Journal every -exit with which trigger fired and realized pnl_pct. - -### 3. Detect — dual-window persistence screen (only if free slots) -Run `damm_v2_scanner` TWICE via `manage_routines(action="run", name="damm_v2_scanner", -strategy_id="meteora_launch_lp.fee_decay_harvester", config={…})`: -- Pass A: `{"ranking_window": "24h", "min_tvl_usd": , "verified_only": true, - "top_n": 10, "exclude_pools": []}` -- Pass B: same but `"ranking_window": "4h"`. - -A candidate qualifies only if it appears in BOTH passes with: -- annualized fee APR ≥ `min_fee_apr_pct_24h` on the 24h window (FeeYield% × 365) AND - ≥ `min_fee_apr_pct_4h` on the 4h window (FeeYield% × 2190) — yield is persistent, not one candle; -- `vol_4h × 6 ≥ min_vol_persistence × vol_24h` (intra-day flow is holding up). -Rank qualifiers by 24h fee APR; take the top one not already held. - -### 4. Gate (stricter than the launch variant) -1. Sellability round-trip: `quote_swap` SELL and BUY both sane (impact ≈ pool fee). -2. `launch_safety_check` routine with `{"pool_address":

, "rpc_url": , - "max_top10_holder_pct": , "min_lock_pct": , - "require_verified": true}` → must be PASS. - -### 5. Open ONE position -Size = min(`total_amount_quote / max_positions`, `max_quote_per_position`), respecting -`min_wallet_sol_reserve`. Acquire base side via `execute_swap` (side=BUY, haircut received ×0.995), -`quote_liquidity`, then `add_liquidity` WITHOUT `position_address` (new NFT). Journal -`position_address`, `sol_in`, `entry_price`, `entry_fee_apr` (annualized 24h yield at entry), -`entry_vol24h`, `scaled_out=false`. `send_notification` with pool + signature. - -### 6. Journal -One `action` entry per tick: open positions with fee_apr_now vs entry, trigger states, any -scale-out/exit, free slots. `learning` only when a trigger's behavior teaches something new. - -## Risk rules -- Never exceed `max_quote_per_position`; keep `min_wallet_sol_reserve` SOL free. -- One position per pool; one open per tick. -- The scale-out (trigger 1) fires at most once per position — never average back in after it. -- On a failed add after a landed swap: retry the add once with the true wallet balance, else swap - back to SOL. Never leave acquired base tokens unmanaged. - -## Error recovery -On any tool failure: journal it, hold, retry next tick. If `damm_v2_scanner` errors, skip detection -this tick (manage existing positions only). From 70e0f455846cc72872dfebfee4a5cc32468718af Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Mon, 10 Aug 2026 21:05:02 -0700 Subject: [PATCH 7/7] fix(web): count Gateway wallets toward the connect-keys gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With only a Solana/Ethereum Gateway wallet added (no CEX credentials), the portfolio page still showed the Connect Keys overlay — useCredentials gated on exchange credentials alone. A Gateway wallet now unlocks the app the same way a CEX key does (Gateway-unreachable errors count as no wallets). Overlay copy updated to mention wallets. Co-Authored-By: Claude Fable 5 --- frontend/src/components/ConnectKeysOverlay.tsx | 7 ++++--- frontend/src/hooks/useCredentials.ts | 18 ++++++++++++++++-- 2 files changed, 20 insertions(+), 5 deletions(-) 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/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 }; }