diff --git a/agents/condor/AGENT.md b/agents/condor/AGENT.md index 93d6b0e2..37d050a5 100644 --- a/agents/condor/AGENT.md +++ b/agents/condor/AGENT.md @@ -18,6 +18,7 @@ You are Condor, a trading assistant. Do NOT explore the codebase — use MCP too - `manage_bots` — start/stop/monitor bots - `manage_controllers` — controller configs - `explore_dex_pools` / `explore_geckoterminal` — DEX discovery +- `manage_amm` — direct AMM liquidity & pool creation (Meteora DAMM v2 / Raydium CPMM / Uniswap V2) - `search_history` — historical trades and executor data - `set_account_position_mode_and_leverage` — futures config diff --git a/agents/meteora_launch_lp/AGENT.md b/agents/meteora_launch_lp/AGENT.md new file mode 100644 index 00000000..3ddc68d6 --- /dev/null +++ b/agents/meteora_launch_lp/AGENT.md @@ -0,0 +1,100 @@ +--- +name: Meteora Launch LP +description: Early-liquidity specialist for tokens graduating from launchpads into + Meteora DAMM v2 — detects fresh graduations (EasyA), gates them on safety and real + post-graduation demand, provides early two-sided liquidity, and exits on decay/risk + triggers. Also harvests fee yield on established DAMM v2 pools between graduations. +agent_key: claude-acp:sonnet +tools: +- manage_amm +- explore_dex_pools +- get_portfolio_overview +- get_market_data +- send_notification +- manage_memory +- manage_skill +when_to_consult: When the user asks whether a freshly-graduated Meteora DAMM v2 pool + is worth LPing, how to size/enter an early position, which established DAMM v2 pool + has the best fee yield, or whether an open AMM position should hold or exit — use + consult. To run the launch-LP loop autonomously (detect graduations → gate → early + add → monitor → exit), use delegate or launch its loop strategy. +server_required: true +server_name: local +created_by: 0 +created_at: '2026-08-05T00:00:00+00:00' +--- + +# Meteora Launch LP + +You provide **early liquidity to tokens graduating from launchpads into Meteora DAMM v2**, and +harvest **fee yield on established DAMM v2 pools** between graduations. You act **directly** through +`manage_amm` — there is no AMM executor, so you are stateless and **your journal is the source of +truth** for every position you hold (record each `position_address`). + +Scope is **AMM only**. Router/one-shot swaps → `manage_executors(order_executor)`. CLMM/DLMM LP → +`manage_executors(lp_executor)` / the Solana DEX LP agent. Never reach for those here. + +## First move +Call `manage_amm()` with **no action** to load the AMM guide, action list, param matrix, and +networks. Re-read it whenever unsure — it is authoritative over this file. + +## The edge: ride the intended graduation venue, don't fight it +Your differentiator is being an **early LP on the pool a token is *meant* to land in** — not chasing +established pools everyone can join, and not fragmenting liquidity by spinning up a competing pool. +- **EasyA → Meteora DAMM v2 ✓** — the graduation pool *is* a DAMM v2 pool. Early liquidity there + rides the canonical flow. **This is the target category.** + +Only target launchpads whose intended graduation venue **is** Meteora DAMM v2 — spinning up a DAMM v2 +pool for a token that graduates elsewhere just fragments liquidity and gets arb-adverse-selected. + +Meteora **auto-creates and seeds** the DAMM v2 pool at graduation, so you are an early **adder**, not +the pool creator — use `add_liquidity` (omit `position_address` → open a NEW position), not +`create_pool`. (`create_pool` is for a separate origination play: a token with real cross-venue +demand but no DAMM v2 pool at all.) + +## Detecting EasyA graduations (no launchpad API needed) +EasyA-graduated tokens carry a **vanity mint suffix `EASY`** (e.g. `…BzCcEaEASY`) and land in a +**SOL-quoted, 2% static-fee** DAMM v2 pool (no fee scheduler). So graduations are discoverable +directly off the Meteora DAMM v2 data API — run the **`easya_graduation_monitor`** routine, which +filters the pool feed to `*EASY` base mints, recent `created_at`, and a TVL/volume floor, and ranks +by freshness + traction. (For general fee-yield harvesting, use **`damm_v2_scanner`**.) + +## Autonomous loop +Run the **`launch_lp_operator`** strategy to execute this end-to-end each tick. It uses three +agent-local routines — **`easya_graduation_monitor`** (detect), **`launch_safety_check`** (gate), +**`damm_v2_scanner`** (fee-yield harvest) — and the **`launch_safety_check`** skill. + +## Per-tick loop (delegated / loop mode) +1. **Detect** — run `easya_graduation_monitor` for fresh graduations; `get_portfolio_overview` for SOL. +2. **Gate** — run the **`launch_safety_check` skill**: sellability (honeypot) round-trip via + `manage_amm(quote_swap)` both directions, the **`launch_safety_check` routine** (mint/freeze + authority, holder concentration, LP lock, TVL — objective on-chain checks), and real + post-graduation demand (skip the graduation candle; wait for the dump to clear). Reject on ANY fail. +3. **Size & enter** — early LP is **directional long the token** (you must BUY the base token to pair + it with SOL, and your LP base side is that token). So size **small and capped**, quote first + (`quote_liquidity`), then `add_liquidity` (omit `position_address` → new position). Journal the + returned `position_address`. +4. **Monitor & exit** — full-range AMM has no range TP/SL; exit via `remove_liquidity(position_address, + percentage_to_remove=100)` when ANY fires: **fee-APR decay** below target, **IL / price drawdown** + beyond your limit, **volume collapse**, or **max-hold-time**. `send_notification` on entry and exit + with the `signature`. + +## Meteora DAMM v2 position model (get this right) +Positions are **NFTs** — a wallet may hold several per pool, each independently addressable. +- `position_info` returns the pool aggregate **+ a `positions[]` breakdown** (each with a + `position_address`); `positions_owned` lists all your positions across pools. +- `add_liquidity` **without** `position_address` opens a **NEW** position; **with** it, adds to that one. +- `remove_liquidity` **requires** `position_address`; the percentage applies to *that* position, so + "remove 100%" truly exits the named position. **Journal every `position_address` you open.** + +## Fee-yield harvesting (between graduations) +When no graduation clears the gates, deploy idle quote capital into established DAMM v2 pools ranked +by `damm_v2_scanner` (liquid, verified, high `fee_tvl_ratio`, static fee), same monitor/exit brain. + +## Discipline +- **Quote before every write.** Size from `quote_liquidity` / `quote_swap`, never guesses. +- **Never LP at the graduation candle.** The pump-graduate-dump pattern makes early full-range LP eat + the drawdown; wait for stabilization and real two-sided flow. +- **Small, capped size** per launch — most launch tokens go to zero; fee income must outrun IL + dump. +- On any doubt about which position to touch, re-read `positions_owned` + your journal before acting — + a mistaken NFT removal is not undone. diff --git a/agents/meteora_launch_lp/routines/damm_v2_scanner.py b/agents/meteora_launch_lp/routines/damm_v2_scanner.py new file mode 100644 index 00000000..52ba7d53 --- /dev/null +++ b/agents/meteora_launch_lp/routines/damm_v2_scanner.py @@ -0,0 +1,159 @@ +"""Scan & rank Meteora DAMM v2 (AMM) pools by fee yield for LP entry. + +Agent-local routine for meteora_launch_lp. Sources pools directly from the **Meteora DAMM v2 data API** +(https://damm-v2.datapi.meteora.ag/pools) — NOT GeckoTerminal, which does not cover DAMM v2 AMM +pools well — filters to the configured quote asset, drops launch-fee-scheduler and unverified pools +by default, ranks by the API's native `fee_tvl_ratio` (fees(window)/TVL), and returns a shortlist +with the exact fields to act via `manage_amm` (connector=meteora, network=solana-mainnet-beta, +pool_address, base/quote mints). + +Related DAMM v2 endpoints for deeper analysis (not used here, available to the agent): +- OHLCV: https://docs.meteora.ag/api-reference/damm-v2/pools/ohlcv +- Historical volume: https://docs.meteora.ag/api-reference/damm-v2/pools/historical-volume + +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 logging + +import aiohttp +from pydantic import BaseModel, Field +from telegram.ext import ContextTypes + +logger = logging.getLogger(__name__) + +CATEGORY = "Analysis" + +DAMM_V2_API = "https://damm-v2.datapi.meteora.ag/pools" +CONNECTOR = "meteora" +NETWORK = "solana-mainnet-beta" # hummingbot-api / Gateway network id for manage_amm + +_QUOTE_MINTS = { + "SOL": "So11111111111111111111111111111111111111112", + "USDC": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "USDT": "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", +} +_WINDOWS = {"1h", "2h", "4h", "12h", "24h"} + + +class Config(BaseModel): + """Rank Meteora DAMM v2 pools by fee yield (fees/TVL) for AMM LP entry.""" + + quote_asset: str = Field(default="SOL", description="Quote token to require on one side: SOL, USDC, or USDT") + query: str | None = Field(default=None, description="Optional search (token symbol/name/address), e.g. 'JUP'") + ranking_window: str = Field(default="24h", description="Fee-yield window: 1h, 2h, 4h, 12h, or 24h") + top_n: int = Field(default=10, description="Number of ranked pools to return") + min_tvl_usd: float = Field(default=25000.0, description="Minimum pool TVL in USD") + include_launch_pools: bool = Field(default=False, description="Include active-fee-scheduler (launch) pools") + verified_only: bool = Field(default=True, description="Require both tokens verified") + exclude_pools: list[str] = Field(default=[], description="Pool addresses to exclude (already held)") + + +async def run(config: Config, context: ContextTypes.DEFAULT_TYPE) -> str: + quote = config.quote_asset.upper() + if quote not in _QUOTE_MINTS: + return f"damm_v2_scanner: unsupported quote_asset '{quote}' — supported: {', '.join(_QUOTE_MINTS)}." + quote_mint = _QUOTE_MINTS[quote] + + window = config.ranking_window if config.ranking_window in _WINDOWS else "24h" + + # Fetch by TVL (surfaces REAL liquid pools) then rank by fee yield locally. Sorting the API by + # fee_tvl_ratio instead floods the page with near-zero-TVL junk pools whose ratio is astronomical. + params = { + "page": 1, + "page_size": 300, + "sort_by": "tvl:desc", + "filter_by": "is_blacklisted=false", + } + if config.query: + params["query"] = config.query + + try: + async with aiohttp.ClientSession() as session: + async with session.get(DAMM_V2_API, params=params, timeout=aiohttp.ClientTimeout(total=20)) as resp: + if resp.status != 200: + return f"damm_v2_scanner: Meteora DAMM v2 API returned HTTP {resp.status}" + payload = await resp.json() + except Exception as e: + return f"damm_v2_scanner: failed to reach Meteora DAMM v2 API: {e}" + + pools = payload.get("data", []) + excl = set(config.exclude_pools) + candidates = [] + + for p in pools: + tx, ty = p.get("token_x", {}), p.get("token_y", {}) + mints = {tx.get("address"), ty.get("address")} + if quote_mint not in mints: + continue # quote asset must be one side + if p.get("address") in excl: + continue + if float(p.get("tvl") or 0) < config.min_tvl_usd: + continue + cfg = p.get("pool_config", {}) or {} + if not config.include_launch_pools and cfg.get("is_fee_scheduler_active"): + continue # skip launch pools whose base fee starts ~99% + if config.verified_only and not (tx.get("is_verified") and ty.get("is_verified")): + continue + + # Base = the side that is NOT the quote asset. + base, quote_tok = (ty, tx) if tx.get("address") == quote_mint else (tx, ty) + fee_yield = float((p.get("fee_tvl_ratio") or {}).get(window) or 0) + candidates.append({ + "pool": p.get("address"), + "pair": p.get("name") or f"{base.get('symbol','?')}-{quote_tok.get('symbol','?')}", + "base_symbol": base.get("symbol") or base.get("address", "")[:6], + "quote_symbol": quote_tok.get("symbol") or quote, + "base_mint": base.get("address"), + "quote_mint": quote_tok.get("address"), + "base_fee_pct": float(cfg.get("base_fee_pct") or 0), + "tvl": float(p.get("tvl") or 0), + "vol_win": float((p.get("volume") or {}).get(window) or 0), + "fee_yield": fee_yield, + "price": float(p.get("current_price") or 0), + }) + + if not candidates: + return (f"damm_v2_scanner: no {quote}-quoted DAMM v2 pools passed the filters " + f"(min TVL ${config.min_tvl_usd:,.0f}, verified_only={config.verified_only}, " + f"launch_pools={config.include_launch_pools}).") + + candidates.sort(key=lambda c: c["fee_yield"], reverse=True) + ranked = candidates[: config.top_n] + + columns = ["#", "Pair", "FeeYield", "BaseFee", "TVL", f"Vol{window}", "Price", "Pool", "BaseMint"] + rows = [] + for i, c in enumerate(ranked, 1): + rows.append({ + "#": i, + "Pair": c["pair"], + "FeeYield": f"{c['fee_yield'] * 100:.3f}%", + "BaseFee": f"{c['base_fee_pct']:.3f}%", + "TVL": f"${c['tvl']:,.0f}", + f"Vol{window}": f"${c['vol_win']:,.0f}", + "Price": f"{c['price']:.4g}", + "Pool": c["pool"], + "BaseMint": c["base_mint"], + # manage_amm hints (constant across rows): connector=meteora, network=solana-mainnet-beta. + "quote_mint": c["quote_mint"], + }) + + top = rows[0] + summary = ( + f"Ranked {len(ranked)} {quote}-quoted Meteora DAMM v2 pools by fee yield ({window}) " + f"from {len(candidates)} candidates. Top: **{top['Pair']}** — yield {top['FeeYield']}, " + f"base fee {top['BaseFee']}, TVL {top['TVL']}, pool `{top['Pool']}`.\n" + f"Act via manage_amm(connector='meteora', network='{NETWORK}', pool_address=, " + f"base_token=, quote_token=). Launch (fee-scheduler) pools " + f"{'included' if config.include_launch_pools else 'excluded'}." + ) + + try: + from routines.base import RoutineResult + return RoutineResult(text=summary, table_data=rows, table_columns=columns) + except Exception: + lines = [summary, ""] + for r in rows: + lines.append(f"{r['#']}. {r['Pair']} | yield {r['FeeYield']} | fee {r['BaseFee']} | " + f"TVL {r['TVL']} | pool {r['Pool']} | base {r['BaseMint']}") + return "\n".join(lines) diff --git a/agents/meteora_launch_lp/routines/easya_graduation_monitor.py b/agents/meteora_launch_lp/routines/easya_graduation_monitor.py new file mode 100644 index 00000000..1fb19615 --- /dev/null +++ b/agents/meteora_launch_lp/routines/easya_graduation_monitor.py @@ -0,0 +1,150 @@ +"""Detect fresh EasyA launchpad graduations into Meteora DAMM v2, for early-LP entry. + +Agent-local routine for meteora_launch_lp. EasyA-graduated tokens carry a vanity mint suffix +**`EASY`** and land in a **SOL-quoted, 2% static-fee** DAMM v2 pool (no fee scheduler), so graduations +are discoverable directly off the Meteora DAMM v2 data API (https://damm-v2.datapi.meteora.ag/pools) +— NO EasyA API needed. This routine filters the pool feed to `*EASY` base mints, recent `created_at`, +and TVL/volume floors, then ranks by 24h fee yield (traction) so the agent LPs the ones actually +earning rather than every graduation. + +It does NOT decide entry — the agent still gates each candidate on sellability (honeypot), real +post-graduation demand, and quality, and must wait for the initial dump to clear before providing +liquidity. See AGENT.md. + +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 logging +import time + +import aiohttp +from pydantic import BaseModel, Field +from telegram.ext import ContextTypes + +logger = logging.getLogger(__name__) + +CATEGORY = "Analysis" + +DAMM_V2_API = "https://damm-v2.datapi.meteora.ag/pools" +CONNECTOR = "meteora" +NETWORK = "solana-mainnet-beta" # hummingbot-api / Gateway network id for manage_amm +SOL_MINT = "So11111111111111111111111111111111111111112" +EASYA_MINT_SUFFIX = "EASY" # EasyA vanity-suffix marker on graduated token mints + + +class Config(BaseModel): + """Detect fresh EasyA graduations into Meteora DAMM v2, ranked by fee yield.""" + + max_age_hours: float = Field(default=72.0, description="Only pools created within this many hours") + min_tvl_usd: float = Field(default=10000.0, description="Minimum pool TVL in USD (graduation liquidity floor)") + min_vol24h_usd: float = Field(default=3000.0, description="Minimum 24h volume in USD (real post-grad demand)") + verified_only: bool = Field(default=False, description="Require the graduated token to be verified") + require_static_fee: bool = Field(default=True, description="Exclude fee-scheduler pools (EasyA grads are static)") + top_n: int = Field(default=15, description="Number of ranked graduations to return") + + +async def run(config: Config, context: ContextTypes.DEFAULT_TYPE) -> str: + # No created_at sort on the API, so fetch a wide TVL-sorted page and filter locally. + # EasyA pools graduate with ~$10k+ TVL, so they are within the top page by TVL. + params = {"page": 1, "page_size": 1000, "sort_by": "tvl:desc", "filter_by": "is_blacklisted=false"} + + try: + async with aiohttp.ClientSession() as session: + async with session.get(DAMM_V2_API, params=params, timeout=aiohttp.ClientTimeout(total=25)) as resp: + if resp.status != 200: + return f"easya_graduation_monitor: Meteora DAMM v2 API returned HTTP {resp.status}" + payload = await resp.json() + except Exception as e: + return f"easya_graduation_monitor: failed to reach Meteora DAMM v2 API: {e}" + + now_ms = time.time() * 1000.0 + max_age_ms = config.max_age_hours * 3.6e6 + candidates = [] + + for p in payload.get("data", []): + tx, ty = p.get("token_x", {}), p.get("token_y", {}) + # Identify the EasyA token side (mint suffix EASY, not SOL); quote must be SOL. + easy_side = None + for side in (tx, ty): + addr = side.get("address", "") + if addr != SOL_MINT and addr.upper().endswith(EASYA_MINT_SUFFIX): + easy_side = side + if easy_side is None: + continue + mints = {tx.get("address"), ty.get("address")} + if SOL_MINT not in mints: + continue # EasyA graduations are SOL-quoted + + created = p.get("created_at") + age_h = (now_ms - created) / 3.6e6 if created else None + if age_h is None or (now_ms - created) > max_age_ms: + continue + if float(p.get("tvl") or 0) < config.min_tvl_usd: + continue + if float((p.get("volume") or {}).get("24h") or 0) < config.min_vol24h_usd: + continue + cfg = p.get("pool_config", {}) or {} + if config.require_static_fee and (cfg.get("is_fee_scheduler_active") or cfg.get("has_fee_scheduler")): + continue + if config.verified_only and not easy_side.get("is_verified"): + continue + + candidates.append({ + "pool": p.get("address"), + "pair": p.get("name") or f"{easy_side.get('symbol','?')}-SOL", + "token_symbol": easy_side.get("symbol") or easy_side.get("address", "")[:6], + "base_mint": easy_side.get("address"), + "verified": bool(easy_side.get("is_verified")), + "base_fee_pct": float(cfg.get("base_fee_pct") or 0), + "tvl": float(p.get("tvl") or 0), + "vol24h": float((p.get("volume") or {}).get("24h") or 0), + "fee_yield24h": float((p.get("fee_tvl_ratio") or {}).get("24h") or 0), + "age_h": age_h, + "price": float(p.get("current_price") or 0), + }) + + if not candidates: + return (f"easya_graduation_monitor: no EasyA graduations in the last {config.max_age_hours:.0f}h " + f"passed the filters (min TVL ${config.min_tvl_usd:,.0f}, min vol24h ${config.min_vol24h_usd:,.0f}, " + f"verified_only={config.verified_only}).") + + # Rank by traction: 24h fee yield (fees/TVL) — the ones actually earning. + candidates.sort(key=lambda c: c["fee_yield24h"], reverse=True) + ranked = candidates[: config.top_n] + + columns = ["#", "Pair", "Age(h)", "FeeYield", "TVL", "Vol24h", "Verified", "Price", "Pool", "BaseMint"] + rows = [] + for i, c in enumerate(ranked, 1): + rows.append({ + "#": i, + "Pair": c["pair"], + "Age(h)": f"{c['age_h']:.1f}", + "FeeYield": f"{c['fee_yield24h'] * 100:.1f}%", + "TVL": f"${c['tvl']:,.0f}", + "Vol24h": f"${c['vol24h']:,.0f}", + "Verified": "yes" if c["verified"] else "no", + "Price": f"{c['price']:.4g}", + "Pool": c["pool"], + "BaseMint": c["base_mint"], + "quote_mint": SOL_MINT, + }) + + top = rows[0] + summary = ( + f"Found {len(ranked)} EasyA→DAMM v2 graduations (< {config.max_age_hours:.0f}h) passing filters, " + f"from the pool feed. Top by fee yield: **{top['Pair']}** — yield {top['FeeYield']}, " + f"age {top['Age(h)']}h, TVL {top['TVL']}, verified {top['Verified']}, pool `{top['Pool']}`.\n" + f"GATE each before LPing: sellability (SELL+BUY quote both return), real post-dump demand, quality. " + f"Then early add_liquidity(connector='meteora', network='{NETWORK}', pool_address=) — small, " + f"capped size (directional-long the token). Journal the position_address." + ) + + try: + from routines.base import RoutineResult + return RoutineResult(text=summary, table_data=rows, table_columns=columns) + except Exception: + lines = [summary, ""] + for r in rows: + lines.append(f"{r['#']}. {r['Pair']} | age {r['Age(h)']}h | yield {r['FeeYield']} | " + f"TVL {r['TVL']} | vol {r['Vol24h']} | verified {r['Verified']} | pool {r['Pool']}") + return "\n".join(lines) diff --git a/agents/meteora_launch_lp/routines/launch_safety_check.py b/agents/meteora_launch_lp/routines/launch_safety_check.py new file mode 100644 index 00000000..d7f27d7d --- /dev/null +++ b/agents/meteora_launch_lp/routines/launch_safety_check.py @@ -0,0 +1,126 @@ +"""Objective safety gates for a freshly-graduated Meteora DAMM v2 token, as executable checks. + +Agent-local routine for meteora_launch_lp. Given a pool address, programmatically evaluates the +gates the agent cannot do through its tools: **mint-authority renounced** and **top-holder +concentration** (Solana RPC), plus **freeze-authority disabled**, **verified**, **LP lock**, and +**TVL** (from the Meteora DAMM v2 data API). Returns a PASS/FAIL verdict with per-gate reasons. + +It does NOT check sellability (honeypot) — that needs a live two-way quote through the pool, which +the agent does itself with manage_amm(quote_swap, side=SELL) + (side=BUY). See the launch_safety_check +SKILL. This routine covers the deterministic on-chain/static gates only. + +RPC: defaults to the public mainnet endpoint (rate-limited but fine for occasional checks); override +with rpc_url for a private endpoint. No credentials are hardcoded. +""" +import logging + +import aiohttp +from pydantic import BaseModel, Field, field_validator +from telegram.ext import ContextTypes + +logger = logging.getLogger(__name__) + +CATEGORY = "Analysis" + +DAMM_V2_API = "https://damm-v2.datapi.meteora.ag/pools" +DEFAULT_RPC = "https://api.mainnet-beta.solana.com" +SOL_MINT = "So11111111111111111111111111111111111111112" + + +class Config(BaseModel): + """Objective safety gates for a graduated Meteora DAMM v2 token.""" + + pool_address: str = Field(description="DAMM v2 pool address to vet (required)") + rpc_url: str = Field(default=DEFAULT_RPC, description="Solana RPC endpoint") + + @field_validator("rpc_url", mode="before") + @classmethod + def _rpc_url_default(cls, v): + # Callers (e.g. strategy configs) may pass an empty string meaning "no private RPC" — + # fall back to the public endpoint instead of failing every RPC gate on an invalid URL. + return v or DEFAULT_RPC + max_top10_holder_pct: float = Field(default=60.0, description="Fail if top-10 holders exceed this % of supply") + require_mint_renounced: bool = Field(default=True, description="Fail if the token mint authority is not renounced") + require_freeze_disabled: bool = Field(default=True, description="Fail if the freeze authority is not disabled") + require_verified: bool = Field(default=False, description="Fail if the token is not verified") + min_lock_pct: float = Field(default=0.0, description="Require locked+vested liquidity ≥ this % of TVL (0 = off)") + min_tvl_usd: float = Field(default=10000.0, description="Fail below this TVL") + + +async def _rpc(session, url, method, params): + async with session.post(url, json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params}, + timeout=aiohttp.ClientTimeout(total=20)) as r: + if r.status != 200: + raise RuntimeError(f"RPC {method} -> HTTP {r.status}") + data = await r.json() + if "error" in data: + raise RuntimeError(f"RPC {method} error: {data['error']}") + return data.get("result") + + +async def run(config: Config, context: ContextTypes.DEFAULT_TYPE) -> str: + gates: list[tuple[str, bool, str]] = [] # (name, passed, detail) + + # 1. Pool + token metadata from the Meteora DAMM v2 API. + try: + async with aiohttp.ClientSession() as session: + async with session.get(DAMM_V2_API, params={"query": config.pool_address, "page_size": 1}, + timeout=aiohttp.ClientTimeout(total=20)) as resp: + if resp.status != 200: + return f"launch_safety_check: Meteora API HTTP {resp.status}" + pool = (await resp.json()).get("data", [{}])[0] + except Exception as e: + return f"launch_safety_check: failed to fetch pool {config.pool_address}: {e}" + + if not pool or pool.get("address") != config.pool_address: + return f"launch_safety_check: pool {config.pool_address} not found in Meteora DAMM v2 feed." + + tx, ty = pool.get("token_x", {}), pool.get("token_y", {}) + base = ty if tx.get("address") == SOL_MINT else tx # the non-SOL side + base_mint = base.get("address") + tvl = float(pool.get("tvl") or 0) + cfg = pool.get("pool_config", {}) or {} + + # Static gates from the API. + gates.append(("tvl", tvl >= config.min_tvl_usd, f"${tvl:,.0f} (min ${config.min_tvl_usd:,.0f})")) + if config.require_verified: + gates.append(("verified", bool(base.get("is_verified")), f"{base.get('symbol')} verified={base.get('is_verified')}")) + if config.require_freeze_disabled: + gates.append(("freeze_disabled", bool(base.get("freeze_authority_disabled")), + f"freeze_authority_disabled={base.get('freeze_authority_disabled')}")) + if config.min_lock_pct > 0: + locked = float(pool.get("permanent_lock_liquidity") or 0) + vested = sum(float(v or 0) for v in (pool.get("vested_liquidity") or {}).values()) + lock_pct = (locked + vested) / tvl * 100 if tvl else 0.0 + gates.append(("lp_lock", lock_pct >= config.min_lock_pct, f"{lock_pct:.1f}% locked/vested (min {config.min_lock_pct}%)")) + + # 2. On-chain gates via RPC: mint authority + top-holder concentration. + try: + async with aiohttp.ClientSession() as session: + if config.require_mint_renounced: + info = await _rpc(session, config.rpc_url, "getAccountInfo", + [base_mint, {"encoding": "jsonParsed"}]) + parsed = ((info or {}).get("value") or {}).get("data", {}).get("parsed", {}).get("info", {}) + mint_auth = parsed.get("mintAuthority") + gates.append(("mint_renounced", mint_auth is None, f"mintAuthority={mint_auth or 'null (renounced)'}")) + + supply_res = await _rpc(session, config.rpc_url, "getTokenSupply", [base_mint]) + total = float((supply_res or {}).get("value", {}).get("uiAmount") or 0) + largest = await _rpc(session, config.rpc_url, "getTokenLargestAccounts", [base_mint]) + accounts = (largest or {}).get("value", []) + top10 = sum(float(a.get("uiAmount") or 0) for a in accounts[:10]) + top10_pct = (top10 / total * 100) if total else 100.0 + gates.append(("holder_concentration", top10_pct <= config.max_top10_holder_pct, + f"top-10 hold {top10_pct:.1f}% (max {config.max_top10_holder_pct}%)")) + except Exception as e: + gates.append(("rpc_checks", False, f"RPC checks failed: {e}")) + + passed_all = all(ok for _, ok, _ in gates) + lines = [f"Safety check for {base.get('symbol','?')} ({base_mint}) — pool {config.pool_address}", + f"VERDICT: {'PASS' if passed_all else 'FAIL'}", ""] + for name, ok, detail in gates: + lines.append(f" [{'PASS' if ok else 'FAIL'}] {name}: {detail}") + lines.append("") + lines.append("NOTE: sellability (honeypot) is NOT checked here — round-trip a SELL and BUY quote via " + "manage_amm(quote_swap) before entering. See the launch_safety_check skill.") + return "\n".join(lines) diff --git a/agents/meteora_launch_lp/skills/launch_safety_check/SKILL.md b/agents/meteora_launch_lp/skills/launch_safety_check/SKILL.md new file mode 100644 index 00000000..1161183d --- /dev/null +++ b/agents/meteora_launch_lp/skills/launch_safety_check/SKILL.md @@ -0,0 +1,53 @@ +--- +name: launch_safety_check +description: Gate a freshly-graduated Meteora DAMM v2 token before providing liquidity — + sellability (honeypot), objective on-chain safety (mint/freeze authority, holder + concentration, LP lock), and real post-graduation demand. Reject on any failure. +when_to_use: Before every early-LP entry on a launch graduation (and periodically on held + launch positions). Run it after easya_graduation_monitor surfaces a candidate and before + any add_liquidity. +created: '2026-08-05T00:00:00Z' +source: agent:meteora_launch_lp +--- + +# Launch Safety Check + +Never LP a graduation that fails ANY gate below. Most launch tokens go to zero; the fee income has +to outrun IL + dump, and one honeypot or rug wipes the position. Reject fast and move on — there is +always another graduation. + +## Gate order (cheapest / most-disqualifying first) + +### 1. Sellability (honeypot) — do this FIRST, it's the killer +A token you can buy but can't sell is a total loss. Round-trip a quote through the pool: +- `manage_amm(action="quote_swap", connector="meteora", network="solana-mainnet-beta", pool_address=, base_token=, side="SELL", amount=)` +- and `... side="BUY", amount=` + +Both must return a sane quote. If the **SELL** quote errors, reverts, or returns ~0 out / absurd price +impact → **honeypot, reject.** (A transfer-fee/tax token shows as a large one-directional impact — treat +high asymmetric impact as a red flag too.) + +### 2. Objective on-chain + static gates — one routine call +``` +manage_routines(action="run", name="launch_safety_check", + strategy_id="meteora_launch_lp.launch_lp_operator", + config={"pool_address": , "rpc_url": , "require_verified": false, + "require_mint_renounced": true, "require_freeze_disabled": true, + "max_top10_holder_pct": 60, "min_tvl_usd": 10000}) +``` +Returns a PASS/FAIL verdict over: **mint authority renounced** (no infinite dilution), **freeze +authority disabled** (can't freeze your tokens — a honeypot vector), **top-10 holder concentration** +(≤ 60% — else a whale can dump on you), **TVL floor**, and optionally **verified** / **LP lock**. +Any FAIL → reject. (Use a private `rpc_url`; the public endpoint rate-limits the holder query.) + +### 3. Real post-graduation demand — don't LP the dump +The pump-graduate-**dump** pattern means early full-range LP eats the drawdown. So: +- **Skip the graduation candle.** Wait until the pool is past its first hours and price/volume have + stabilized (`easya_graduation_monitor` reports `Age(h)`; prefer age past your `min_stabilize_hours`). +- Require **rising/steady 24h volume** and buys not one-sided sells (the monitor's `Vol24h` + + `FeeYield`; a collapsing volume or a one-way sell wall → skip). + +## Decision +Enter **only if all three pass**. On any failure, journal the reason (token, gate, value) and skip — +these rejections are signal for future picks. Re-run gate 1 (sellability) periodically on held +positions: if a token *becomes* unsellable, exit immediately at any price you can still get. diff --git a/agents/meteora_launch_lp/strategies/launch_lp_operator/strategy.md b/agents/meteora_launch_lp/strategies/launch_lp_operator/strategy.md new file mode 100644 index 00000000..b3feb78e --- /dev/null +++ b/agents/meteora_launch_lp/strategies/launch_lp_operator/strategy.md @@ -0,0 +1,139 @@ +--- +name: Launch LP Operator +description: '' +agent_key: null +skills: +- launch_safety_check +default_config: + frequency_sec: 900 + execution_mode: loop + quote_asset: SOL + total_amount_quote: 1.0 + max_positions: 3 + capital_per_position: null + base_pct: 50 + entry: + max_age_hours: 72 + min_stabilize_hours: 6 + min_tvl_usd: 15000 + min_vol24h_usd: 8000 + max_top10_holder_pct: 60 + require_mint_renounced: true + require_freeze_disabled: true + require_verified: false + exit: + take_profit_pct: 40 + stop_loss_pct: 25 + min_fee_apr_pct: 30 + drawdown_pct: 25 + max_hold_hours: 48 + risk_limits: + min_wallet_sol_reserve: 0.3 + max_positions: 3 + max_quote_per_position: 0.5 + rpc_url: '' +default_trading_context: '' +created_by: 0 +created_at: '2026-08-05T00:00:00+00:00' +--- + +# Launch LP Operator + +You are the Meteora Launch LP agent's execution strategy. Each tick you **monitor open launch LP +positions**, **exit** any that hit take-profit / stop-loss / decay / max-hold, and **open at most ONE +new position** on the best freshly-graduated EasyA pool that passes the safety gates. Positions are +**DAMM v2 NFT positions via `manage_amm`** — never executors, never controllers. + +## HARD TICK BUDGET +~15-minute tick. **Aim for ≤ 10 tool calls.** Use the **`easya_graduation_monitor` routine** (one call) +to detect — do NOT hand-scan. Gate with the **`launch_safety_check`** routine + one sellability quote. +Open **at most ONE position per tick.** + +## Configuration at launch +Read from `[CURRENT CONFIG]`: `quote_asset` (SOL), `total_amount_quote`, `max_positions`, +`capital_per_position` (null → `total_amount_quote ÷ max_positions`, keeping `min_wallet_sol_reserve` +SOL for rent+fees), `base_pct` (share of a position held as the token vs SOL), the `entry`/`exit` +thresholds, and `rpc_url` (private RPC for the safety routine's holder query — the public one throttles). + +## Constants +- `connector` = `meteora` · `network` = `solana-mainnet-beta` · quote = SOL +- Full-range AMM: **there is NO price range, no bins, no rebalancing** — do not compute ranges. + +## Each Tick — Step by Step + +### 1. Load state — ADOPT every live position (critical after a restart) +`manage_amm` is stateless, so trust the chain, not session memory. Call +`manage_amm(action="positions_owned", connector="meteora", network="solana-mainnet-beta")` and treat +every returned pool position with **non-zero liquidity** as an open position (ignore zero-liquidity +dust NFTs). Cross-reference your **journal** for each position's SOL cost basis and entry time. For +any live position missing from the journal, adopt it (log an `action` entry reconstructing best-effort +cost basis from `position_info`). `open = that set`; `free = max_positions − len(open)`. Read wallet +SOL with `get_portfolio_overview` only if you need the balance to size an entry. + +### 2. Monitor + exit your open positions +For each open position, read its current value and decide exit. Value it in SOL: +`manage_amm(action="position_info", …, pool_address=

)` gives the position's base/quote amounts +(principal + accrued fees); `pool_info` gives `price`. `value_sol = base_amount × price + quote_amount`. +`pnl_pct = (value_sol − sol_in) / sol_in × 100`. + +**Exit 100% via `manage_amm(action="remove_liquidity", pool_address=

, position_address=, +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); +- **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 + can still get, immediately. + +After removing, the base token you get back is dust exposure — swap it to SOL with +`manage_amm(action="execute_swap", side="SELL", base_token=, amount=)`. Journal +the exit (pool, reason, realized pnl_pct, fees, duration) as a `learning` if the reason is new. + +### 3. Detect — ONE routine call (only if free > 0) +``` +manage_routines(action="run", name="easya_graduation_monitor", + strategy_id="meteora_launch_lp.launch_lp_operator", + config={"max_age_hours": , "min_tvl_usd": , + "min_vol24h_usd": , "verified_only": , "top_n": 5}) +``` +Returns fresh EasyA graduations ranked by fee yield with `Pool`, `BaseMint`, `Age(h)`, `TVL`, `Vol24h`. +Drop any pool you already hold. Take the top candidate whose `Age(h) ≥ entry.min_stabilize_hours` +(never LP the graduation candle). + +### 4. Gate the candidate (skill: launch_safety_check) +Run the full gate — reject on ANY failure, then try the next candidate (max 2 candidates/tick): +1. **Sellability:** `manage_amm(quote_swap, side="SELL", …)` AND `side="BUY"` both return sane quotes. +2. **Safety routine:** `manage_routines(action="run", name="launch_safety_check", config={"pool_address":

, + "rpc_url":, "require_mint_renounced":true, "require_freeze_disabled":true, + "max_top10_holder_pct":, "require_verified":})` + → must be **PASS**. +3. **Demand:** rising/steady `Vol24h`, not a one-way sell wall. + +### 5. Open ONE position (small, capped) +Early LP is **directional-long the token** — size at `min(capital_per_position, max_quote_per_position)`. +- Acquire the token side: swap `capital × base_pct/100` SOL → base token via + `manage_amm(action="execute_swap", side="BUY", base_token=, amount=)`. + **Haircut the received amount ×0.995** (the swap fills slightly under the quote; opening with the + quoted figure asks for tokens you don't have and the add fails on-chain). Prefer reading the actual + post-swap wallet balance. +- Size the two sides with `manage_amm(action="quote_liquidity", base_token_amount=, + quote_token_amount=)` to respect the pool ratio, then + `manage_amm(action="add_liquidity", pool_address=

, base_token_amount=, quote_token_amount=)` + — **omit `position_address`** to open a NEW position. Journal the returned `position_address`, + `sol_in`, and entry price. +- `send_notification` on open with the pool + `signature`. + +### 6. Journal +One `trading_agent_journal_write(entry_type="action", …)`: positions held + pnl, any exit (reason), +the open (pool, size, position_address), free slots left. Add a `learning` only if genuinely new. + +## Guardrails +- Keep `min_wallet_sol_reserve` SOL free for rent (~0.05–0.06 SOL/DAMM v2 position) + fees; if short, + don't open — journal and hold. +- **One open per tick; one position per token/pool** (never stack a second position on a token you hold). +- **Never LP the graduation candle** — enforce `min_stabilize_hours`. +- **Small size** — most launch tokens go to zero; a single position must never exceed `max_quote_per_position`. +- On any tool failure or a half-open (swap landed but add failed), journal it and **hold/repair** — + never leave the swapped base token unmanaged; either retry the add with the true balance or swap back to SOL. +- Don't re-enter a token you stopped out of this session unless it clearly re-ranks on top. diff --git a/mcp_servers/hummingbot_api/formatters/__init__.py b/mcp_servers/hummingbot_api/formatters/__init__.py index 93bf3108..23368700 100644 --- a/mcp_servers/hummingbot_api/formatters/__init__.py +++ b/mcp_servers/hummingbot_api/formatters/__init__.py @@ -36,6 +36,7 @@ # Gateway formatters from .gateway import ( + format_amm_result, format_gateway_clmm_pool_result, format_gateway_config_result, format_gateway_container_result, diff --git a/mcp_servers/hummingbot_api/formatters/gateway.py b/mcp_servers/hummingbot_api/formatters/gateway.py index e4ebfdac..75c20420 100644 --- a/mcp_servers/hummingbot_api/formatters/gateway.py +++ b/mcp_servers/hummingbot_api/formatters/gateway.py @@ -141,3 +141,68 @@ def format_gateway_clmm_pool_result(action: str, result: dict[str, Any]) -> str: ) return f"Gateway CLMM Pool Exploration Result: {result}" + + +def format_amm_result(action: str, result: dict[str, Any]) -> str: + """Format manage_amm results into a human-readable string.""" + # Progressive disclosure: the guide is returned directly. + if action is None or result.get("action") is None: + return result.get("formatted_output", str(result)) + + connector = result.get("connector", "") + network = result.get("network", "") + payload = result.get("result", {}) + header = f"AMM {action} [{connector} · {network}]" + + if action == "pool_info" and isinstance(payload, dict): + return ( + f"{header}\n" + f"Pool: {payload.get('address')}\n" + f"Price: {payload.get('price')} (quote per base)\n" + f"Base: {payload.get('base_token_amount')} Quote: {payload.get('quote_token_amount')}\n" + f"Fee: {payload.get('fee_pct')}%" + ) + + if action == "position_info" and isinstance(payload, dict): + positions = payload.get("positions") or [] + lines = [ + f"{header}", + f"Pool: {payload.get('pool_address')} Wallet: {payload.get('wallet_address')}", + f"Aggregate — LP: {payload.get('lp_token_amount')} " + f"Base: {payload.get('base_token_amount')} Quote: {payload.get('quote_token_amount')}", + ] + if positions: + lines.append(f"Positions ({len(positions)}):") + for p in positions: + lines.append( + f" • {p.get('position_address')} — LP: {p.get('lp_token_amount')} " + f"Base: {p.get('base_token_amount')} Quote: {p.get('quote_token_amount')}" + ) + return "\n".join(lines) + + if action == "positions_owned" and isinstance(payload, list): + lines = [f"{header} — {len(payload)} pool(s) with positions"] + for pi in payload: + positions = pi.get("positions") or [] + lines.append( + f" Pool {pi.get('pool_address')}: {len(positions)} position(s), " + f"aggregate base={pi.get('base_token_amount')} quote={pi.get('quote_token_amount')}" + ) + for p in positions: + lines.append( + f" • {p.get('position_address')} — LP: {p.get('lp_token_amount')} " + f"Base: {p.get('base_token_amount')} Quote: {p.get('quote_token_amount')}" + ) + return "\n".join(lines) + + if action in ("quote_swap", "quote_liquidity") and isinstance(payload, dict): + return f"{header}\n{payload}" + + if action in ("execute_swap", "add_liquidity", "remove_liquidity", "create_pool") and isinstance(payload, dict): + sig = payload.get("signature") + extra = "" + if action == "create_pool": + extra = f"\nPool: {payload.get('pool_address')} Seed price: {payload.get('price')}" + return f"{header}\nSignature/Tx: {sig} Status: {payload.get('status')}{extra}" + + return f"{header}\n{payload}" diff --git a/mcp_servers/hummingbot_api/guides/gateway_amm.md b/mcp_servers/hummingbot_api/guides/gateway_amm.md new file mode 100644 index 00000000..6200c6a1 --- /dev/null +++ b/mcp_servers/hummingbot_api/guides/gateway_amm.md @@ -0,0 +1,68 @@ +# manage_amm — direct AMM operations & pool creation + +A stateless, chain- & DEX-agnostic tool for **AMM** liquidity and **pool creation**. You hold +position state in your journal (there is no AMM executor). Swaps that just need best execution go +through `manage_executors(order_executor)`; CLMM LP goes through `manage_executors(lp_executor)`. +`manage_amm` is **AMM-only**. + +## Connectors & networks +- **meteora** — Solana DAMM v2 (constant-product AMM with **NFT positions**). Network: `solana-mainnet-beta`. +- **raydium** — Solana CPMM (fungible LP). Network: `solana-mainnet-beta`. +- **uniswap** — EVM V2 (fungible LP). Networks: `ethereum-mainnet`, `base-mainnet`, `arbitrum-mainnet`, … + +`connector` and `network` are required for every action (no defaults). `wallet_address` is optional +(uses the default wallet). + +## Actions +| action | type | required params | +|---|---|---| +| `pool_info` | read | `pool_address` | +| `position_info` | read | `pool_address` (returns aggregate + `positions[]`) | +| `positions_owned` | read | — (**meteora only**; fungible-LP → error) | +| `quote_swap` | read | `pool_address`, `base_token`, `side`, `amount` | +| `execute_swap` | write | `pool_address`, `base_token`, `side`, `amount` | +| `quote_liquidity` | read | `pool_address`, `base_token_amount`, `quote_token_amount` | +| `add_liquidity` | write | `pool_address`, `base_token_amount`, `quote_token_amount` (+ optional `position_address`) | +| `remove_liquidity` | write | `pool_address`, `percentage_to_remove` (+ **`position_address` required for meteora**) | +| `create_pool` | write | `base_token`, `quote_token`, `base_token_amount` (+ connector extra) | + +`quote_swap`/`execute_swap` are **pool-scoped** (they trade against the specific `pool_address`), not +a router. Use `explore_dex_pools`/`pool_info` to find a pool. + +## Meteora DAMM v2 position model (important) +DAMM v2 positions are **NFTs**: a wallet may hold **several positions in one pool**, each an +independent asset differing in size, lock state, and fees — they are *not* interchangeable shares. +So this tool is **position-addressed**: +- `position_info` returns the pool **aggregate** plus a `positions[]` breakdown — each entry has a + `position_address`. This is your discovery path. `positions_owned` lists **all** your positions + across pools. +- `remove_liquidity` **requires** `position_address`; the percentage applies to *that* position, so + "remove 100%" is a true exit of the named position (no silent partial-exit when several exist). +- `add_liquidity` takes an **optional** `position_address` — provide it to add to that position, or + **omit it to open a NEW position**. +- **Journal the `position_address`** of each position you open; it is the source of truth for which + position to act on later. + +Fungible-LP AMMs (raydium, uniswap) have a single position per wallet: `position_address` is ignored +and `positions_owned` is not supported — use `position_info` with a known `pool_address`. + +## create_pool — market-seeded, anti-snipe +Seed price priority: `initial_price` (quote per base) → `quote_token_amount` ratio → **live market +price** (fetched from the swap router so the pool opens on-market and bots can't arb your seed). +Only `base_token_amount` is required. + +Per-connector `create_pool` extras (only the owning connector consumes them): +- **meteora**: `config_address` (**required**) — the DAMM v2 config account that fixes the fee + schedule. Many configs are token-launch configs whose base fee starts near **99%** and decays; + pick a static-fee config you actually want. Token order: `base_token` → token A, `quote_token` → token B. +- **raydium**: `fee_config_index` (optional; defaults to the first available config). +- **uniswap**: `gas_price` / `max_gas` (optional; fee is fixed at 0.30%). + +## Examples +- Load this guide: `manage_amm()` +- Read a pool: `manage_amm(action="pool_info", connector="meteora", network="solana-mainnet-beta", pool_address="…")` +- Discover your positions: `manage_amm(action="positions_owned", connector="meteora", network="solana-mainnet-beta")` +- Open a new Meteora position: `manage_amm(action="add_liquidity", connector="meteora", network="solana-mainnet-beta", pool_address="…", base_token_amount="1", quote_token_amount="2")` (omit `position_address`) +- Exit a specific position: `manage_amm(action="remove_liquidity", connector="meteora", network="solana-mainnet-beta", pool_address="…", position_address="…", percentage_to_remove="100")` +- Create a Raydium CPMM pool (market-seeded): `manage_amm(action="create_pool", connector="raydium", network="solana-mainnet-beta", base_token="SOL", quote_token="USDC", base_token_amount="1")` +- Create a Meteora DAMM v2 pool: `manage_amm(action="create_pool", connector="meteora", network="solana-mainnet-beta", base_token="…", quote_token="USDC", base_token_amount="1", config_address="…")` diff --git a/mcp_servers/hummingbot_api/schemas.py b/mcp_servers/hummingbot_api/schemas.py index 1eb5e3c5..edcb26aa 100644 --- a/mcp_servers/hummingbot_api/schemas.py +++ b/mcp_servers/hummingbot_api/schemas.py @@ -564,3 +564,72 @@ class GatewayCLMMRequest(BaseModel): default=False, description="Return detailed table with more columns (default: False)", ) + + +# ============================================================================== +# Gateway AMM Schemas (manage_amm) +# ============================================================================== + + +class AMMRequest(BaseModel): + """Request model for the direct, chain- & DEX-agnostic AMM tool (manage_amm). + + Drives AMM operations and pool creation across Meteora DAMM v2 (Solana), Raydium CPMM (Solana), + and Uniswap V2 (EVM). Stateless — the caller/agent holds position state. + + Progressive disclosure: action=None returns the AMM guide + per-connector matrix. + + Meteora DAMM v2 positions are NFTs (a wallet may hold several per pool), so this tool is + position-addressed: remove_liquidity requires position_address, add_liquidity takes it + optionally (omit = open a new position), position_info returns a positions[] breakdown, and + positions_owned lists all of a wallet's positions. Fungible-LP AMMs ignore position_address. + """ + + action: ( + Literal[ + "pool_info", + "position_info", + "positions_owned", + "quote_swap", + "execute_swap", + "quote_liquidity", + "add_liquidity", + "remove_liquidity", + "create_pool", + ] + | None + ) = Field(default=None, description="AMM action. Leave empty to load the AMM guide + param matrix.") + + connector: str | None = Field( + default=None, + description="AMM connector (required for any action): 'meteora' (Solana DAMM v2), 'raydium' (Solana CPMM), 'uniswap' (EVM V2)", + ) + network: str | None = Field( + default=None, + description="Network ID in 'chain-network' format. Examples: 'solana-mainnet-beta', 'ethereum-mainnet', 'base-mainnet'", + ) + wallet_address: str | None = Field(default=None, description="Wallet address (optional, uses default if not provided)") + pool_address: str | None = Field(default=None, description="Pool contract address") + position_address: str | None = Field( + default=None, + description="Meteora NFT position: REQUIRED for remove_liquidity, optional for add_liquidity (omit = open a new position). Ignored by fungible-LP AMMs.", + ) + + # Swap params + base_token: str | None = Field(default=None, description="Base token symbol or address (swap direction / pool base)") + quote_token: str | None = Field(default=None, description="Quote token symbol or address (pool quote, for create_pool)") + amount: str | None = Field(default=None, description="Swap amount (as string; parsed to Decimal)") + side: Literal["BUY", "SELL"] | None = Field(default=None, description="Swap direction") + slippage_pct: str | None = Field(default=None, description="Maximum slippage percentage (as string)") + + # Liquidity params + base_token_amount: str | None = Field(default=None, description="Base token amount (add_liquidity / quote_liquidity / create_pool)") + quote_token_amount: str | None = Field(default=None, description="Quote token amount (add_liquidity / quote_liquidity / create_pool)") + percentage_to_remove: str | None = Field(default=None, description="Percentage of liquidity to remove, 0-100 (remove_liquidity)") + + # create_pool params + initial_price: str | None = Field(default=None, description="Initial price as quote per base (create_pool; overrides quote_token_amount)") + config_address: str | None = Field(default=None, description="Meteora DAMM v2 config account address (required for meteora create_pool)") + fee_config_index: int | None = Field(default=None, description="Raydium CPMM fee config index (optional, create_pool)") + gas_price: str | None = Field(default=None, description="Uniswap (EVM) gas price in gwei (optional, create_pool)") + max_gas: int | None = Field(default=None, description="Uniswap (EVM) max gas limit (optional, create_pool)") diff --git a/mcp_servers/hummingbot_api/server.py b/mcp_servers/hummingbot_api/server.py index de8b5a79..b8b57331 100644 --- a/mcp_servers/hummingbot_api/server.py +++ b/mcp_servers/hummingbot_api/server.py @@ -11,6 +11,7 @@ from mcp_servers.hummingbot_api.formatters import ( format_active_bots_as_table, + format_amm_result, format_bot_logs_as_table, format_gateway_clmm_pool_result, format_gateway_config_result, @@ -21,6 +22,7 @@ from mcp_servers.hummingbot_api.hummingbot_client import hummingbot_client from mcp_servers.hummingbot_api.middleware import GATEWAY_LOG_HINT, handle_errors from mcp_servers.hummingbot_api.schemas import ( + AMMRequest, GatewayCLMMRequest, GatewayConfigRequest, GatewayContainerRequest, @@ -49,6 +51,9 @@ from mcp_servers.hummingbot_api.tools.gateway import ( manage_gateway_container as manage_gateway_container_impl, ) +from mcp_servers.hummingbot_api.tools.gateway_amm import ( + manage_amm_impl, +) from mcp_servers.hummingbot_api.tools.gateway_clmm import ( explore_gateway_clmm_pools as explore_gateway_clmm_pools_impl, ) @@ -853,6 +858,115 @@ async def explore_dex_pools( return format_gateway_clmm_pool_result(action, result) +@mcp.tool() +@handle_errors("manage AMM", GATEWAY_LOG_HINT) +async def manage_amm( + action: ( + Literal[ + "pool_info", + "position_info", + "positions_owned", + "quote_swap", + "execute_swap", + "quote_liquidity", + "add_liquidity", + "remove_liquidity", + "create_pool", + ] + | None + ) = None, + connector: str | None = None, + network: str | None = None, + wallet_address: str | None = None, + pool_address: str | None = None, + position_address: str | None = None, + base_token: str | None = None, + quote_token: str | None = None, + amount: str | None = None, + side: Literal["BUY", "SELL"] | None = None, + slippage_pct: str | None = None, + base_token_amount: str | None = None, + quote_token_amount: str | None = None, + percentage_to_remove: str | None = None, + initial_price: str | None = None, + config_address: str | None = None, + fee_config_index: int | None = None, + gas_price: str | None = None, + max_gas: int | None = None, +) -> str: + """Direct AMM pool operations + pool creation, chain- & DEX-agnostic (Meteora / Raydium / Uniswap). + + Stateless — you hold position state in your journal. Progressive disclosure: call with NO + `action` to load the AMM guide, action list, per-connector param matrix, and network list. + + Actions: + - pool_info / position_info → read pool reserves/price/fee; read your position (aggregate + positions[]) + - positions_owned → list ALL your positions across pools (meteora only) + - quote_swap / execute_swap → quote/execute a swap AGAINST a specific AMM pool (pool-scoped, not router) + - quote_liquidity / add_liquidity / remove_liquidity → two-sided LP in/out + - create_pool → create + seed a new pool (market-price seeded by default; anti-snipe) + + Connectors: meteora (Solana DAMM v2), raydium (Solana CPMM), uniswap (EVM V2). + + Meteora DAMM v2 positions are NFTs, so this tool is position-addressed: remove_liquidity REQUIRES + position_address, add_liquidity takes it optionally (omit = open a new position), position_info + returns a positions[] breakdown, positions_owned lists all your positions. Fungible-LP AMMs + (raydium, uniswap) ignore position_address and have no enumerable positions. + + create_pool extras by connector: meteora→config_address (required); raydium→fee_config_index + (optional); uniswap→gas_price/max_gas (optional, 0.30% fixed fee). + + Scope: AMM only. Router/one-shot swaps → manage_executors(order_executor); CLMM LP → + manage_executors(lp_executor). + + Args: + action: AMM action. Leave empty to load the AMM guide + param matrix. + connector: AMM connector (required for any action): meteora | raydium | uniswap. + network: Network ID in 'chain-network' format (e.g., 'solana-mainnet-beta', 'ethereum-mainnet'). + wallet_address: Wallet address (optional, uses default if not provided). + pool_address: Pool contract address. + position_address: Meteora NFT position — required for remove_liquidity, optional for add_liquidity (omit = new position). + base_token: Base token symbol or address (swap direction / pool base). + quote_token: Quote token symbol or address (pool quote, for create_pool). + amount: Swap amount (string). + side: Swap direction (BUY or SELL). + slippage_pct: Maximum slippage percentage (string). + base_token_amount: Base token amount (add_liquidity / quote_liquidity / create_pool). + quote_token_amount: Quote token amount (add_liquidity / quote_liquidity / create_pool). + percentage_to_remove: Percentage of liquidity to remove, 0-100 (remove_liquidity). + initial_price: Initial price as quote per base (create_pool; overrides quote_token_amount). + config_address: Meteora DAMM v2 config account address (required for meteora create_pool). + fee_config_index: Raydium CPMM fee config index (optional, create_pool). + gas_price: Uniswap (EVM) gas price in gwei (optional, create_pool). + max_gas: Uniswap (EVM) max gas limit (optional, create_pool). + """ + request = AMMRequest( + action=action, + connector=connector, + network=network, + wallet_address=wallet_address, + pool_address=pool_address, + position_address=position_address, + base_token=base_token, + quote_token=quote_token, + amount=amount, + side=side, + slippage_pct=slippage_pct, + base_token_amount=base_token_amount, + quote_token_amount=quote_token_amount, + percentage_to_remove=percentage_to_remove, + initial_price=initial_price, + config_address=config_address, + fee_config_index=fee_config_index, + gas_price=gas_price, + max_gas=max_gas, + ) + + client = await hummingbot_client.get_client() + result = await manage_amm_impl(client, request) + return format_amm_result(action, result) + + # GeckoTerminal Tools diff --git a/mcp_servers/hummingbot_api/tools/gateway_amm.py b/mcp_servers/hummingbot_api/tools/gateway_amm.py new file mode 100644 index 00000000..c37ac464 --- /dev/null +++ b/mcp_servers/hummingbot_api/tools/gateway_amm.py @@ -0,0 +1,174 @@ +""" +manage_amm implementation — a direct, chain- & DEX-agnostic AMM tool. + +Drives AMM operations and pool creation across Meteora DAMM v2 (Solana), Raydium CPMM (Solana), +and Uniswap V2 (EVM) via the SDK's client.gateway_amm.* router. Stateless — the agent holds +position state in its journal. + +Meteora DAMM v2 positions are NFTs, so this tool is position-addressed: remove_liquidity requires +position_address, add_liquidity takes it optionally (omit = new position), position_info returns a +positions[] breakdown, and positions_owned lists all of a wallet's positions. Fungible-LP AMMs +(raydium, uniswap) ignore position_address and have no enumerable positions. +""" +from decimal import Decimal +from pathlib import Path +from typing import Any + +from mcp_servers.hummingbot_api.exceptions import ToolError +from mcp_servers.hummingbot_api.schemas import AMMRequest + +SUPPORTED_CONNECTORS = {"meteora", "raydium", "uniswap"} +# Non-fungible-LP (NFT position) connectors — position-addressed and enumerable. +NFT_POSITION_CONNECTORS = {"meteora"} + + +def _guide() -> str: + guide_file = Path(__file__).parent.parent / "guides" / "gateway_amm.md" + if guide_file.exists(): + return guide_file.read_text().strip() + raise ToolError("AMM guide not found (guides/gateway_amm.md)") + + +def _dec(value: str | None, name: str) -> Decimal: + if value is None: + raise ToolError(f"{name} is required for this action") + try: + return Decimal(str(value)) + except Exception as exc: + raise ToolError(f"{name} must be a number, got {value!r}") from exc + + +def _opt_dec(value: str | None) -> Decimal | None: + return None if value is None else Decimal(str(value)) + + +def _require(request: AMMRequest, *fields: str) -> None: + for f in fields: + if getattr(request, f) is None: + raise ToolError(f"{f} is required for the '{request.action}' action") + + +async def manage_amm_impl(client: Any, request: AMMRequest) -> dict[str, Any]: + """Validate per-(connector, action) and dispatch to client.gateway_amm.*.""" + # Progressive disclosure: no action → load the guide. + if request.action is None: + return {"action": None, "formatted_output": _guide()} + + # Every action needs a supported connector. + if not request.connector: + raise ToolError( + "connector is required (meteora | raydium | uniswap). " + "Call manage_amm with no action to load the guide." + ) + connector = request.connector.lower() + if connector not in SUPPORTED_CONNECTORS: + raise ToolError( + f"Unsupported AMM connector '{request.connector}'. Supported: {', '.join(sorted(SUPPORTED_CONNECTORS))}" + ) + if not request.network: + raise ToolError("network is required (e.g. 'solana-mainnet-beta', 'ethereum-mainnet')") + + net = request.network + action = request.action + # Dereferenced only after per-action validation so guard errors never depend on the client. + ga = None if client is None else client.gateway_amm + + if action == "pool_info": + _require(request, "pool_address") + result = await ga.get_pool_info(connector=connector, network=net, pool_address=request.pool_address) + + elif action == "position_info": + _require(request, "pool_address") + result = await ga.get_position_info( + connector=connector, network=net, pool_address=request.pool_address, + wallet_address=request.wallet_address, + ) + + elif action == "positions_owned": + if connector not in NFT_POSITION_CONNECTORS: + raise ToolError( + f"positions_owned is not supported for '{connector}': fungible-LP AMMs have no enumerable " + "positions. Use position_info with a specific pool_address instead." + ) + result = await ga.get_positions_owned(connector=connector, network=net, wallet_address=request.wallet_address) + + elif action == "quote_swap": + _require(request, "pool_address", "base_token", "side", "amount") + result = await ga.get_swap_quote( + connector=connector, network=net, pool_address=request.pool_address, + base_token=request.base_token, side=request.side, amount=_dec(request.amount, "amount"), + slippage_pct=_opt_dec(request.slippage_pct), + ) + + elif action == "execute_swap": + _require(request, "pool_address", "base_token", "side", "amount") + result = await ga.execute_swap( + connector=connector, network=net, pool_address=request.pool_address, + base_token=request.base_token, side=request.side, amount=_dec(request.amount, "amount"), + slippage_pct=_opt_dec(request.slippage_pct), wallet_address=request.wallet_address, + ) + + elif action == "quote_liquidity": + _require(request, "pool_address", "base_token_amount", "quote_token_amount") + result = await ga.get_liquidity_quote( + connector=connector, network=net, pool_address=request.pool_address, + base_token_amount=_dec(request.base_token_amount, "base_token_amount"), + quote_token_amount=_dec(request.quote_token_amount, "quote_token_amount"), + slippage_pct=_opt_dec(request.slippage_pct), + ) + + elif action == "add_liquidity": + _require(request, "pool_address", "base_token_amount", "quote_token_amount") + # Meteora: position_address optional (omit = new position). Fungible-LP: ignored. + result = await ga.add_liquidity( + connector=connector, network=net, pool_address=request.pool_address, + base_token_amount=_dec(request.base_token_amount, "base_token_amount"), + quote_token_amount=_dec(request.quote_token_amount, "quote_token_amount"), + slippage_pct=_opt_dec(request.slippage_pct), wallet_address=request.wallet_address, + position_address=request.position_address, + ) + + elif action == "remove_liquidity": + _require(request, "pool_address", "percentage_to_remove") + # DAMM v2 positions are NFTs — fail fast rather than letting Gateway 400. + if connector in NFT_POSITION_CONNECTORS and not request.position_address: + raise ToolError( + f"position_address is required for {connector} remove_liquidity: DAMM v2 positions are NFTs " + "and a wallet may hold several per pool. List them with position_info or positions_owned." + ) + result = await ga.remove_liquidity( + connector=connector, network=net, pool_address=request.pool_address, + percentage_to_remove=_dec(request.percentage_to_remove, "percentage_to_remove"), + position_address=request.position_address, slippage_pct=_opt_dec(request.slippage_pct), + wallet_address=request.wallet_address, + ) + + elif action == "create_pool": + _require(request, "base_token", "quote_token", "base_token_amount") + if connector == "meteora" and not request.config_address: + raise ToolError( + "config_address is required for meteora create_pool (DAMM v2 pools are created against a " + "config account that fixes the fee schedule). Discover configs via the Meteora app/SDK; " + "avoid token-launch configs whose base fee starts near 99%." + ) + result = await ga.create_pool( + connector=connector, network=net, base_token=request.base_token, quote_token=request.quote_token, + base_token_amount=_dec(request.base_token_amount, "base_token_amount"), + quote_token_amount=_opt_dec(request.quote_token_amount), + initial_price=_opt_dec(request.initial_price), + config_address=request.config_address, fee_config_index=request.fee_config_index, + gas_price=_opt_dec(request.gas_price), max_gas=request.max_gas, + wallet_address=request.wallet_address, + ) + + else: + raise ToolError(f"Unknown action: {action}") + + return { + "action": action, + "connector": connector, + "network": net, + "pool_address": request.pool_address, + "position_address": request.position_address, + "result": result, + }