From df6596cd50d6d997329edc8a5ae1bfcf4f04da14 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Wed, 12 Aug 2026 18:22:53 -0700 Subject: [PATCH 01/54] =?UTF-8?q?feat(executors):=20orphaned-position=20li?= =?UTF-8?q?fecycle=20for=20LP=20executors=20=E2=80=94=20flag,=20listing,?= =?UTF-8?q?=20DB-aware=20stop,=20resolve?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API-side of the gateway#678 retry-ownership work (canonical design: docs/retry-architecture.md in the companion gateway PR). Executors created via the API run in-process with no controller, so the API owns the "react to a stranded position" role: - Stop on a terminal executor returns already_terminated with close_type, position_address, orphaned_position, and hold_reason instead of the 404 dead end #678 hit (terminal executors are popped from memory within one tick, so "not in memory" almost always means "already terminated"). 404 is reserved for ids the DB has never seen. - Completion flags stranded exposure in the persisted final state: an involuntary hold (POSITION_HOLD with hold_reason set — an LP close that exhausted its retries) or a legacy FAILED-with-position gets orphaned_position: true and an error-level log. Voluntary holds never match (a successful close clears position_address first). - GET /executors/positions/orphaned lists recovery candidates (SQL-filtered to lp_executor; involuntary holds, FAILED-with-position, and SYSTEM_CLEANUP restarts flagged needs_onchain_reconciliation). - POST /executors/{id}/resolve-orphan marks a candidate recovered after the position is closed externally, silencing listings and warnings. - bots/controllers lp_rebalancer mirror: halt + skip accounting for executors that ended with a live position (re-creating one would mint a second position on top of the stranded one). Validated live on mainnet: forced close-failure cascade terminated as the involuntary hold, surfaced in the orphan listing with hold_reason, re-stop returned already_terminated, and resolve-orphan cleared it after a direct gateway close recovered all funds + rent. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HahKfEY9rvKnZijrzUAFSq --- .../generic/lp_rebalancer/lp_rebalancer.py | 63 +++++-- database/repositories/executor_repository.py | 22 +++ models/__init__.py | 4 + models/executors.py | 46 ++++- routers/executors.py | 52 ++++++ services/executor_service.py | 164 ++++++++++++++++++ 6 files changed, 339 insertions(+), 12 deletions(-) diff --git a/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py b/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py index fd4c43d6..8793f4ce 100644 --- a/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py +++ b/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py @@ -58,7 +58,8 @@ class LPRebalancerConfig(ControllerConfigBase): position_offset_pct: Decimal = Field( default=Decimal("0.01"), json_schema_extra={"is_updatable": True}, - description="Offset from current price. Positive = out-of-range (single-sided). Negative = in-range (needs both tokens, autoswap will convert |offset|%)" + description="Offset from current price. Positive = out-of-range (single-sided). " + "Negative = in-range (needs both tokens, autoswap will convert |offset|%)" ) # Rebalance threshold - used to set LP executor's limit prices @@ -184,6 +185,9 @@ def __init__(self, config: LPRebalancerConfig, *args, **kwargs): # Track the executor we created self._current_executor_id: Optional[str] = None + # Set when a FAILED LP executor still reports a live on-chain position; the + # controller halts new position creation until it is recovered manually + self._orphaned_position_address: Optional[str] = None # Track amounts from last closed position (for autoswap sizing) self._last_closed_base_amount: Optional[Decimal] = None @@ -416,6 +420,16 @@ def determine_executor_actions(self) -> List[ExecutorAction]: actions = [] + # An orphaned on-chain position (FAILED close) must be recovered before any new + # position is opened - creating a fresh executor here would stack live exposure + # on top of the stranded one. + if self._orphaned_position_address: + self.logger().debug( + f"Halted: position {self._orphaned_position_address} from a FAILED executor is " + "still open on-chain and requires manual recovery" + ) + return actions + # Handle order executor tracking and completion (for autoswap) if self._pending_swap_side is not None: if not self._swap_executor_id: @@ -509,10 +523,15 @@ def determine_executor_actions(self) -> List[ExecutorAction]: # Previous executor terminated - capture final amounts and update position_hold terminated_executor = self.get_tracked_executor() if terminated_executor: - # Skip position_hold update if executor failed (no tokens were actually deposited/returned) - if terminated_executor.close_type == CloseType.FAILED: + # Skip position_hold update if the executor failed (nothing deposited or + # returned) or ended as an involuntary hold (close exhausted): in the + # latter case base/quote amounts are pool balances of a still-open + # position, and booking them as returned tokens would corrupt the hold. + if (terminated_executor.close_type == CloseType.FAILED + or terminated_executor.custom_info.get("hold_reason")): self.logger().warning( - f"Executor {terminated_executor.id} FAILED - skipping position_hold update" + f"Executor {terminated_executor.id} ended {terminated_executor.close_type} " + "without returning tokens - skipping position_hold update" ) else: self._last_closed_base_amount = Decimal(str(terminated_executor.custom_info.get("base_amount", 0))) @@ -539,16 +558,32 @@ def determine_executor_actions(self) -> List[ExecutorAction]: f"Position hold total: base={self._position_hold_base}, quote={self._position_hold_quote}" ) - # Check if executor FAILED - retry with same side from executor's config + # Check if the executor went terminal abnormally - FAILED (nothing on-chain) + # or an involuntary POSITION_HOLD (close retries exhausted, hold_reason set) executor_failed = terminated_executor and terminated_executor.close_type == CloseType.FAILED + involuntary_hold = bool(terminated_executor and terminated_executor.custom_info.get("hold_reason")) failed_executor_side = None - if executor_failed: + if executor_failed or involuntary_hold: failed_executor_side = terminated_executor.custom_info.get("side") + # A terminal executor that still reports a position address went down on + # the CLOSE side: its deposit is still on-chain (involuntary hold, or a + # legacy FAILED-with-position from a force-stop). Re-opening would stack + # a second position on top of the stranded one. + orphaned_position = terminated_executor.custom_info.get("position_address") + if orphaned_position: + self._orphaned_position_address = orphaned_position + self._current_executor_id = None + self.logger().error( + f"Executor {terminated_executor.id} ended {terminated_executor.close_type} " + f"with position {orphaned_position} still open on-chain. Halting new " + "position creation until the position is closed or recovered manually." + ) + return actions # Capture closed position bounds for side determination (only for successful closes) closed_lower_price = None closed_upper_price = None - if terminated_executor and not executor_failed: + if terminated_executor and not executor_failed and not involuntary_hold: closed_lower_price = Decimal(str(terminated_executor.custom_info.get("lower_price", 0))) closed_upper_price = Decimal(str(terminated_executor.custom_info.get("upper_price", 0))) @@ -576,7 +611,10 @@ def determine_executor_actions(self) -> List[ExecutorAction]: else: # Price is within old bounds (shouldn't happen with limit-price auto-close) side = self._determine_side_from_price(self._pool_price) - self.logger().info(f"Price {self._pool_price} in range [{closed_lower_price}, {closed_upper_price}] → side={side} from limits") + self.logger().info( + f"Price {self._pool_price} in range [{closed_lower_price}, {closed_upper_price}] " + f"→ side={side} from limits" + ) else: # Fallback to price limits if not self._pool_price: @@ -941,7 +979,8 @@ def to_format_status(self) -> List[str]: width = self.config.position_width_pct offset = self.config.position_offset_pct threshold = self.config.rebalance_threshold_pct - line = f"| Config: side={side_str}, amount={amt} {self._quote_token}, width={width}%, offset={offset}%, threshold={threshold}%" + line = (f"| Config: side={side_str}, amount={amt} {self._quote_token}, " + f"width={width}%, offset={offset}%, threshold={threshold}%") status.append(line + " " * (box_width - len(line) + 1) + "|") status.append("|" + " " * box_width + "|") @@ -985,7 +1024,8 @@ def to_format_status(self) -> List[str]: lower_limit = Decimal(str(lower_price)) * (Decimal("1") - threshold_pct) upper_limit = Decimal(str(upper_price)) * (Decimal("1") + threshold_pct) - line = f"| Price: {float(self._pool_price):.{price_decimals}f} | Auto-close if: <{float(lower_limit):.{price_decimals}f} or >{float(upper_limit):.{price_decimals}f}" + line = (f"| Price: {float(self._pool_price):.{price_decimals}f} | Auto-close if: " + f"<{float(lower_limit):.{price_decimals}f} or >{float(upper_limit):.{price_decimals}f}") status.append(line + " " * (box_width - len(line) + 1) + "|") state = custom.get("state", "UNKNOWN") @@ -1070,7 +1110,8 @@ def to_format_status(self) -> List[str]: line = f"| Swaps Executed: {len(closed_swaps)}" status.append(line + " " * (box_width - len(line) + 1) + "|") - line = f"| Fees Collected: {float(total_fees_base):.6f} {self._base_token} + {float(total_fees_quote):.6f} {self._quote_token} = {float(total_fees_value):.6f} {self._quote_token}" + line = (f"| Fees Collected: {float(total_fees_base):.6f} {self._base_token} + " + f"{float(total_fees_quote):.6f} {self._quote_token} = {float(total_fees_value):.6f} {self._quote_token}") status.append(line + " " * (box_width - len(line) + 1) + "|") status.append("+" + "-" * box_width + "+") diff --git a/database/repositories/executor_repository.py b/database/repositories/executor_repository.py index c6c6c0b4..5ebdd4ae 100644 --- a/database/repositories/executor_repository.py +++ b/database/repositories/executor_repository.py @@ -134,6 +134,28 @@ async def get_executors( result = await self.session.execute(stmt) return list(result.scalars().all()) + async def get_executors_by_close_types( + self, + close_types: List[str], + executor_type: Optional[str] = None, + limit: Optional[int] = 500, + ) -> List[ExecutorRecord]: + """Get executors whose close_type is one of the given values, newest first. + + Filter by executor_type in SQL where possible: applying `limit` to a broad + candidate set and filtering in Python can silently drop the very rows the + caller is looking for once the newest N candidates are all irrelevant. + """ + stmt = select(ExecutorRecord).where(ExecutorRecord.close_type.in_(close_types)) + if executor_type: + stmt = stmt.where(ExecutorRecord.executor_type == executor_type) + stmt = stmt.order_by(desc(ExecutorRecord.created_at)) + if limit is not None: + stmt = stmt.limit(limit) + + result = await self.session.execute(stmt) + return list(result.scalars().all()) + async def get_active_executors( self, account_name: Optional[str] = None, diff --git a/models/__init__.py b/models/__init__.py index 8ecd9eb3..0fbe6a8a 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -70,6 +70,8 @@ ExecutorFilterRequest, ExecutorResponse, ExecutorsSummaryResponse, + OrphanedPositionRecord, + OrphanedPositionsResponse, StopExecutorRequest, StopExecutorResponse, ) @@ -384,4 +386,6 @@ "ExecutorResponse", "ExecutorDetailResponse", "ExecutorsSummaryResponse", + "OrphanedPositionRecord", + "OrphanedPositionsResponse", ] diff --git a/models/executors.py b/models/executors.py index 278fd2a2..c261d5f2 100644 --- a/models/executors.py +++ b/models/executors.py @@ -421,8 +421,52 @@ class CreateExecutorResponse(BaseModel): class StopExecutorResponse(BaseModel): """Response after stopping an executor.""" executor_id: str = Field(description="Executor identifier") - status: str = Field(description="New status (usually 'stopping')") + status: str = Field(description="New status: 'stopping', or 'already_terminated' when the stop was a no-op") keep_position: bool = Field(description="Whether position was kept open") + close_type: Optional[str] = Field(default=None, description="Final close_type when already terminated") + position_address: Optional[str] = Field( + default=None, description="On-chain position address from the executor's final state, if any" + ) + orphaned_position: bool = Field( + default=False, + description="True when the executor terminated with a live on-chain position that needs recovery" + ) + hold_reason: Optional[str] = Field( + default=None, + description="Why a POSITION_HOLD terminal was involuntary (e.g. close_retries_exhausted); None for voluntary holds" + ) + + +class OrphanedPositionRecord(BaseModel): + """A terminated executor that may still own an on-chain position.""" + executor_id: str = Field(description="Executor identifier") + executor_type: str = Field(description="Executor type (e.g. lp_executor)") + account_name: Optional[str] = Field(default=None, description="Account name") + connector_name: Optional[str] = Field(default=None, description="Connector name") + trading_pair: Optional[str] = Field(default=None, description="Trading pair") + controller_id: str = Field(default="main", description="Controller/agent grouping label") + close_type: Optional[str] = Field( + default=None, description="POSITION_HOLD (involuntary hold), FAILED, or SYSTEM_CLEANUP" + ) + closed_at: Optional[str] = Field(default=None, description="Termination timestamp (ISO format)") + position_address: Optional[str] = Field( + default=None, description="On-chain position address (None for restart cleanups, which never persisted state)" + ) + state: Optional[str] = Field(default=None, description="Executor state at termination (e.g. FAILED, CLOSING)") + hold_reason: Optional[str] = Field( + default=None, + description="Why the hold was involuntary (e.g. close_retries_exhausted); None for legacy FAILED/SYSTEM_CLEANUP records" + ) + needs_onchain_reconciliation: bool = Field( + default=False, + description="True when the position address is unknown and on-chain state must be checked externally" + ) + + +class OrphanedPositionsResponse(BaseModel): + """Terminated executors that may have stranded on-chain positions.""" + count: int = Field(description="Number of orphan candidates") + orphans: List[OrphanedPositionRecord] = Field(description="Orphan candidate records") class ExecutorsSummaryResponse(BaseModel): diff --git a/routers/executors.py b/routers/executors.py index 1110fd43..bffe1552 100644 --- a/routers/executors.py +++ b/routers/executors.py @@ -18,6 +18,7 @@ ExecutorFilterRequest, ExecutorLogsResponse, ExecutorsSummaryResponse, + OrphanedPositionsResponse, PerformanceReportResponse, PositionHoldResponse, PositionsSummaryResponse, @@ -357,6 +358,57 @@ async def stop_executor( # Position Hold Endpoints # ======================================== +@router.get("/positions/orphaned", response_model=OrphanedPositionsResponse) +async def get_orphaned_positions( + executor_service: ExecutorService = Depends(get_executor_service) +): + """ + List terminated executors that may still own an on-chain position. + + Covers three orphan classes: + - Involuntary holds: close_type POSITION_HOLD with hold_reason set (an LP close + that exhausted its retries): the position is live on-chain with no automated + owner. + - Legacy FAILED records whose final state still reported a position_address + (force-stop stragglers, records persisted by older executors). + - Executors terminated by SYSTEM_CLEANUP after an API restart: their on-chain + state was never persisted, so they need external reconciliation. + + This is a DB-side listing. Before recovering, cross-check candidates against + on-chain reality via the gateway positions-owned endpoints + (/trading/clmm/positions-owned, /trading/amm/positions-owned). + """ + try: + orphans = await executor_service.get_orphaned_positions() + return OrphanedPositionsResponse(count=len(orphans), orphans=orphans) + except Exception as e: + logger.error(f"Error listing orphaned positions: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Error listing orphaned positions: {str(e)}") + + +@router.post("/{executor_id}/resolve-orphan") +async def resolve_orphaned_position( + executor_id: str, + executor_service: ExecutorService = Depends(get_executor_service) +): + """ + Mark an orphaned position as recovered. + + Call after the stranded on-chain position has been closed (or adopted) + externally. Removes the executor from /executors/positions/orphaned and from + agent-facing orphan warnings. Only valid for terminated executors that are + orphan candidates: an involuntary hold (POSITION_HOLD with hold_reason or the + orphaned_position flag), FAILED, or SYSTEM_CLEANUP. + """ + try: + return await executor_service.resolve_orphaned_position(executor_id) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error resolving orphaned position for {executor_id}: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Error resolving orphaned position: {str(e)}") + + @router.get("/positions/summary", response_model=PositionsSummaryResponse) async def get_positions_summary( controller_id: Optional[str] = None, diff --git a/services/executor_service.py b/services/executor_service.py index 21c235c1..8288daee 100644 --- a/services/executor_service.py +++ b/services/executor_service.py @@ -629,6 +629,31 @@ async def stop_executor( """ executor = self._active_executors.get(executor_id) if not executor: + # Terminal executors are popped from memory within one control-loop tick, + # so "not in memory" usually means "already terminated", not "unknown". + # Fall back to the DB and answer with the final state as a no-op success. + # This deliberately includes rows still marked RUNNING in the DB: an + # executor known to the DB but absent from memory is dead regardless of + # its stored status (completion race, failed persist, restart window), + # and answering 404 there is the gateway#678 dead end. 404 is reserved + # for executor ids the DB has never seen (or a DB outage, which + # get_executor logs and swallows to None). + db_record = await self.get_executor(executor_id) + if db_record: + custom_info = db_record.get("custom_info") or {} + logger.info( + f"Stop requested for already-terminated executor {executor_id} " + f"(db status: {db_record.get('status')}, close_type: {db_record.get('close_type')}) - no-op" + ) + return { + "executor_id": executor_id, + "status": "already_terminated", + "keep_position": keep_position, + "close_type": db_record.get("close_type"), + "position_address": custom_info.get("position_address"), + "orphaned_position": bool(custom_info.get("orphaned_position", False)), + "hold_reason": custom_info.get("hold_reason"), + } raise HTTPException(status_code=404, detail=f"Executor {executor_id} not found") if executor.is_closed: @@ -649,6 +674,116 @@ async def stop_executor( "keep_position": keep_position } + async def get_orphaned_positions(self) -> List[Dict[str, Any]]: + """ + List executors that terminated while potentially still owning an on-chain position. + + Covers both orphan classes: + - close_type FAILED with a position_address in the persisted final state + (e.g. an LP close that exhausted retries - gateway#678) + - close_type SYSTEM_CLEANUP (RUNNING rows rewritten after an API restart); + these have no final state, so the position address is unknown and the + on-chain state must be reconciled externally + + This listing is DB-side only: cross-check candidates against on-chain reality + (gateway CLMM/AMM positions-owned endpoints) before recovering. Recovered + orphans are silenced with resolve_orphaned_position(). + + Raises on DB errors rather than returning [] - "no orphans" from a broken DB + would read as all-clear on a safety endpoint. + """ + if not self.db_manager: + raise RuntimeError("Orphan listing unavailable: no database configured") + + # lp_executor is the only executor type that owns an on-chain position + # account; filtering in SQL keeps the limit meaningful (a Python-side filter + # over the newest N mixed candidates can silently drop older real orphans). + async with self.db_manager.get_session_context() as session: + repo = ExecutorRepository(session) + records = await repo.get_executors_by_close_types( + ["FAILED", "SYSTEM_CLEANUP", "POSITION_HOLD"], executor_type="lp_executor" + ) + + orphans: List[Dict[str, Any]] = [] + for record in records: + final_state: Dict[str, Any] = {} + if record.final_state: + try: + final_state = json.loads(record.final_state) + except (json.JSONDecodeError, TypeError): + final_state = {} + + if final_state.get("orphan_resolved"): + continue + + position_address = final_state.get("position_address") + if record.close_type == "FAILED" and not position_address: + # Failed without on-chain exposure - not an orphan + continue + if record.close_type == "POSITION_HOLD" and not ( + final_state.get("orphaned_position") or final_state.get("hold_reason") + ): + # Voluntary hold (keep_position=True stop) - position was closed on-chain + continue + + orphans.append({ + "executor_id": record.executor_id, + "executor_type": record.executor_type, + "account_name": record.account_name, + "connector_name": record.connector_name, + "trading_pair": record.trading_pair, + "controller_id": record.controller_id or "main", + "close_type": record.close_type, + "closed_at": record.closed_at.isoformat() if record.closed_at else None, + "position_address": position_address, + "state": final_state.get("state"), + "hold_reason": final_state.get("hold_reason"), + "needs_onchain_reconciliation": position_address is None, + }) + + return orphans + + async def resolve_orphaned_position(self, executor_id: str) -> Dict[str, Any]: + """ + Mark an orphaned position as recovered so it stops surfacing. + + Call this after the stranded on-chain position has been closed (or adopted) + externally. Sets orphan_resolved in the persisted final state, which removes + the record from get_orphaned_positions() and from agent-facing warnings. + """ + if not self.db_manager: + raise HTTPException(status_code=503, detail="No database configured") + + async with self.db_manager.get_session_context() as session: + repo = ExecutorRepository(session) + record = await repo.get_executor_by_id(executor_id) + if not record: + raise HTTPException(status_code=404, detail=f"Executor {executor_id} not found") + if record.status == "RUNNING": + raise HTTPException(status_code=400, detail=f"Executor {executor_id} is still running") + + final_state: Dict[str, Any] = {} + if record.final_state: + try: + final_state = json.loads(record.final_state) + except (json.JSONDecodeError, TypeError): + final_state = {} + + is_involuntary_hold = record.close_type == "POSITION_HOLD" and ( + final_state.get("orphaned_position") or final_state.get("hold_reason") + ) + if record.close_type not in ("FAILED", "SYSTEM_CLEANUP") and not is_involuntary_hold: + raise HTTPException( + status_code=400, + detail=f"Executor {executor_id} (close_type: {record.close_type}) is not an orphan candidate", + ) + final_state["orphaned_position"] = False + final_state["orphan_resolved"] = True + await repo.update_executor(executor_id=executor_id, final_state=json.dumps(final_state)) + + logger.info(f"Orphaned position for executor {executor_id} marked resolved") + return {"executor_id": executor_id, "orphan_resolved": True} + async def _handle_executor_completion(self, executor_id: str): """Handle cleanup when an executor completes.""" # Atomically claim the executor so a concurrent completion (e.g. the @@ -679,6 +814,24 @@ async def _handle_executor_completion(self, executor_id: str): close_type = executor.close_type.name if executor.close_type else "UNKNOWN" logger.info(f"Executor {executor_id} completed with close_type: {close_type}") + # Surface stranded on-chain exposure loudly: a live position address on an + # involuntary hold (hold_reason set) or a legacy FAILED means the position + # has no automated owner from this point on. + if executor.close_type in (CloseType.FAILED, CloseType.POSITION_HOLD): + try: + completion_info = executor.get_custom_info() + position_address = completion_info.get("position_address") + hold_reason = completion_info.get("hold_reason") + except Exception: + position_address = None + hold_reason = None + if position_address and (hold_reason or executor.close_type == CloseType.FAILED): + logger.error( + f"Executor {executor_id} ended {close_type} with position {position_address} " + f"still open on-chain (hold_reason: {hold_reason}) - orphaned position requires " + "recovery (flagged in DB record; see /executors/positions/orphaned)" + ) + def _format_executor_info( self, executor_id: str, @@ -996,6 +1149,17 @@ async def _persist_executor_completed(self, executor_id: str, executor: Executor # Get custom_info directly from executor to avoid Pydantic serialization issues # with TrackedOrder and other complex types custom_info = executor.get_custom_info() + + # A stranded live on-chain position: an involuntary hold (close retries + # exhausted -> POSITION_HOLD with hold_reason set, gateway#678) or a legacy + # FAILED-with-position (force-stop straggler, older wheel). Flag it in the + # persisted final_state so /executors/positions/orphaned, dashboards, and + # agents can find and recover it. Voluntary holds never match: a successful + # close clears position_address before the executor terminates. + if custom_info.get("position_address") and ( + custom_info.get("hold_reason") or close_type == "FAILED" + ): + custom_info["orphaned_position"] = True # Serialize custom_info, fallback to None if serialization fails final_state_json = None metadata = self._executor_metadata.get(executor_id, {}) From 4fa3172266019bb185f998220c71b6ffd696bf14 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Thu, 13 Aug 2026 09:57:23 -0700 Subject: [PATCH 02/54] feat(clmm): pass bin_count through to Gateway pool-info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /gateway/clmm/pool-info accepts bin_count and forwards it to Gateway's unified trading/clmm/pool-info, so the per-tick liquidity distribution is reachable for orca, raydium, uniswap and pancakeswap (Meteora always returns its own bins). The response model already carried bins. Requests with bin_count > 0 skip the direct-Raydium-API shortcut: that API returns no bin distribution, and only Gateway computes it from on-chain ticks. Also wraps pre-existing long lines and drops an unused import in routers/gateway_clmm.py — the flake8 pre-commit hook lints the whole file and would not otherwise accept a commit touching it. Co-Authored-By: Claude Fable 5 --- routers/gateway_clmm.py | 59 ++++++++++++++++++++++++-------------- services/gateway_client.py | 17 ++++++++--- 2 files changed, 51 insertions(+), 25 deletions(-) diff --git a/routers/gateway_clmm.py b/routers/gateway_clmm.py index 7e9c92ec..c3ec25b0 100644 --- a/routers/gateway_clmm.py +++ b/routers/gateway_clmm.py @@ -26,7 +26,6 @@ CLMMPositionInfo, CLMMPositionsOwnedRequest, CLMMRemoveLiquidityRequest, - TimeBasedMetrics, ) from services.accounts_service import AccountsService from services.gateway_client import GatewayError, check_gateway_error @@ -258,7 +257,7 @@ async def _refresh_position_data(position, accounts_service: AccountsService, cl ) logger.debug(f"Refreshed position {position.position_address}: price={current_price}, in_range={in_range}, " - f"base={base_token_amount}, quote={quote_token_amount}") + f"base={base_token_amount}, quote={quote_token_amount}") except Exception as e: logger.error(f"Error refreshing position {position.position_address}: {e}", exc_info=True) @@ -270,6 +269,7 @@ async def get_clmm_pool_info( connector: str, network: str, pool_address: str, + bin_count: int = 0, accounts_service: AccountsService = Depends(get_accounts_service) ): """ @@ -279,20 +279,27 @@ async def get_clmm_pool_info( connector: CLMM connector (e.g., 'meteora', 'raydium') network: Network ID in 'chain-network' format (e.g., 'solana-mainnet-beta') pool_address: Pool contract address + bin_count: If > 0, include the per-tick liquidity distribution (`bins`) + around the active tick. Meteora always returns its bins and ignores + this; orca, raydium, uniswap and pancakeswap honour it. Example: - GET /gateway/clmm/pool-info?connector=meteora&network=solana-mainnet-beta&pool_address=2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3 + GET /gateway/clmm/pool-info?connector=meteora&network=solana-mainnet-beta + &pool_address=2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3 Returns: Pool information including liquidity, price, bins (for Meteora), etc. All field names are returned in snake_case format. Note: - For Raydium connector, uses Raydium API directly instead of Gateway. + For Raydium connector, uses Raydium API directly instead of Gateway, + except when bin_count > 0 — the Raydium API returns no bin distribution, + so those requests go to Gateway, which computes it from on-chain ticks. """ try: - # Special handling for Raydium - use Raydium API directly (not Gateway) - if connector.lower() == "raydium": + # Special handling for Raydium - use Raydium API directly (not Gateway). + # Skipped when bins are requested: only Gateway can produce them. + if connector.lower() == "raydium" and not bin_count: logger.info(f"Using Raydium API directly for pool info: {pool_address}") # Fetch from Raydium API @@ -323,7 +330,8 @@ async def get_clmm_pool_info( result = check_gateway_error(await accounts_service.gateway_client.clmm_pool_info( connector=connector, chain_network=network, - pool_address=pool_address + pool_address=pool_address, + bin_count=bin_count )) # Parse the camelCase Gateway response into snake_case Pydantic model @@ -520,7 +528,8 @@ async def open_clmm_position( # Position address can be at root level or nested in data object data = result.get("data", {}) - position_address = result.get("positionAddress") or result.get("position") or data.get("positionAddress") or data.get("position") + position_address = (result.get("positionAddress") or result.get("position") + or data.get("positionAddress") or data.get("position")) # Extract position rent (SOL locked for position NFT) position_rent = data.get("positionRent") @@ -590,7 +599,8 @@ async def open_clmm_position( } await clmm_repo.create_event(event_data) - logger.info(f"Recorded CLMM OPEN event in database: {transaction_hash} (status: {tx_status}, gas: {gas_fee} {gas_token})") + logger.info(f"Recorded CLMM OPEN event in database: {transaction_hash} " + f"(status: {tx_status}, gas: {gas_fee} {gas_token})") except Exception as db_error: # Log but don't fail the operation - it was submitted successfully logger.error(f"Error recording CLMM position in database: {db_error}", exc_info=True) @@ -692,7 +702,8 @@ async def add_liquidity_to_clmm_position( "status": tx_status } await clmm_repo.create_event(event_data) - logger.info(f"Recorded CLMM ADD_LIQUIDITY event: {transaction_hash} (status: {tx_status}, gas: {gas_fee} {gas_token})") + logger.info(f"Recorded CLMM ADD_LIQUIDITY event: {transaction_hash} " + f"(status: {tx_status}, gas: {gas_fee} {gas_token})") except Exception as db_error: logger.error(f"Error recording ADD_LIQUIDITY event: {db_error}", exc_info=True) @@ -784,7 +795,8 @@ async def remove_liquidity_from_clmm_position( "status": tx_status } await clmm_repo.create_event(event_data) - logger.info(f"Recorded CLMM REMOVE_LIQUIDITY event: {transaction_hash} (status: {tx_status}, gas: {gas_fee} {gas_token})") + logger.info(f"Recorded CLMM REMOVE_LIQUIDITY event: {transaction_hash} " + f"(status: {tx_status}, gas: {gas_fee} {gas_token})") except Exception as db_error: logger.error(f"Error recording REMOVE_LIQUIDITY event: {db_error}", exc_info=True) @@ -876,7 +888,8 @@ async def close_clmm_position( base_fee_to_collect = Decimal(str(pos.get("baseFeeAmount", 0))) quote_fee_to_collect = Decimal(str(pos.get("quoteFeeAmount", 0))) close_price = float(pos.get("price", 0)) if pos.get("price") else None - logger.info(f"Before closing: price={close_price}, pending fees base={base_fee_to_collect}, quote={quote_fee_to_collect}") + logger.info(f"Before closing: price={close_price}, pending fees " + f"base={base_fee_to_collect}, quote={quote_fee_to_collect}") break else: logger.warning(f"Could not find position {request.position_address} in positions_owned response") @@ -909,7 +922,8 @@ async def close_clmm_position( # Use response values if available, otherwise use pre-fetched values base_fee_collected = Decimal(str(base_fee_from_response)) if base_fee_from_response is not None else base_fee_to_collect - quote_fee_collected = Decimal(str(quote_fee_from_response)) if quote_fee_from_response is not None else quote_fee_to_collect + quote_fee_collected = (Decimal(str(quote_fee_from_response)) + if quote_fee_from_response is not None else quote_fee_to_collect) logger.info(f"Collected fees on close: base={base_fee_collected}, quote={quote_fee_collected}") @@ -933,7 +947,8 @@ async def close_clmm_position( "status": tx_status } await clmm_repo.create_event(event_data) - logger.info(f"Recorded CLMM CLOSE event: {transaction_hash} (status: {tx_status}, gas: {gas_fee} {gas_token})") + logger.info(f"Recorded CLMM CLOSE event: {transaction_hash} " + f"(status: {tx_status}, gas: {gas_fee} {gas_token})") # Update position: add to collected, reset pending to 0, mark as CLOSED new_base_collected = Decimal(str(position.base_fee_collected)) + base_fee_collected @@ -972,14 +987,16 @@ async def close_clmm_position( status_code = verify_result.get("status") if status_code in (404, 500): await clmm_repo.close_position(request.position_address) - logger.info(f"Position {request.position_address} verified as closed (Gateway returned {status_code})") + logger.info(f"Position {request.position_address} verified as closed " + f"(Gateway returned {status_code})") else: logger.warning(f"Unexpected error verifying position close: {verify_result}") elif verify_result and "address" in verify_result: # Position still exists - might be a failed close or delayed propagation - logger.warning(f"Position {request.position_address} still exists after close transaction. Will be handled by poller.") + logger.warning(f"Position {request.position_address} still exists after close " + "transaction. Will be handled by poller.") else: - logger.debug(f"Could not verify position close status, will be handled by poller") + logger.debug("Could not verify position close status, will be handled by poller") except Exception as verify_error: logger.warning(f"Error verifying position close: {verify_error}. Will be handled by poller.") @@ -1104,7 +1121,8 @@ async def collect_fees_from_clmm_position( # Use response values if available, otherwise use pre-fetched values base_fee_collected = Decimal(str(base_fee_from_response)) if base_fee_from_response is not None else base_fee_to_collect - quote_fee_collected = Decimal(str(quote_fee_from_response)) if quote_fee_from_response is not None else quote_fee_to_collect + quote_fee_collected = (Decimal(str(quote_fee_from_response)) + if quote_fee_from_response is not None else quote_fee_to_collect) # Extract gas fee from Gateway response gas_fee = data.get("fee") @@ -1132,7 +1150,8 @@ async def collect_fees_from_clmm_position( "status": tx_status } await clmm_repo.create_event(event_data) - logger.info(f"Recorded CLMM COLLECT_FEES event: {transaction_hash} (status: {tx_status}, gas: {gas_fee} {gas_token})") + logger.info(f"Recorded CLMM COLLECT_FEES event: {transaction_hash} " + f"(status: {tx_status}, gas: {gas_fee} {gas_token})") # Update position: add to collected, reset pending to 0 new_base_collected = Decimal(str(position.base_fee_collected)) + base_fee_collected @@ -1405,5 +1424,3 @@ async def search_clmm_positions( except Exception as e: logger.error(f"Error searching CLMM positions: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"Error searching CLMM positions: {str(e)}") - - diff --git a/services/gateway_client.py b/services/gateway_client.py index cc15d8d1..dc3848a6 100644 --- a/services/gateway_client.py +++ b/services/gateway_client.py @@ -672,14 +672,23 @@ async def clmm_pool_info( self, connector: str, chain_network: str, - pool_address: str + pool_address: str, + bin_count: int = 0 ) -> Dict: - """Get detailed CLMM pool information by pool address""" - return await self._request("GET", "trading/clmm/pool-info", params={ + """Get detailed CLMM pool information by pool address. + + bin_count > 0 asks Gateway for the per-tick liquidity distribution + (`bins`) around the active tick. Meteora always returns its bins and + ignores the parameter; orca, raydium, uniswap and pancakeswap honour it. + """ + params = { "connector": connector, "chainNetwork": chain_network, "poolAddress": pool_address - }) + } + if bin_count: + params["binCount"] = bin_count + return await self._request("GET", "trading/clmm/pool-info", params=params) async def clmm_fetch_pools( self, From c4ebae4eb45b7973c48a472c806c6e8396125894 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Thu, 13 Aug 2026 10:17:53 -0700 Subject: [PATCH 03/54] refactor(clmm): route Raydium pool-info through Gateway like every other connector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /gateway/clmm/pool-info special-cased Raydium: it skipped Gateway entirely, called api-v3.raydium.io directly, and reshaped that response to look like Gateway's. That divergence cost real data — the transform hardcoded active_bin_id to None, bin_step to 1, and bins to [] — and it meant Raydium could not answer bin_count at all, since only Gateway computes the tick distribution. Raydium now takes the same path as meteora/orca/uniswap/pancakeswap. The Raydium API helpers and their aiohttp import go with it. Co-Authored-By: Claude Fable 5 --- routers/gateway_clmm.py | 119 ---------------------------------------- 1 file changed, 119 deletions(-) diff --git a/routers/gateway_clmm.py b/routers/gateway_clmm.py index c3ec25b0..4b8a91a2 100644 --- a/routers/gateway_clmm.py +++ b/routers/gateway_clmm.py @@ -7,7 +7,6 @@ from decimal import Decimal from typing import List, Optional -import aiohttp from fastapi import APIRouter, Depends, HTTPException, Query from database import AsyncDatabaseManager @@ -35,94 +34,6 @@ router = APIRouter(tags=["Gateway CLMM"], prefix="/gateway") -async def fetch_raydium_pool_info(pool_address: str) -> Optional[dict]: - """ - Fetch pool info from Raydium API. - - Args: - pool_address: Pool contract address - - Returns: - Dictionary with pool info from Raydium API, or None if failed - """ - try: - url = f"https://api-v3.raydium.io/pools/info/ids?ids={pool_address}" - async with aiohttp.ClientSession() as session: - async with session.get(url, headers={"accept": "application/json"}) as response: - response.raise_for_status() - data = await response.json() - - if not data.get("success"): - logger.error(f"Raydium API returned unsuccessful response: {data}") - return None - - # Extract the first pool from the data list - pools_data = data.get("data", []) - if not pools_data: - logger.error(f"Raydium API returned empty data for pool: {pool_address}") - return None - - # Return the pool data directly (not wrapped in data key) - return pools_data[0] - except aiohttp.ClientError as e: - logger.error(f"Failed to fetch pool info from Raydium API: {e}") - return None - except Exception as e: - logger.error(f"Error fetching Raydium pool info: {e}", exc_info=True) - return None - - -def transform_raydium_to_clmm_response(raydium_data: dict, pool_address: str) -> dict: - """ - Transform Raydium API response to match Gateway's CLMMPoolInfoResponse format. - - Args: - raydium_data: Pool data from Raydium API (pools/info/ids endpoint) - pool_address: Pool contract address - - Returns: - Dictionary matching Gateway's pool info structure - """ - # Extract token info - mint_a = raydium_data.get("mintA", {}) - mint_b = raydium_data.get("mintB", {}) - - base_token_address = mint_a.get("address", "") - quote_token_address = mint_b.get("address", "") - - # Get current price - current_price = Decimal(str(raydium_data.get("price", 0))) - - # Get token amounts - base_amount = Decimal(str(raydium_data.get("mintAmountA", 0))) - quote_amount = Decimal(str(raydium_data.get("mintAmountB", 0))) - - # Get fee rate (convert from decimal to percentage, e.g., 0.0025 -> 0.25%) - fee_rate = raydium_data.get("feeRate", 0.0025) - fee_pct = Decimal(str(fee_rate * 100)) - - # Check if this is a CLMM (Concentrated) pool - pool_type = raydium_data.get("type", "Standard") - is_clmm = pool_type == "Concentrated" - - # Return in Gateway-compatible format - return { - "address": pool_address, - "baseTokenAddress": base_token_address, - "quoteTokenAddress": quote_token_address, - "binStep": 1 if is_clmm else None, # CLMM pools have tick spacing - "feePct": fee_pct, - "price": current_price, - "baseTokenAmount": base_amount, - "quoteTokenAmount": quote_amount, - "activeBinId": None, # Not available from this endpoint - "dynamicFeePct": None, - "minBinId": None, - "maxBinId": None, - "bins": [] # Bin data not available from pool info endpoint - } - - def get_transaction_status_from_response(gateway_response: dict) -> str: """ Determine transaction status from Gateway response. @@ -291,38 +202,8 @@ async def get_clmm_pool_info( Pool information including liquidity, price, bins (for Meteora), etc. All field names are returned in snake_case format. - Note: - For Raydium connector, uses Raydium API directly instead of Gateway, - except when bin_count > 0 — the Raydium API returns no bin distribution, - so those requests go to Gateway, which computes it from on-chain ticks. """ try: - # Special handling for Raydium - use Raydium API directly (not Gateway). - # Skipped when bins are requested: only Gateway can produce them. - if connector.lower() == "raydium" and not bin_count: - logger.info(f"Using Raydium API directly for pool info: {pool_address}") - - # Fetch from Raydium API - raydium_data = await fetch_raydium_pool_info(pool_address) - if raydium_data is None: - raise HTTPException(status_code=503, detail="Failed to get pool info from Raydium API") - - # Check if this is a CLMM pool - Standard AMM pools are not supported on this endpoint - pool_type = raydium_data.get("type", "Standard") - if pool_type != "Concentrated": - raise HTTPException( - status_code=400, - detail=f"Pool {pool_address} is a Raydium {pool_type} AMM pool, not a CLMM pool. " - f"This endpoint only supports Concentrated Liquidity (CLMM) pools." - ) - - # Transform to Gateway-compatible format - result = transform_raydium_to_clmm_response(raydium_data, pool_address) - - # Parse into response model - return CLMMPoolInfoResponse(**result) - - # Default behavior for other connectors: use Gateway if not await accounts_service.gateway_client.ping(): raise HTTPException(status_code=503, detail="Gateway service is not available") From d045d01e22da827dc5e1ddfa39fe6b459665eb52 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Mon, 17 Aug 2026 06:49:12 -0700 Subject: [PATCH 04/54] feat(clmm): make orphaned LP positions closable through the API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An lp_executor that exhausts its close retries terminates as an involuntary POSITION_HOLD with the position still on-chain. Recovering it means closing that position by address — which this API could not do. Two things blocked it: - /gateway/clmm/close and /collect-fees read the position's pool only from the gateway_clmm_positions table, and 404'd when it was absent. An lp_executor opens its position straight from the bot to Gateway, so it is never in that table: on a live deployment the table was empty and every orphan 404'd. Both endpoints now accept pool_address on the request, resolving database-first and erroring with 400 (a bad request, not a missing position) naming pool_address as the fix. Gateway's close needs only position_address; pool_address is used to snapshot pending fees before the close so they can be reported. - /executors/positions/orphaned reported connector_name and trading_pair but not the DEX or the pool, so a caller had nothing to build a close from. Note connector_name holds the *network* for an lp_executor ("solana-mainnet-beta"). The DEX ("orca/clmm") and pool live in the executor's stored config; both are now surfaced as lp_provider and pool_address. Also wraps two pre-existing over-length lines in gateway_trading.py, which the whole-file flake8 hook fails on. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Mt84XBEMVxbbyMG8fDxDKj --- models/executors.py | 16 +++++++++++++++- models/gateway_trading.py | 19 +++++++++++++++++-- routers/gateway_clmm.py | 27 +++++++++++++++++++++------ services/executor_service.py | 13 +++++++++++++ 4 files changed, 66 insertions(+), 9 deletions(-) diff --git a/models/executors.py b/models/executors.py index c261d5f2..c1e5f774 100644 --- a/models/executors.py +++ b/models/executors.py @@ -442,8 +442,22 @@ class OrphanedPositionRecord(BaseModel): executor_id: str = Field(description="Executor identifier") executor_type: str = Field(description="Executor type (e.g. lp_executor)") account_name: Optional[str] = Field(default=None, description="Account name") - connector_name: Optional[str] = Field(default=None, description="Connector name") + connector_name: Optional[str] = Field( + default=None, + description="Connector name. For lp_executor this is the network id (e.g. 'solana-mainnet-beta'), " + "not the DEX - see lp_provider for the DEX" + ) trading_pair: Optional[str] = Field(default=None, description="Trading pair") + lp_provider: Optional[str] = Field( + default=None, + description="DEX connector that holds the position (e.g. 'orca/clmm'), read from the executor config. " + "Pass its base name to the CLMM close endpoint" + ) + pool_address: Optional[str] = Field( + default=None, + description="Pool the position was opened against, read from the executor config. Required to close a " + "position that was never recorded in the API database (LP-executor positions never are)" + ) controller_id: str = Field(default="main", description="Controller/agent grouping label") close_type: Optional[str] = Field( default=None, description="POSITION_HOLD (involuntary hold), FAILED, or SYSTEM_CLEANUP" diff --git a/models/gateway_trading.py b/models/gateway_trading.py index c9cb9c48..5330710d 100644 --- a/models/gateway_trading.py +++ b/models/gateway_trading.py @@ -13,6 +13,7 @@ # Swap Models (Router: Jupiter, 0x) # ============================================ + class SwapQuoteRequest(BaseModel): """Request for swap price quote""" connector: str = Field(description="DEX router connector (e.g., 'jupiter', '0x')") @@ -29,8 +30,12 @@ class SwapQuoteResponse(BaseModel): quote: str = Field(description="Quote token symbol") price: Decimal = Field(description="Quoted price (base/quote)") amount: Decimal = Field(description="Amount specified in request (BUY: base amount to receive, SELL: base amount to sell)") - amount_in: Optional[Decimal] = Field(default=None, description="Actual input amount (BUY: quote to spend, SELL: base to sell)") - amount_out: Optional[Decimal] = Field(default=None, description="Actual output amount (BUY: base to receive, SELL: quote to receive)") + amount_in: Optional[Decimal] = Field( + default=None, description="Actual input amount (BUY: quote to spend, SELL: base to sell)" + ) + amount_out: Optional[Decimal] = Field( + default=None, description="Actual output amount (BUY: base to receive, SELL: quote to receive)" + ) expected_amount: Optional[Decimal] = Field(default=None, description="Deprecated: use amount_out instead") slippage_pct: Decimal = Field(description="Applied slippage percentage") gas_estimate: Optional[Decimal] = Field(default=None, description="Estimated gas cost") @@ -116,6 +121,11 @@ class CLMMClosePositionRequest(BaseModel): connector: str = Field(description="CLMM connector (e.g., 'meteora', 'raydium', 'uniswap')") network: str = Field(description="Network ID in 'chain-network' format (e.g., 'solana-mainnet-beta')") position_address: str = Field(description="Position address to close") + pool_address: Optional[str] = Field( + default=None, + description="Pool the position belongs to. Only needed for positions this API never recorded " + "(e.g. opened by an lp_executor straight against Gateway); otherwise read from the database" + ) wallet_address: Optional[str] = Field(default=None, description="Wallet address (optional, uses default if not provided)") @@ -124,6 +134,11 @@ class CLMMCollectFeesRequest(BaseModel): connector: str = Field(description="CLMM connector (e.g., 'meteora', 'raydium', 'uniswap')") network: str = Field(description="Network ID in 'chain-network' format (e.g., 'solana-mainnet-beta')") position_address: str = Field(description="Position address to collect fees from") + pool_address: Optional[str] = Field( + default=None, + description="Pool the position belongs to. Only needed for positions this API never recorded " + "(e.g. opened by an lp_executor straight against Gateway); otherwise read from the database" + ) wallet_address: Optional[str] = Field(default=None, description="Wallet address (optional, uses default if not provided)") diff --git a/routers/gateway_clmm.py b/routers/gateway_clmm.py index 4b8a91a2..e45fd005 100644 --- a/routers/gateway_clmm.py +++ b/routers/gateway_clmm.py @@ -742,11 +742,19 @@ async def close_clmm_position( wallet_address=request.wallet_address ) - # If no pool_address from database, we can't query Gateway + # Positions this API never recorded (an lp_executor opens straight against Gateway) have no + # row to read the pool from, so accept it on the request. Gateway's close needs only + # position_address - pool_address is for the pre-close fee snapshot below. + pool_address = pool_address or request.pool_address + if not pool_address: raise HTTPException( - status_code=404, - detail=f"Position {request.position_address} not found in database. Pool address is required." + status_code=400, + detail=( + f"Position {request.position_address} is not in the database, so its pool is unknown. " + "Pass pool_address explicitly - LP-executor positions are never recorded here, and " + "/executors/positions/orphaned reports the pool for each orphan." + ) ) # Fetch pending fees and current price BEFORE closing (Gateway doesn't always return these in response) @@ -948,11 +956,18 @@ async def collect_fees_from_clmm_position( wallet_address=request.wallet_address ) - # If no pool_address from database, we can't query Gateway + # Positions this API never recorded (an lp_executor opens straight against Gateway) have no + # row to read the pool from, so accept it on the request. + pool_address = pool_address or request.pool_address + if not pool_address: raise HTTPException( - status_code=404, - detail=f"Position {request.position_address} not found in database. Pool address is required." + status_code=400, + detail=( + f"Position {request.position_address} is not in the database, so its pool is unknown. " + "Pass pool_address explicitly - LP-executor positions are never recorded here, and " + "/executors/positions/orphaned reports the pool for each orphan." + ) ) # Fetch pending fees BEFORE collecting (Gateway doesn't always return collected amounts in response) diff --git a/services/executor_service.py b/services/executor_service.py index 8288daee..46e58143 100644 --- a/services/executor_service.py +++ b/services/executor_service.py @@ -726,12 +726,25 @@ async def get_orphaned_positions(self) -> List[Dict[str, Any]]: # Voluntary hold (keep_position=True stop) - position was closed on-chain continue + # The DEX and pool live in the executor config, not in any column: for lp_executor the + # connector_name column carries the network id. Both are needed to close the position, + # and LP-executor positions are opened by the bot straight against Gateway so they are + # never in the API's own CLMM position table to be looked up there. + config: Dict[str, Any] = {} + if record.config: + try: + config = json.loads(record.config) + except (json.JSONDecodeError, TypeError): + config = {} + orphans.append({ "executor_id": record.executor_id, "executor_type": record.executor_type, "account_name": record.account_name, "connector_name": record.connector_name, "trading_pair": record.trading_pair, + "lp_provider": config.get("lp_provider"), + "pool_address": config.get("pool_address"), "controller_id": record.controller_id or "main", "close_type": record.close_type, "closed_at": record.closed_at.isoformat() if record.closed_at else None, From 664944f4fe00aa2ffcacffcdba0b6fd228c7eeb2 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Mon, 17 Aug 2026 11:46:14 -0700 Subject: [PATCH 05/54] chore(gateway): default the Gateway container to the development image The CLMM work on this branch depends on Gateway changes that ship in hummingbot/gateway#679 and are not in the `latest` tag, so a container started from the default image cannot serve the endpoints this branch calls. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Mt84XBEMVxbbyMG8fDxDKj --- models/gateway.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/models/gateway.py b/models/gateway.py index 87d6d862..9ab63269 100644 --- a/models/gateway.py +++ b/models/gateway.py @@ -19,7 +19,7 @@ class GatewayConfig(BaseModel): clients (which use ``CONFIG_PASSWORD``). The passphrase is therefore always ``CONFIG_PASSWORD``; a separate value would only break the API<->Gateway mTLS chain. """ - image: str = Field(default="hummingbot/gateway:latest", description="Docker image for Gateway") + image: str = Field(default="hummingbot/gateway:development", description="Docker image for Gateway") port: int = Field(default=15888, description="Port for Gateway API") From ff0a932cc79745bb65e8f33404a3eb105c33a4eb Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 18 Aug 2026 15:50:46 -0700 Subject: [PATCH 06/54] fix(clmm): drop the pool_address no-op from positions_owned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gateway's /trading/clmm/positions-owned takes no pool filter — its handler reads only connector, chainNetwork and walletAddress — so the pool_address this API required, forwarded, and documented as a filter never filtered anything: every caller always got the wallet's full position list labeled as one pool's. Remove the parameter end to end (request model, router, gateway_client) so the contract says what actually happens; each returned row carries its own pool_address for callers that want one pool. Includes flake8 fixes in gateway_transaction_poller.py that the pre-commit hook now enforces on the touched file. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- models/gateway_trading.py | 8 ++++++-- routers/gateway_clmm.py | 26 ++++++++++++-------------- services/gateway_client.py | 16 ++++++---------- services/gateway_transaction_poller.py | 17 +++++++++-------- 4 files changed, 33 insertions(+), 34 deletions(-) diff --git a/models/gateway_trading.py b/models/gateway_trading.py index 5330710d..7989b6dc 100644 --- a/models/gateway_trading.py +++ b/models/gateway_trading.py @@ -152,10 +152,14 @@ class CLMMCollectFeesResponse(BaseModel): class CLMMPositionsOwnedRequest(BaseModel): - """Request to get all CLMM positions owned by a wallet for a specific pool""" + """Request to get all CLMM positions owned by a wallet. + + Mirrors Gateway's /trading/clmm/positions-owned, which takes no pool filter — + every CLMM position the wallet owns on the connector is returned, each row + carrying its own pool_address. + """ connector: str = Field(description="CLMM connector (e.g., 'meteora', 'raydium', 'uniswap')") network: str = Field(description="Network ID in 'chain-network' format (e.g., 'solana-mainnet-beta')") - pool_address: str = Field(description="Pool contract address to filter positions") wallet_address: Optional[str] = Field(default=None, description="Wallet address (optional, uses default if not provided)") diff --git a/routers/gateway_clmm.py b/routers/gateway_clmm.py index e45fd005..dc2fac98 100644 --- a/routers/gateway_clmm.py +++ b/routers/gateway_clmm.py @@ -101,8 +101,7 @@ async def _refresh_position_data(position, accounts_service: AccountsService, cl positions_list = check_gateway_error(await accounts_service.gateway_client.clmm_positions_owned( connector=position.connector, chain_network=position.network, # position.network is already in 'chain-network' format - wallet_address=wallet_address, - pool_address=position.pool_address + wallet_address=wallet_address )) # Find our specific position in the list @@ -373,8 +372,7 @@ async def open_clmm_position( # opening a position without knowing its tokens would corrupt the position record. pool_info = check_gateway_error(await accounts_service.gateway_client.clmm_pool_info( connector=request.connector, - chain_network=request.network, - pool_address=request.pool_address + chain_network=request.network )) # Extract tokens from pool info @@ -766,8 +764,7 @@ async def close_clmm_position( positions_list = check_gateway_error(await accounts_service.gateway_client.clmm_positions_owned( connector=request.connector, chain_network=request.network, # request.network is already in 'chain-network' format - wallet_address=wallet_address, - pool_address=pool_address + wallet_address=wallet_address )) # Find our specific position and get pending fees and current price @@ -978,8 +975,7 @@ async def collect_fees_from_clmm_position( positions_list = check_gateway_error(await accounts_service.gateway_client.clmm_positions_owned( connector=request.connector, chain_network=request.network, # request.network is already in 'chain-network' format - wallet_address=wallet_address, - pool_address=pool_address + wallet_address=wallet_address )) # Find our specific position and get pending fees @@ -1089,16 +1085,20 @@ async def get_clmm_positions_owned( accounts_service: AccountsService = Depends(get_accounts_service) ): """ - Get all CLMM liquidity positions owned by a wallet for a specific pool. + Get all CLMM liquidity positions owned by a wallet. + + Mirrors Gateway's /trading/clmm/positions-owned, which takes no pool filter: + every CLMM position the wallet owns on the connector is returned, each row + carrying its own pool_address. (The old pool_address request field was a + silent no-op — Gateway never read it and the response was never filtered.) Example: connector: 'meteora' network: 'solana-mainnet-beta' - pool_address: '2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3' wallet_address: (optional, uses default if not provided) Returns: - List of CLMM position information for the specified pool + List of CLMM position information """ try: if not await accounts_service.gateway_client.ping(): @@ -1113,12 +1113,10 @@ async def get_clmm_positions_owned( wallet_address=request.wallet_address ) - # Get positions for the specified pool result = check_gateway_error(await accounts_service.gateway_client.clmm_positions_owned( connector=request.connector, chain_network=request.network, # request.network is already in 'chain-network' format - wallet_address=wallet_address, - pool_address=request.pool_address + wallet_address=wallet_address )) # Gateway returns a list directly diff --git a/services/gateway_client.py b/services/gateway_client.py index dc3848a6..3d983b3b 100644 --- a/services/gateway_client.py +++ b/services/gateway_client.py @@ -618,18 +618,19 @@ async def clmm_positions_owned( self, connector: str, chain_network: str, - wallet_address: str, - pool_address: Optional[str] = None + wallet_address: str ) -> List[Dict]: """ - Get CLMM positions owned by a wallet. + Get ALL CLMM positions owned by a wallet on a connector. + + Gateway's /trading/clmm/positions-owned takes no pool filter (its handler + reads only connector, chainNetwork and walletAddress); callers that care + about one pool filter the returned rows by their poolAddress field. Args: connector: CLMM connector (e.g., 'meteora', 'raydium') chain_network: Chain and network in format 'chain-network' (e.g., 'solana-mainnet-beta') wallet_address: Wallet address to query - pool_address: Optional pool address to filter positions. - If not provided, returns ALL positions across all pools. Returns: List of position dictionaries with fields like: @@ -646,11 +647,6 @@ async def clmm_positions_owned( "chainNetwork": chain_network, "walletAddress": wallet_address, } - - # Only add poolAddress if specified (allows fetching all positions) - if pool_address: - params["poolAddress"] = pool_address - return await self._request("GET", "trading/clmm/positions-owned", params=params) async def clmm_collect_fees( diff --git a/services/gateway_transaction_poller.py b/services/gateway_transaction_poller.py index 2781043e..ba6e254f 100644 --- a/services/gateway_transaction_poller.py +++ b/services/gateway_transaction_poller.py @@ -480,8 +480,7 @@ async def _discover_positions_from_gateway(self) -> int: gateway_positions = await self.gateway_client.clmm_positions_owned( connector=connector, chain_network=chain_network, - wallet_address=wallet_address, - pool_address=None # Get all positions across all pools + wallet_address=wallet_address ) if not gateway_positions or not isinstance(gateway_positions, list): @@ -509,7 +508,7 @@ async def _discover_positions_from_gateway(self) -> int: closed_positions.discard(position_address) open_positions.add(position_address) logger.warning(f"Reopened position {position_address} - " - f"was CLOSED in DB but still exists on-chain") + f"was CLOSED in DB but still exists on-chain") continue # Create new position in database @@ -525,7 +524,7 @@ async def _discover_positions_from_gateway(self) -> int: discovered_count += 1 open_positions.add(position_address) logger.info(f"Discovered new position: {position_address} " - f"(pool: {pos_data.get('poolAddress', 'unknown')[:16]}...)") + f"(pool: {pos_data.get('poolAddress', 'unknown')[:16]}...)") except Exception as e: logger.warning(f"Error discovering positions for {connector}/{chain}/{wallet_address}: {e}") @@ -731,11 +730,13 @@ async def _refresh_position_state(self, position: GatewayCLMMPosition, clmm_repo # Gateway returns 500 instead of 404 when position doesn't exist (closed) # Treat any error (404 or 500) on position-info as "position closed" if status_code in (404, 500): - logger.info(f"Position {position.position_address} not found on Gateway (status: {status_code}), marking as CLOSED") + logger.info(f"Position {position.position_address} not found on Gateway " + f"(status: {status_code}), marking as CLOSED") await clmm_repo.close_position(position.position_address) return # Other errors → skip update, don't close - logger.debug(f"Gateway error for position {position.position_address}: {result.get('error')} (status: {status_code})") + logger.debug(f"Gateway error for position {position.position_address}: " + f"{result.get('error')} (status: {status_code})") return # Validate response has required fields @@ -798,8 +799,8 @@ async def _refresh_position_state(self, position: GatewayCLMMPosition, clmm_repo ) logger.debug(f"Refreshed position {position.position_address}: price={current_price}, in_range={in_range}, " - f"base={base_token_amount}, quote={quote_token_amount}, " - f"base_fee={base_fee_pending}, quote_fee={quote_fee_pending}") + f"base={base_token_amount}, quote={quote_token_amount}, " + f"base_fee={base_fee_pending}, quote_fee={quote_fee_pending}") except Exception as e: logger.error(f"Error refreshing position state {position.position_address}: {e}", exc_info=True) From 432d59e068cad79eda946bf8a34bb13633354e1d Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 18 Aug 2026 16:08:01 -0700 Subject: [PATCH 07/54] fix(clmm): close the contract gaps against Gateway's unified trading routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit fixes, most severe first: - close: read the confirmed transaction data Gateway returns — the removed base/quote amounts and positionRentRefunded — record them on the CLOSE event and surface them in a new CLMMClosePositionResponse. The rent tracked as locked at open was never reconciled as refunded. - open/add/remove: prefer Gateway's confirmed on-chain amounts (baseTokenAmountAdded/Removed) over the requested amounts when persisting and responding; requested amounts remain the submitted-not-confirmed fallback. Also fixes REMOVE_LIQUIDITY events never persisting: the event payload carried a "percentage" key GatewayCLMMEvent has no column for, so create_event raised into the log-and-continue handler on every call. - new endpoints mirroring Gateway routes hapi never exposed: POST /gateway/clmm/quote-position (pre-trade deposit split), POST /gateway/clmm/create-pool (CLMM pools; AMM had this, CLMM did not), GET /gateway/clmm/position-info (single position by address). - positions_owned/position-info: pass through rewardTokenAddress / rewardAmount (farm rewards; populated by pancakeswap-sol today) instead of dropping them. - amm create-pool: expose openTime (Raydium CPMM) and slippagePct (Uniswap seeding) which Gateway accepts. - open: reject extra_params keys other than strategyType with a 400 — Gateway's unified open silently ignores everything else. - drop dead surface: dynamicFeePct/minBinId/maxBinId on pool-info (Gateway's declared response schema strips them before serialization; nothing consumes them) and the camelCase pageSize field (renamed page_size; no consumers). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- models/__init__.py | 8 ++ models/gateway_trading.py | 91 ++++++++++++- routers/gateway_amm.py | 2 + routers/gateway_clmm.py | 260 +++++++++++++++++++++++++++++++++++-- services/gateway_client.py | 79 +++++++++++ 5 files changed, 420 insertions(+), 20 deletions(-) diff --git a/models/__init__.py b/models/__init__.py index 0fbe6a8a..5991d18b 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -107,8 +107,10 @@ AMMTransactionResponse, CLMMAddLiquidityRequest, CLMMClosePositionRequest, + CLMMClosePositionResponse, CLMMCollectFeesRequest, CLMMCollectFeesResponse, + CLMMCreatePoolRequest, CLMMGetPositionInfoRequest, CLMMOpenPositionRequest, CLMMOpenPositionResponse, @@ -119,6 +121,8 @@ CLMMPoolListResponse, CLMMPositionInfo, CLMMPositionsOwnedRequest, + CLMMQuotePositionRequest, + CLMMQuotePositionResponse, CLMMRemoveLiquidityRequest, GetPoolInfoRequest, PoolInfo, @@ -341,8 +345,12 @@ "CLMMRemoveLiquidityRequest", "CLMMClosePositionRequest", "CLMMCollectFeesRequest", + "CLMMClosePositionResponse", + "CLMMCreatePoolRequest", "CLMMCollectFeesResponse", "CLMMPositionsOwnedRequest", + "CLMMQuotePositionRequest", + "CLMMQuotePositionResponse", "CLMMPositionInfo", "CLMMGetPositionInfoRequest", "CLMMPoolInfoRequest", diff --git a/models/gateway_trading.py b/models/gateway_trading.py index 7989b6dc..3185c347 100644 --- a/models/gateway_trading.py +++ b/models/gateway_trading.py @@ -93,6 +93,14 @@ class CLMMOpenPositionResponse(BaseModel): pool_address: str = Field(description="Pool address") lower_price: Decimal = Field(description="Lower price bound") upper_price: Decimal = Field(description="Upper price bound") + base_token_amount_added: Optional[Decimal] = Field( + default=None, + description="Base amount actually added on-chain (confirmed txs only; the requested amount otherwise)") + quote_token_amount_added: Optional[Decimal] = Field( + default=None, + description="Quote amount actually added on-chain (confirmed txs only; the requested amount otherwise)") + position_rent: Optional[Decimal] = Field( + default=None, description="Native token locked as rent for the position account (refunded on close)") status: str = Field(default="submitted", description="Transaction status") @@ -151,6 +159,72 @@ class CLMMCollectFeesResponse(BaseModel): status: str = Field(default="submitted", description="Transaction status") +class CLMMClosePositionResponse(CLMMCollectFeesResponse): + """Response after closing a position: fees collected plus what the close returned. + + The removed amounts and rent refund come from Gateway's confirmed transaction data, + so they are None for submitted-not-confirmed transactions. + """ + base_token_amount_removed: Optional[Decimal] = Field( + default=None, description="Base liquidity actually withdrawn on-chain") + quote_token_amount_removed: Optional[Decimal] = Field( + default=None, description="Quote liquidity actually withdrawn on-chain") + position_rent_refunded: Optional[Decimal] = Field( + default=None, description="Native token rent refunded when the position account closed") + + +class CLMMQuotePositionRequest(BaseModel): + """Request to quote a candidate CLMM position before opening or adding. + + Mirrors Gateway's GET /trading/clmm/quote-position: given the price range and + one or both deposit amounts, returns the actual base/quote split the pool + would take (and which side limits it) without signing anything. + """ + connector: str = Field(description="CLMM connector (e.g., 'meteora', 'raydium', 'orca')") + network: str = Field(description="Network ID in 'chain-network' format (e.g., 'solana-mainnet-beta')") + pool_address: str = Field(description="Pool contract address") + lower_price: Decimal = Field(description="Lower price bound") + upper_price: Decimal = Field(description="Upper price bound") + base_token_amount: Optional[Decimal] = Field(default=None, description="Base amount to deposit (one side may be omitted)") + quote_token_amount: Optional[Decimal] = Field(default=None, description="Quote amount to deposit (one side may be omitted)") + slippage_pct: Optional[Decimal] = Field(default=None, description="Max acceptable slippage percentage") + + +class CLMMQuotePositionResponse(BaseModel): + """Gateway's position quote: the deposit split the pool would actually take.""" + base_limited: bool = Field(alias="baseLimited", description="True when the base side limits the deposit") + base_token_amount: Decimal = Field(alias="baseTokenAmount", description="Base amount the position would take") + quote_token_amount: Decimal = Field(alias="quoteTokenAmount", description="Quote amount the position would take") + base_token_amount_max: Decimal = Field(alias="baseTokenAmountMax", description="Base ceiling after slippage") + quote_token_amount_max: Decimal = Field(alias="quoteTokenAmountMax", description="Quote ceiling after slippage") + + model_config = {"populate_by_name": True} + + +class CLMMCreatePoolRequest(BaseModel): + """Request to create a new (empty) CLMM pool — liquidity is added by opening positions. + + Mirrors Gateway's POST /trading/clmm/create-pool. Connector extras are consumed + only by their owning connector. + """ + connector: str = Field(description="CLMM connector (e.g., 'meteora', 'raydium', 'orca', 'uniswap')") + network: str = Field(description="Network ID in 'chain-network' format (e.g., 'solana-mainnet-beta')") + base_token: str = Field(description="Base token symbol or address") + quote_token: str = Field(description="Quote token symbol or address") + initial_price: Optional[Decimal] = Field( + default=None, description="Initial price (quote per base); market price when omitted") + wallet_address: Optional[str] = Field(default=None, description="Wallet address (optional, uses default)") + # Connector-specific create-pool extras: + bin_step: Optional[int] = Field(default=None, description="Meteora DLMM bin step (bps)") + fee_bps: Optional[int] = Field(default=None, description="Meteora DLMM base fee (bps)") + amm_config_index: Optional[int] = Field(default=None, description="Raydium CLMM AMM config index (fee tier)") + fee: Optional[Decimal] = Field(default=None, description="Orca/EVM fee parameter (connector-specific)") + tick_spacing: Optional[int] = Field(default=None, description="Orca Whirlpool tick spacing (fee tier)") + amm_config: Optional[str] = Field(default=None, description="pancakeswap-sol CLMM amm_config account address") + gas_price: Optional[Decimal] = Field(default=None, description="EVM gas price in gwei (uniswap/pancakeswap)") + max_gas: Optional[int] = Field(default=None, description="EVM max gas limit (uniswap/pancakeswap)") + + class CLMMPositionsOwnedRequest(BaseModel): """Request to get all CLMM positions owned by a wallet. @@ -179,6 +253,10 @@ class CLMMPositionInfo(BaseModel): quote_fee_amount: Optional[Decimal] = Field(default=None, description="Quote token uncollected fees") lower_bin_id: Optional[int] = Field(default=None, description="Lower bin ID (Meteora)") upper_bin_id: Optional[int] = Field(default=None, description="Upper bin ID (Meteora)") + reward_token_address: Optional[str] = Field( + default=None, description="Reward token contract address (farm rewards, where the connector reports them)") + reward_amount: Optional[Decimal] = Field( + default=None, description="Unclaimed reward-token amount (farm rewards)") in_range: bool = Field(description="Whether position is currently in range") @@ -227,9 +305,9 @@ class CLMMPoolInfoResponse(BaseModel): base_token_amount: Decimal = Field(alias="baseTokenAmount", description="Total base token liquidity") quote_token_amount: Decimal = Field(alias="quoteTokenAmount", description="Total quote token liquidity") active_bin_id: Optional[int] = Field(None, alias="activeBinId", description="Currently active bin ID (Meteora DLMM only)") - dynamic_fee_pct: Optional[Decimal] = Field(None, alias="dynamicFeePct", description="Dynamic fee percentage") - min_bin_id: Optional[int] = Field(None, alias="minBinId", description="Minimum bin ID (Meteora-specific)") - max_bin_id: Optional[int] = Field(None, alias="maxBinId", description="Maximum bin ID (Meteora-specific)") + # No dynamicFeePct/minBinId/maxBinId: those are Meteora connector extensions that + # Gateway's unified /trading/clmm/pool-info response schema strips before serialization, + # so they can never arrive here — and nothing downstream consumes them. bins: List[CLMMPoolBin] = Field(default_factory=list, description="List of bins with liquidity") model_config = { @@ -245,9 +323,6 @@ class CLMMPoolInfoResponse(BaseModel): "base_token_amount": 8645709.142366, "quote_token_amount": 1095942.335132, "active_bin_id": -374, - "dynamic_fee_pct": 0.2, - "min_bin_id": -21835, - "max_bin_id": 21835, "bins": [] } } @@ -409,8 +484,10 @@ class AMMCreatePoolRequest(BaseModel): # Connector-specific create-pool extras (only consumed by their owning connector): config_address: Optional[str] = Field(default=None, description="Meteora DAMM v2 config account (required for meteora)") fee_config_index: Optional[int] = Field(default=None, description="Raydium CPMM fee config index (optional)") + open_time: Optional[int] = Field(default=None, description="Raydium CPMM pool open time (unix seconds; optional)") gas_price: Optional[Decimal] = Field(default=None, description="Uniswap (EVM) gas price in gwei (optional)") max_gas: Optional[int] = Field(default=None, description="Uniswap (EVM) max gas limit (optional)") + slippage_pct: Optional[Decimal] = Field(default=None, description="Uniswap seeding slippage percentage (optional)") class AMMCreatePoolResponse(BaseModel): @@ -495,4 +572,4 @@ class CLMMPoolListResponse(BaseModel): pools: List[CLMMPoolListItem] = Field(description="List of available pools") total: int = Field(description="Total number of matching pools") page: int = Field(description="Current page number") - pageSize: int = Field(description="Number of pools per page") + page_size: int = Field(description="Number of pools per page") diff --git a/routers/gateway_amm.py b/routers/gateway_amm.py index 42d9d0d9..24130d90 100644 --- a/routers/gateway_amm.py +++ b/routers/gateway_amm.py @@ -304,8 +304,10 @@ async def create_amm_pool( initial_price=float(request.initial_price) if request.initial_price is not None else None, config_address=request.config_address, fee_config_index=request.fee_config_index, + open_time=request.open_time, gas_price=float(request.gas_price) if request.gas_price is not None else None, max_gas=request.max_gas, + slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else None, )) return AMMCreatePoolResponse(**result) except HTTPException: diff --git a/routers/gateway_clmm.py b/routers/gateway_clmm.py index dc2fac98..9526b360 100644 --- a/routers/gateway_clmm.py +++ b/routers/gateway_clmm.py @@ -13,10 +13,13 @@ from database.repositories import GatewayCLMMRepository from deps import get_accounts_service, get_database_manager from models import ( + AMMCreatePoolResponse, CLMMAddLiquidityRequest, CLMMClosePositionRequest, + CLMMClosePositionResponse, CLMMCollectFeesRequest, CLMMCollectFeesResponse, + CLMMCreatePoolRequest, CLMMOpenPositionRequest, CLMMOpenPositionResponse, CLMMPoolInfoResponse, @@ -24,6 +27,8 @@ CLMMPoolListResponse, CLMMPositionInfo, CLMMPositionsOwnedRequest, + CLMMQuotePositionRequest, + CLMMQuotePositionResponse, CLMMRemoveLiquidityRequest, ) from services.accounts_service import AccountsService @@ -319,7 +324,7 @@ async def get_clmm_pools( pools=pools, total=total, page=page, - pageSize=limit + page_size=limit ) except HTTPException: @@ -356,6 +361,23 @@ async def open_clmm_position( Transaction hash and position address """ try: + # Gateway's unified open destructures ONLY strategyType from the body; any other + # extra_params key is silently dropped there. Reject unknown keys here so a typo + # (or a connector param the unified route does not carry) fails loudly instead of + # opening a position with the parameter ignored. + supported_extra_params = {"strategyType"} + if request.extra_params: + unknown = set(request.extra_params) - supported_extra_params + if unknown: + raise HTTPException( + status_code=400, + detail=( + f"Unsupported extra_params {sorted(unknown)}: Gateway's unified " + f"/trading/clmm/open honors only {sorted(supported_extra_params)} " + "and silently ignores everything else." + ) + ) + if not await accounts_service.gateway_client.ping(): raise HTTPException(status_code=503, detail="Gateway service is not available") @@ -415,6 +437,18 @@ async def open_clmm_position( if position_rent: logger.info(f"Position rent: {position_rent} SOL") + # Prefer the CONFIRMED on-chain amounts over the requested ones: slippage and + # rounding make them differ, and persisting the request silently diverges the + # DB from the chain. data is only present when Gateway confirmed the tx, so + # the requested amounts remain the fallback for submitted-not-confirmed + # (reconciled later by the poller). + base_amount_added = data.get("baseTokenAmountAdded") + if base_amount_added is None: + base_amount_added = float(request.base_token_amount) if request.base_token_amount else 0 + quote_amount_added = data.get("quoteTokenAmountAdded") + if quote_amount_added is None: + quote_amount_added = float(request.quote_token_amount) if request.quote_token_amount else 0 + if not transaction_hash: raise HTTPException(status_code=500, detail="No transaction hash returned from Gateway") if not position_address: @@ -454,11 +488,11 @@ async def open_clmm_position( "percentage": percentage, "entry_price": entry_price, # Pool price when position opened "current_price": entry_price, # Same as entry at open time, updated by poller - "initial_base_token_amount": float(request.base_token_amount) if request.base_token_amount else 0, - "initial_quote_token_amount": float(request.quote_token_amount) if request.quote_token_amount else 0, + "initial_base_token_amount": float(base_amount_added), + "initial_quote_token_amount": float(quote_amount_added), "position_rent": float(position_rent) if position_rent else None, - "base_token_amount": float(request.base_token_amount) if request.base_token_amount else 0, - "quote_token_amount": float(request.quote_token_amount) if request.quote_token_amount else 0, + "base_token_amount": float(base_amount_added), + "quote_token_amount": float(quote_amount_added), "in_range": "UNKNOWN" # Will be updated by poller } @@ -470,8 +504,8 @@ async def open_clmm_position( "position_id": position.id, "transaction_hash": transaction_hash, "event_type": "OPEN", - "base_token_amount": float(request.base_token_amount) if request.base_token_amount else None, - "quote_token_amount": float(request.quote_token_amount) if request.quote_token_amount else None, + "base_token_amount": float(base_amount_added) if base_amount_added else None, + "quote_token_amount": float(quote_amount_added) if quote_amount_added else None, "gas_fee": float(gas_fee) if gas_fee else None, "gas_token": gas_token, "status": tx_status @@ -491,6 +525,9 @@ async def open_clmm_position( pool_address=request.pool_address, lower_price=request.lower_price, upper_price=request.upper_price, + base_token_amount_added=Decimal(str(base_amount_added)) if base_amount_added else None, + quote_token_amount_added=Decimal(str(quote_amount_added)) if quote_amount_added else None, + position_rent=Decimal(str(position_rent)) if position_rent else None, status="submitted" ) @@ -562,6 +599,16 @@ async def add_liquidity_to_clmm_position( gas_fee = data.get("fee") gas_token = "SOL" if chain == "solana" else "ETH" if chain == "ethereum" else None + # Prefer the CONFIRMED on-chain amounts (data is only present when Gateway + # confirmed the tx); the requested amounts are the submitted-not-confirmed + # fallback, reconciled later by the poller. + base_amount_added = data.get("baseTokenAmountAdded") + if base_amount_added is None: + base_amount_added = float(request.base_token_amount) if request.base_token_amount else None + quote_amount_added = data.get("quoteTokenAmountAdded") + if quote_amount_added is None: + quote_amount_added = float(request.quote_token_amount) if request.quote_token_amount else None + # Store ADD_LIQUIDITY event in database try: async with db_manager.get_session_context() as session: @@ -574,8 +621,8 @@ async def add_liquidity_to_clmm_position( "position_id": position.id, "transaction_hash": transaction_hash, "event_type": "ADD_LIQUIDITY", - "base_token_amount": float(request.base_token_amount) if request.base_token_amount else None, - "quote_token_amount": float(request.quote_token_amount) if request.quote_token_amount else None, + "base_token_amount": float(base_amount_added) if base_amount_added else None, + "quote_token_amount": float(quote_amount_added) if quote_amount_added else None, "gas_fee": float(gas_fee) if gas_fee else None, "gas_token": gas_token, "status": tx_status @@ -589,6 +636,9 @@ async def add_liquidity_to_clmm_position( return { "transaction_hash": transaction_hash, "position_address": request.position_address, + "base_token_amount_added": base_amount_added, + "quote_token_amount_added": quote_amount_added, + "gas_fee": gas_fee, "status": "submitted" } @@ -656,6 +706,11 @@ async def remove_liquidity_from_clmm_position( gas_fee = data.get("fee") gas_token = "SOL" if chain == "solana" else "ETH" if chain == "ethereum" else None + # The CONFIRMED on-chain amounts (data is only present when Gateway confirmed + # the tx). A percentage alone says nothing about what actually left the pool. + base_amount_removed = data.get("baseTokenAmountRemoved") + quote_amount_removed = data.get("quoteTokenAmountRemoved") + # Store REMOVE_LIQUIDITY event in database try: async with db_manager.get_session_context() as session: @@ -664,11 +719,15 @@ async def remove_liquidity_from_clmm_position( # Get position to link event position = await clmm_repo.get_position_by_address(request.position_address) if position: + # No "percentage" key: GatewayCLMMEvent has no such column, and the + # stray kwarg made create_event raise — silently losing every + # REMOVE_LIQUIDITY event to the log-and-continue handler below. event_data = { "position_id": position.id, "transaction_hash": transaction_hash, "event_type": "REMOVE_LIQUIDITY", - "percentage": float(request.percentage), + "base_token_amount": float(base_amount_removed) if base_amount_removed is not None else None, + "quote_token_amount": float(quote_amount_removed) if quote_amount_removed is not None else None, "gas_fee": float(gas_fee) if gas_fee else None, "gas_token": gas_token, "status": tx_status @@ -683,6 +742,9 @@ async def remove_liquidity_from_clmm_position( "transaction_hash": transaction_hash, "position_address": request.position_address, "percentage": float(request.percentage), + "base_token_amount_removed": base_amount_removed, + "quote_token_amount_removed": quote_amount_removed, + "gas_fee": gas_fee, "status": "submitted" } @@ -697,7 +759,7 @@ async def remove_liquidity_from_clmm_position( raise HTTPException(status_code=500, detail=f"Error removing liquidity from CLMM position: {str(e)}") -@router.post("/clmm/close", response_model=CLMMCollectFeesResponse) +@router.post("/clmm/close", response_model=CLMMClosePositionResponse) async def close_clmm_position( request: CLMMClosePositionRequest, accounts_service: AccountsService = Depends(get_accounts_service), @@ -811,7 +873,16 @@ async def close_clmm_position( quote_fee_collected = (Decimal(str(quote_fee_from_response)) if quote_fee_from_response is not None else quote_fee_to_collect) - logger.info(f"Collected fees on close: base={base_fee_collected}, quote={quote_fee_collected}") + # Confirmed-close accounting: what actually left the pool, and the rent the + # chain refunded for the closed position account (tracked as locked at open + # via position_rent). None until the transaction confirms. + base_amount_removed = data.get("baseTokenAmountRemoved") + quote_amount_removed = data.get("quoteTokenAmountRemoved") + position_rent_refunded = data.get("positionRentRefunded") + + logger.info(f"Collected fees on close: base={base_fee_collected}, quote={quote_fee_collected}; " + f"removed base={base_amount_removed}, quote={quote_amount_removed}; " + f"rent refunded={position_rent_refunded}") # Store CLOSE event in database and update position try: @@ -826,6 +897,8 @@ async def close_clmm_position( "position_id": position.id, "transaction_hash": transaction_hash, "event_type": "CLOSE", + "base_token_amount": float(base_amount_removed) if base_amount_removed is not None else None, + "quote_token_amount": float(quote_amount_removed) if quote_amount_removed is not None else None, "base_fee_collected": float(base_fee_collected) if base_fee_collected else None, "quote_fee_collected": float(quote_fee_collected) if quote_fee_collected else None, "gas_fee": float(gas_fee) if gas_fee else None, @@ -891,11 +964,14 @@ async def close_clmm_position( except Exception as db_error: logger.error(f"Error recording CLOSE event: {db_error}", exc_info=True) - return CLMMCollectFeesResponse( + return CLMMClosePositionResponse( transaction_hash=transaction_hash, position_address=request.position_address, base_fee_collected=Decimal(str(base_fee_collected)) if base_fee_collected else None, quote_fee_collected=Decimal(str(quote_fee_collected)) if quote_fee_collected else None, + base_token_amount_removed=Decimal(str(base_amount_removed)) if base_amount_removed is not None else None, + quote_token_amount_removed=Decimal(str(quote_amount_removed)) if quote_amount_removed is not None else None, + position_rent_refunded=Decimal(str(position_rent_refunded)) if position_rent_refunded is not None else None, status="submitted" ) @@ -1157,6 +1233,8 @@ async def get_clmm_positions_owned( quote_fee_amount=Decimal(str(pos.get("quoteFeeAmount", 0))) if pos.get("quoteFeeAmount") else None, lower_bin_id=pos.get("lowerBinId"), upper_bin_id=pos.get("upperBinId"), + reward_token_address=pos.get("rewardTokenAddress"), + reward_amount=Decimal(str(pos.get("rewardAmount"))) if pos.get("rewardAmount") is not None else None, in_range=in_range )) @@ -1173,6 +1251,162 @@ async def get_clmm_positions_owned( raise HTTPException(status_code=500, detail=f"Error getting CLMM positions owned: {str(e)}") +@router.post("/clmm/quote-position", response_model=CLMMQuotePositionResponse, response_model_by_alias=False) +async def quote_clmm_position( + request: CLMMQuotePositionRequest, + accounts_service: AccountsService = Depends(get_accounts_service) +): + """ + Quote a candidate CLMM position before opening or adding liquidity. + + Mirrors Gateway's GET /trading/clmm/quote-position: returns the base/quote + split the pool would actually take for the given range and deposit amounts + (and which side limits it), without signing or submitting anything. + """ + try: + if not await accounts_service.gateway_client.ping(): + raise HTTPException(status_code=503, detail="Gateway service is not available") + + result = check_gateway_error(await accounts_service.gateway_client.clmm_quote_position( + connector=request.connector, + chain_network=request.network, + pool_address=request.pool_address, + lower_price=float(request.lower_price), + upper_price=float(request.upper_price), + base_token_amount=float(request.base_token_amount) if request.base_token_amount is not None else None, + quote_token_amount=float(request.quote_token_amount) if request.quote_token_amount is not None else None, + slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else None, + )) + return CLMMQuotePositionResponse(**result) + + except HTTPException: + raise + except GatewayError as e: + raise HTTPException(status_code=e.status, detail=f"Gateway error quoting CLMM position: {e}") + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Error quoting CLMM position: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Error quoting CLMM position: {str(e)}") + + +@router.post("/clmm/create-pool", response_model=AMMCreatePoolResponse, response_model_by_alias=False) +async def create_clmm_pool( + request: CLMMCreatePoolRequest, + accounts_service: AccountsService = Depends(get_accounts_service) +): + """ + Create a new (empty) CLMM pool — liquidity is added afterwards by opening positions. + + Mirrors Gateway's POST /trading/clmm/create-pool (which shares the AMM + create-pool response shape). Connector extras are sent only when provided. + """ + try: + if not await accounts_service.gateway_client.ping(): + raise HTTPException(status_code=503, detail="Gateway service is not available") + + chain, _ = accounts_service.gateway_client.parse_network_id(request.network) + wallet_address = await accounts_service.gateway_client.get_wallet_address_or_default( + chain=chain, + wallet_address=request.wallet_address + ) + + result = check_gateway_error(await accounts_service.gateway_client.clmm_create_pool( + connector=request.connector, + chain_network=request.network, + wallet_address=wallet_address, + base_token=request.base_token, + quote_token=request.quote_token, + initial_price=float(request.initial_price) if request.initial_price is not None else None, + bin_step=request.bin_step, + fee_bps=request.fee_bps, + amm_config_index=request.amm_config_index, + fee=float(request.fee) if request.fee is not None else None, + tick_spacing=request.tick_spacing, + amm_config=request.amm_config, + gas_price=float(request.gas_price) if request.gas_price is not None else None, + max_gas=request.max_gas, + )) + return AMMCreatePoolResponse(**result) + + except HTTPException: + raise + except GatewayError as e: + raise HTTPException(status_code=e.status, detail=f"Gateway error creating CLMM pool: {e}") + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Error creating CLMM pool: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Error creating CLMM pool: {str(e)}") + + +@router.get("/clmm/position-info", response_model=CLMMPositionInfo) +async def get_clmm_position_info( + connector: str, + network: str, + position_address: str, + accounts_service: AccountsService = Depends(get_accounts_service) +): + """ + Get a single CLMM position by its address. + + Mirrors Gateway's GET /trading/clmm/position-info. Gateway reports a missing + or closed position as an error (500/404), surfaced here as 404. + """ + try: + if not await accounts_service.gateway_client.ping(): + raise HTTPException(status_code=503, detail="Gateway service is not available") + + pos = await accounts_service.gateway_client.clmm_position_info( + connector=connector, + chain_network=network, + position_address=position_address + ) + if isinstance(pos, dict) and "error" in pos: + status_code = pos.get("status") + if status_code in (404, 500): + raise HTTPException(status_code=404, detail=f"Position {position_address} not found or closed") + raise HTTPException(status_code=status_code or 502, detail=str(pos.get("error"))) + + base_token_address = pos.get("baseTokenAddress", "") + quote_token_address = pos.get("quoteTokenAddress", "") + base_token = base_token_address[-8:] if base_token_address else "" + quote_token = quote_token_address[-8:] if quote_token_address else "" + current_price = Decimal(str(pos.get("price", 0))) + lower_price = Decimal(str(pos.get("lowerPrice", 0))) if pos.get("lowerPrice") else Decimal("0") + upper_price = Decimal(str(pos.get("upperPrice", 0))) if pos.get("upperPrice") else Decimal("0") + in_range = bool(current_price > 0 and lower_price > 0 and upper_price > 0 + and lower_price <= current_price <= upper_price) + + return CLMMPositionInfo( + position_address=pos.get("address", position_address), + pool_address=pos.get("poolAddress", ""), + trading_pair=f"{base_token}-{quote_token}" if base_token and quote_token else "", + base_token=base_token, + quote_token=quote_token, + base_token_amount=Decimal(str(pos.get("baseTokenAmount", 0))), + quote_token_amount=Decimal(str(pos.get("quoteTokenAmount", 0))), + current_price=current_price, + lower_price=lower_price, + upper_price=upper_price, + base_fee_amount=Decimal(str(pos.get("baseFeeAmount", 0))) if pos.get("baseFeeAmount") else None, + quote_fee_amount=Decimal(str(pos.get("quoteFeeAmount", 0))) if pos.get("quoteFeeAmount") else None, + lower_bin_id=pos.get("lowerBinId"), + upper_bin_id=pos.get("upperBinId"), + reward_token_address=pos.get("rewardTokenAddress"), + reward_amount=Decimal(str(pos.get("rewardAmount"))) if pos.get("rewardAmount") is not None else None, + in_range=in_range + ) + + except HTTPException: + raise + except GatewayError as e: + raise HTTPException(status_code=e.status, detail=f"Gateway error getting CLMM position: {e}") + except Exception as e: + logger.error(f"Error getting CLMM position info: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Error getting CLMM position info: {str(e)}") + + @router.get("/clmm/positions/{position_address}/events") async def get_clmm_position_events( position_address: str, diff --git a/services/gateway_client.py b/services/gateway_client.py index 3d983b3b..9235cc34 100644 --- a/services/gateway_client.py +++ b/services/gateway_client.py @@ -649,6 +649,79 @@ async def clmm_positions_owned( } return await self._request("GET", "trading/clmm/positions-owned", params=params) + async def clmm_quote_position( + self, + connector: str, + chain_network: str, + pool_address: str, + lower_price: float, + upper_price: float, + base_token_amount: Optional[float] = None, + quote_token_amount: Optional[float] = None, + slippage_pct: Optional[float] = None, + ) -> Dict: + """Quote the base/quote split a candidate position would take, without signing anything.""" + params = { + "connector": connector, + "chainNetwork": chain_network, + "poolAddress": pool_address, + "lowerPrice": lower_price, + "upperPrice": upper_price, + } + if base_token_amount is not None: + params["baseTokenAmount"] = base_token_amount + if quote_token_amount is not None: + params["quoteTokenAmount"] = quote_token_amount + if slippage_pct is not None: + params["slippagePct"] = slippage_pct + return await self._request("GET", "trading/clmm/quote-position", params=params) + + async def clmm_create_pool( + self, + connector: str, + chain_network: str, + wallet_address: str, + base_token: str, + quote_token: str, + initial_price: Optional[float] = None, + bin_step: Optional[int] = None, + fee_bps: Optional[int] = None, + amm_config_index: Optional[int] = None, + fee: Optional[float] = None, + tick_spacing: Optional[int] = None, + amm_config: Optional[str] = None, + gas_price: Optional[float] = None, + max_gas: Optional[int] = None, + ) -> Dict: + """Create a new (empty) CLMM pool. Connector extras are sent only when provided.""" + payload = { + "connector": connector, + "chainNetwork": chain_network, + "walletAddress": wallet_address, + "baseToken": base_token, + "quoteToken": quote_token, + } + if initial_price is not None: + payload["initialPrice"] = initial_price + # Connector-specific extras (each consumed only by its owning connector): + if bin_step is not None: + payload["binStep"] = bin_step + if fee_bps is not None: + payload["feeBps"] = fee_bps + if amm_config_index is not None: + payload["ammConfigIndex"] = amm_config_index + if fee is not None: + payload["fee"] = fee + if tick_spacing is not None: + payload["tickSpacing"] = tick_spacing + if amm_config is not None: + payload["ammConfig"] = amm_config + if gas_price is not None: + payload["gasPrice"] = gas_price + if max_gas is not None: + payload["maxGas"] = max_gas + return await self._request("POST", "trading/clmm/create-pool", json=payload) + async def clmm_collect_fees( self, connector: str, @@ -884,8 +957,10 @@ async def amm_create_pool( initial_price: Optional[float] = None, config_address: Optional[str] = None, fee_config_index: Optional[int] = None, + open_time: Optional[int] = None, gas_price: Optional[float] = None, max_gas: Optional[int] = None, + slippage_pct: Optional[float] = None, ) -> Dict: """Create and seed a new AMM pool. Connector extras are sent only when provided.""" payload = { @@ -906,10 +981,14 @@ async def amm_create_pool( payload["configAddress"] = config_address if fee_config_index is not None: payload["feeConfigIndex"] = fee_config_index + if open_time is not None: + payload["openTime"] = open_time if gas_price is not None: payload["gasPrice"] = gas_price if max_gas is not None: payload["maxGas"] = max_gas + if slippage_pct is not None: + payload["slippagePct"] = slippage_pct return await self._request("POST", "trading/amm/create-pool", json=payload) # ============================================ From 04adc98b30daacba9c257d2156578c10a4245519 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 18 Aug 2026 16:20:40 -0700 Subject: [PATCH 08/54] refactor(gateway): standardize connector extras on extra_params; honest swap quote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CLMM and AMM create-pool drop their per-connector named fields; connector params ride extra_params under Gateway's own names (the clmm open contract), spread into the payload, with unknown keys rejected loudly — Gateway destructures a fixed set and silently ignores the rest. Meteora's required configAddress is enforced at the router. - CLMMPositionInfo drops reward_token_address/reward_amount: no Gateway connector populates them (the only assignments are commented out) and the schema fields are being removed from Gateway's trading responses. - CLMMPoolInfoRequest documents bin_count, mirroring Gateway's binCount. - SwapQuoteResponse mirrors what /trading/swap/quote actually returns: gains min_amount_out/max_amount_in/price_impact_pct/pool_address/ route_path, slippage_pct reflects Gateway's applied value, and the phantom gas_estimate (never returned by Gateway) and deprecated expected_amount are gone — nothing consumed either. - Fix the stale module docstring claiming AMM support was removed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- models/gateway_trading.py | 62 ++++++++++++++------------ routers/gateway_amm.py | 34 ++++++++++---- routers/gateway_clmm.py | 33 ++++++++------ routers/gateway_swap.py | 29 ++++++------ services/gateway_client.py | 66 +++++++++------------------- test/test_gateway_client_contract.py | 7 +-- 6 files changed, 118 insertions(+), 113 deletions(-) diff --git a/models/gateway_trading.py b/models/gateway_trading.py index 3185c347..2d8cda30 100644 --- a/models/gateway_trading.py +++ b/models/gateway_trading.py @@ -1,8 +1,8 @@ """ -Models for Gateway DEX trading operations. -Supports swaps via routers (Jupiter, 0x) and CLMM liquidity positions (Meteora, Raydium, Uniswap V3). - -Note: AMM support has been removed. Use Router for simple swaps, CLMM for liquidity provision. +Models for Gateway DEX trading operations, mirroring Gateway's unified /trading routes: +swaps (routers like Jupiter and pool-scoped AMM swaps), CLMM liquidity positions +(Meteora, Raydium, Orca, Uniswap V3, PancakeSwap), and AMM liquidity/pool creation +(Meteora DAMM v2, Raydium CPMM, Uniswap V2). """ from decimal import Decimal from typing import Any, Dict, List, Optional @@ -25,7 +25,12 @@ class SwapQuoteRequest(BaseModel): class SwapQuoteResponse(BaseModel): - """Response with swap quote details""" + """Swap quote, re-framed from Gateway's token-flow response into trading-pair terms. + + Gateway's /trading/swap/quote speaks tokenIn/tokenOut; this keeps the base/quote + + side framing bots use and passes Gateway's execution-safety fields through in + snake_case. No gas estimate: Gateway's quote does not return one. + """ base: str = Field(description="Base token symbol") quote: str = Field(description="Quote token symbol") price: Decimal = Field(description="Quoted price (base/quote)") @@ -36,9 +41,15 @@ class SwapQuoteResponse(BaseModel): amount_out: Optional[Decimal] = Field( default=None, description="Actual output amount (BUY: base to receive, SELL: quote to receive)" ) - expected_amount: Optional[Decimal] = Field(default=None, description="Deprecated: use amount_out instead") - slippage_pct: Decimal = Field(description="Applied slippage percentage") - gas_estimate: Optional[Decimal] = Field(default=None, description="Estimated gas cost") + min_amount_out: Optional[Decimal] = Field( + default=None, description="Minimum output the transaction will accept after slippage") + max_amount_in: Optional[Decimal] = Field( + default=None, description="Maximum input the transaction will spend after slippage") + price_impact_pct: Optional[Decimal] = Field( + default=None, description="Price impact of this trade size on the route") + pool_address: Optional[str] = Field(default=None, description="Pool the quote was priced against") + route_path: Optional[str] = Field(default=None, description="Route taken (router connectors)") + slippage_pct: Decimal = Field(description="Slippage percentage Gateway applied to the quote") class SwapExecuteRequest(BaseModel): @@ -214,15 +225,11 @@ class CLMMCreatePoolRequest(BaseModel): initial_price: Optional[Decimal] = Field( default=None, description="Initial price (quote per base); market price when omitted") wallet_address: Optional[str] = Field(default=None, description="Wallet address (optional, uses default)") - # Connector-specific create-pool extras: - bin_step: Optional[int] = Field(default=None, description="Meteora DLMM bin step (bps)") - fee_bps: Optional[int] = Field(default=None, description="Meteora DLMM base fee (bps)") - amm_config_index: Optional[int] = Field(default=None, description="Raydium CLMM AMM config index (fee tier)") - fee: Optional[Decimal] = Field(default=None, description="Orca/EVM fee parameter (connector-specific)") - tick_spacing: Optional[int] = Field(default=None, description="Orca Whirlpool tick spacing (fee tier)") - amm_config: Optional[str] = Field(default=None, description="pancakeswap-sol CLMM amm_config account address") - gas_price: Optional[Decimal] = Field(default=None, description="EVM gas price in gwei (uniswap/pancakeswap)") - max_gas: Optional[int] = Field(default=None, description="EVM max gas limit (uniswap/pancakeswap)") + extra_params: Optional[Dict[str, Any]] = Field( + default=None, + description="Connector-specific create params, passed through to Gateway under its own " + "names: binStep/feeBps (meteora), ammConfigIndex (raydium), fee/tickSpacing (orca), " + "ammConfig (pancakeswap-sol), gasPrice/maxGas (EVM connectors). Unknown keys are rejected.") class CLMMPositionsOwnedRequest(BaseModel): @@ -253,10 +260,6 @@ class CLMMPositionInfo(BaseModel): quote_fee_amount: Optional[Decimal] = Field(default=None, description="Quote token uncollected fees") lower_bin_id: Optional[int] = Field(default=None, description="Lower bin ID (Meteora)") upper_bin_id: Optional[int] = Field(default=None, description="Upper bin ID (Meteora)") - reward_token_address: Optional[str] = Field( - default=None, description="Reward token contract address (farm rewards, where the connector reports them)") - reward_amount: Optional[Decimal] = Field( - default=None, description="Unclaimed reward-token amount (farm rewards)") in_range: bool = Field(description="Whether position is currently in range") @@ -272,6 +275,11 @@ class CLMMPoolInfoRequest(BaseModel): connector: str = Field(description="CLMM connector (e.g., 'meteora', 'raydium')") network: str = Field(description="Network ID in 'chain-network' format (e.g., 'solana-mainnet-beta')") pool_address: str = Field(description="Pool contract address") + bin_count: int = Field( + default=0, + description="If > 0, include the per-tick liquidity distribution (bins) around the active " + "price — Gateway's binCount. Meteora always returns its bins and ignores this; orca, " + "raydium, uniswap and pancakeswap compute them on request.") class CLMMPoolBin(BaseModel): @@ -481,13 +489,11 @@ class AMMCreatePoolRequest(BaseModel): quote_token_amount: Optional[Decimal] = Field(default=None, description="Amount of quote to seed (sets price if given)") initial_price: Optional[Decimal] = Field(default=None, description="Initial price (quote per base); overrides quote amount") wallet_address: Optional[str] = Field(default=None, description="Wallet address (optional, uses default)") - # Connector-specific create-pool extras (only consumed by their owning connector): - config_address: Optional[str] = Field(default=None, description="Meteora DAMM v2 config account (required for meteora)") - fee_config_index: Optional[int] = Field(default=None, description="Raydium CPMM fee config index (optional)") - open_time: Optional[int] = Field(default=None, description="Raydium CPMM pool open time (unix seconds; optional)") - gas_price: Optional[Decimal] = Field(default=None, description="Uniswap (EVM) gas price in gwei (optional)") - max_gas: Optional[int] = Field(default=None, description="Uniswap (EVM) max gas limit (optional)") - slippage_pct: Optional[Decimal] = Field(default=None, description="Uniswap seeding slippage percentage (optional)") + extra_params: Optional[Dict[str, Any]] = Field( + default=None, + description="Connector-specific create params, passed through to Gateway under its own " + "names: configAddress (meteora DAMM v2, required there), feeConfigIndex/openTime (raydium " + "CPMM), gasPrice/maxGas/slippagePct (uniswap/EVM). Unknown keys are rejected.") class AMMCreatePoolResponse(BaseModel): diff --git a/routers/gateway_amm.py b/routers/gateway_amm.py index 24130d90..5aeafa27 100644 --- a/routers/gateway_amm.py +++ b/routers/gateway_amm.py @@ -290,10 +290,33 @@ async def create_amm_pool( Create and seed a new AMM pool. Seed price priority: initial_price → quote_token_amount ratio → live market price (anti-snipe). - Connector extras are sent only when provided (config_address for meteora, fee_config_index for - raydium, gas_price/max_gas for uniswap). + Connector-specific params ride extra_params under Gateway's own names (configAddress for + meteora — required there, feeConfigIndex/openTime for raydium, gasPrice/maxGas/slippagePct + for uniswap) — the same contract as clmm open's extra_params. """ try: + # Gateway's unified create-pool destructures exactly these; anything else + # would be silently ignored there, so reject it loudly here. + supported_extra_params = { + "configAddress", "feeConfigIndex", "openTime", "gasPrice", "maxGas", "slippagePct", + } + extra_params = request.extra_params or {} + unknown = set(extra_params) - supported_extra_params + if unknown: + raise HTTPException( + status_code=400, + detail=( + f"Unsupported extra_params {sorted(unknown)}: Gateway's unified " + f"/trading/amm/create-pool honors only {sorted(supported_extra_params)}." + ) + ) + if request.connector == "meteora" and not extra_params.get("configAddress"): + raise HTTPException( + status_code=400, + detail="extra_params.configAddress is required for meteora create-pool " + "(DAMM v2 pools are created against a config account)." + ) + await _require_gateway(accounts_service) wallet_address = await _resolve_wallet(accounts_service, request.network, request.wallet_address) result = check_gateway_error(await accounts_service.gateway_client.amm_create_pool( @@ -302,12 +325,7 @@ async def create_amm_pool( base_token_amount=float(request.base_token_amount), quote_token_amount=float(request.quote_token_amount) if request.quote_token_amount is not None else None, initial_price=float(request.initial_price) if request.initial_price is not None else None, - config_address=request.config_address, - fee_config_index=request.fee_config_index, - open_time=request.open_time, - gas_price=float(request.gas_price) if request.gas_price is not None else None, - max_gas=request.max_gas, - slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else None, + extra_params=request.extra_params, )) return AMMCreatePoolResponse(**result) except HTTPException: diff --git a/routers/gateway_clmm.py b/routers/gateway_clmm.py index 9526b360..eb5e6e3c 100644 --- a/routers/gateway_clmm.py +++ b/routers/gateway_clmm.py @@ -1233,8 +1233,6 @@ async def get_clmm_positions_owned( quote_fee_amount=Decimal(str(pos.get("quoteFeeAmount", 0))) if pos.get("quoteFeeAmount") else None, lower_bin_id=pos.get("lowerBinId"), upper_bin_id=pos.get("upperBinId"), - reward_token_address=pos.get("rewardTokenAddress"), - reward_amount=Decimal(str(pos.get("rewardAmount"))) if pos.get("rewardAmount") is not None else None, in_range=in_range )) @@ -1299,9 +1297,27 @@ async def create_clmm_pool( Create a new (empty) CLMM pool — liquidity is added afterwards by opening positions. Mirrors Gateway's POST /trading/clmm/create-pool (which shares the AMM - create-pool response shape). Connector extras are sent only when provided. + create-pool response shape). Connector-specific params ride extra_params + under Gateway's own names — the same contract as open's extra_params. """ try: + # Gateway's unified create-pool destructures exactly these; anything else + # would be silently ignored there, so reject it loudly here. + supported_extra_params = { + "binStep", "feeBps", "ammConfigIndex", "fee", "tickSpacing", + "ammConfig", "gasPrice", "maxGas", + } + if request.extra_params: + unknown = set(request.extra_params) - supported_extra_params + if unknown: + raise HTTPException( + status_code=400, + detail=( + f"Unsupported extra_params {sorted(unknown)}: Gateway's unified " + f"/trading/clmm/create-pool honors only {sorted(supported_extra_params)}." + ) + ) + if not await accounts_service.gateway_client.ping(): raise HTTPException(status_code=503, detail="Gateway service is not available") @@ -1318,14 +1334,7 @@ async def create_clmm_pool( base_token=request.base_token, quote_token=request.quote_token, initial_price=float(request.initial_price) if request.initial_price is not None else None, - bin_step=request.bin_step, - fee_bps=request.fee_bps, - amm_config_index=request.amm_config_index, - fee=float(request.fee) if request.fee is not None else None, - tick_spacing=request.tick_spacing, - amm_config=request.amm_config, - gas_price=float(request.gas_price) if request.gas_price is not None else None, - max_gas=request.max_gas, + extra_params=request.extra_params, )) return AMMCreatePoolResponse(**result) @@ -1393,8 +1402,6 @@ async def get_clmm_position_info( quote_fee_amount=Decimal(str(pos.get("quoteFeeAmount", 0))) if pos.get("quoteFeeAmount") else None, lower_bin_id=pos.get("lowerBinId"), upper_bin_id=pos.get("upperBinId"), - reward_token_address=pos.get("rewardTokenAddress"), - reward_amount=Decimal(str(pos.get("rewardAmount"))) if pos.get("rewardAmount") is not None else None, in_range=in_range ) diff --git a/routers/gateway_swap.py b/routers/gateway_swap.py index 3a5816c6..96c6b406 100644 --- a/routers/gateway_swap.py +++ b/routers/gateway_swap.py @@ -81,27 +81,26 @@ async def get_swap_quote( slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else 1.0 )) - # Extract amounts from Gateway response (snake_case for consistency) - amount_in_raw = result.get("amountIn") or result.get("amount_in") - amount_out_raw = result.get("amountOut") or result.get("amount_out") - - amount_in = Decimal(str(amount_in_raw)) if amount_in_raw else None - amount_out = Decimal(str(amount_out_raw)) if amount_out_raw else None - - # Extract gas estimate (try both camelCase and snake_case) - gas_estimate = result.get("gasEstimate") or result.get("gas_estimate") - gas_estimate_value = Decimal(str(gas_estimate)) if gas_estimate else None + # Re-frame Gateway's token-flow response (tokenIn/tokenOut) into pair terms, + # passing its execution-safety fields through in snake_case. + def _dec(key): + value = result.get(key) + return Decimal(str(value)) if value is not None else None return SwapQuoteResponse( base=base, quote=quote, price=Decimal(str(result.get("price", 0))), amount=request.amount, - amount_in=amount_in, - amount_out=amount_out, - expected_amount=amount_out, # Deprecated, kept for backward compatibility - slippage_pct=request.slippage_pct if request.slippage_pct is not None else Decimal("1.0"), - gas_estimate=gas_estimate_value + amount_in=_dec("amountIn"), + amount_out=_dec("amountOut"), + min_amount_out=_dec("minAmountOut"), + max_amount_in=_dec("maxAmountIn"), + price_impact_pct=_dec("priceImpactPct"), + pool_address=result.get("poolAddress"), + route_path=result.get("routePath"), + slippage_pct=(_dec("slippagePct") + or (request.slippage_pct if request.slippage_pct is not None else Decimal("1.0"))), ) except HTTPException: diff --git a/services/gateway_client.py b/services/gateway_client.py index 9235cc34..87ccdf68 100644 --- a/services/gateway_client.py +++ b/services/gateway_client.py @@ -684,16 +684,15 @@ async def clmm_create_pool( base_token: str, quote_token: str, initial_price: Optional[float] = None, - bin_step: Optional[int] = None, - fee_bps: Optional[int] = None, - amm_config_index: Optional[int] = None, - fee: Optional[float] = None, - tick_spacing: Optional[int] = None, - amm_config: Optional[str] = None, - gas_price: Optional[float] = None, - max_gas: Optional[int] = None, + extra_params: Optional[Dict] = None, ) -> Dict: - """Create a new (empty) CLMM pool. Connector extras are sent only when provided.""" + """Create a new (empty) CLMM pool. + + extra_params carries the connector-specific create params under Gateway's own + names (binStep, feeBps, ammConfigIndex, fee, tickSpacing, ammConfig, gasPrice, + maxGas) and is spread into the payload — the same contract as clmm open's + extra_params. The router validates keys before this is called. + """ payload = { "connector": connector, "chainNetwork": chain_network, @@ -703,23 +702,8 @@ async def clmm_create_pool( } if initial_price is not None: payload["initialPrice"] = initial_price - # Connector-specific extras (each consumed only by its owning connector): - if bin_step is not None: - payload["binStep"] = bin_step - if fee_bps is not None: - payload["feeBps"] = fee_bps - if amm_config_index is not None: - payload["ammConfigIndex"] = amm_config_index - if fee is not None: - payload["fee"] = fee - if tick_spacing is not None: - payload["tickSpacing"] = tick_spacing - if amm_config is not None: - payload["ammConfig"] = amm_config - if gas_price is not None: - payload["gasPrice"] = gas_price - if max_gas is not None: - payload["maxGas"] = max_gas + if extra_params: + payload.update(extra_params) return await self._request("POST", "trading/clmm/create-pool", json=payload) async def clmm_collect_fees( @@ -955,14 +939,15 @@ async def amm_create_pool( base_token_amount: float, quote_token_amount: Optional[float] = None, initial_price: Optional[float] = None, - config_address: Optional[str] = None, - fee_config_index: Optional[int] = None, - open_time: Optional[int] = None, - gas_price: Optional[float] = None, - max_gas: Optional[int] = None, - slippage_pct: Optional[float] = None, + extra_params: Optional[Dict] = None, ) -> Dict: - """Create and seed a new AMM pool. Connector extras are sent only when provided.""" + """Create and seed a new AMM pool. + + extra_params carries the connector-specific create params under Gateway's own + names (configAddress, feeConfigIndex, openTime, gasPrice, maxGas, slippagePct) + and is spread into the payload — the same contract as clmm open's extra_params. + The router validates keys before this is called. + """ payload = { "connector": connector, "chainNetwork": chain_network, @@ -976,19 +961,8 @@ async def amm_create_pool( payload["quoteTokenAmount"] = quote_token_amount if initial_price is not None: payload["initialPrice"] = initial_price - # Connector-specific extras (each consumed only by its owning connector): - if config_address is not None: - payload["configAddress"] = config_address - if fee_config_index is not None: - payload["feeConfigIndex"] = fee_config_index - if open_time is not None: - payload["openTime"] = open_time - if gas_price is not None: - payload["gasPrice"] = gas_price - if max_gas is not None: - payload["maxGas"] = max_gas - if slippage_pct is not None: - payload["slippagePct"] = slippage_pct + if extra_params: + payload.update(extra_params) return await self._request("POST", "trading/amm/create-pool", json=payload) # ============================================ diff --git a/test/test_gateway_client_contract.py b/test/test_gateway_client_contract.py index 38d2a803..0152f5cd 100644 --- a/test/test_gateway_client_contract.py +++ b/test/test_gateway_client_contract.py @@ -344,7 +344,7 @@ async def test_amm_create_pool_meteora_extras(client_and_calls): client, calls = client_and_calls await client.amm_create_pool(connector="meteora", chain_network=NET, wallet_address=WALLET, base_token="SOL", quote_token="USDC", base_token_amount=1.0, - config_address="CFG123") + extra_params={"configAddress": "CFG123"}) c = calls[0] assert (c["method"], c["path"]) == ("POST", "trading/amm/create-pool") assert c["json"]["configAddress"] == "CFG123" @@ -358,7 +358,7 @@ async def test_amm_create_pool_raydium_fee_config_index(client_and_calls): client, calls = client_and_calls await client.amm_create_pool(connector="raydium", chain_network=NET, wallet_address=WALLET, base_token="SOL", quote_token="USDC", base_token_amount=1.0, - fee_config_index=0, quote_token_amount=100.0) + extra_params={"feeConfigIndex": 0}, quote_token_amount=100.0) c = calls[0] assert c["json"]["feeConfigIndex"] == 0 assert c["json"]["quoteTokenAmount"] == 100.0 @@ -370,7 +370,8 @@ async def test_amm_create_pool_uniswap_gas_extras(client_and_calls): client, calls = client_and_calls await client.amm_create_pool(connector="uniswap", chain_network="ethereum-mainnet", wallet_address=WALLET, base_token="WETH", quote_token="USDC", base_token_amount=1.0, - initial_price=3000.0, gas_price=20.0, max_gas=500000) + initial_price=3000.0, + extra_params={"gasPrice": 20.0, "maxGas": 500000}) c = calls[0] assert c["json"]["gasPrice"] == 20.0 assert c["json"]["maxGas"] == 500000 From 1ef8ebe889082c552473b5803588a9583201ecb6 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 18 Aug 2026 19:18:32 -0700 Subject: [PATCH 09/54] fix(gateway): align request contracts with Gateway's standardized trading routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Stop forcing slippage 1.0 everywhere: all request models default slippage_pct=None and the routers omit the key when unset, so Gateway applies the connector's configured slippagePct (the schema-default shadowing this used to cause was removed Gateway-side). The swap DB record and quote response echo the applied/requested value or None — SwapQuoteResponse.slippage_pct is now Optional and no longer backfills a fabricated 1.0 (also fixes the falsy-`or` that swallowed slippagePct=0). - extra_params convention extended to every surface with connector-specific params, matching the executor stack's LPExecutorConfig.extra_params: swap quote/execute gain approximateIfNoExactOut (Solana routers; query values stringified for aiohttp), clmm add gains strategyType (same contract as open), each guarded by loud unknown-key rejection since Gateway silently drops unrecognized keys. - CLMM remove exposes the standard slippage_pct field (Orca-only today). - create-pool guards pinned to what Gateway actually destructures: clmm {binStep, feeBps, ammConfigIndex}; amm {configAddress, ammConfigIndex} + first-class seeding slippage_pct. The phantom keys (fee/tickSpacing/ammConfig/gasPrice/maxGas/feeConfigIndex/openTime) passed the guard and were silently ignored by Gateway. - Fix /clmm/open crash: clmm_pool_info was called without its required pool_address since the pool-info signature change (TypeError → 500 on every open). - Contract tests updated and extended for all of the above. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- models/gateway_trading.py | 50 ++++++++++--- routers/gateway_amm.py | 9 +-- routers/gateway_clmm.py | 43 ++++++++--- routers/gateway_swap.py | 43 +++++++++-- services/gateway_client.py | 62 ++++++++++++---- test/test_gateway_client_contract.py | 106 ++++++++++++++++++++++++--- 6 files changed, 251 insertions(+), 62 deletions(-) diff --git a/models/gateway_trading.py b/models/gateway_trading.py index 2d8cda30..748c89b1 100644 --- a/models/gateway_trading.py +++ b/models/gateway_trading.py @@ -21,7 +21,12 @@ class SwapQuoteRequest(BaseModel): trading_pair: str = Field(description="Trading pair in BASE-QUOTE format (e.g., 'SOL-USDC')") side: str = Field(description="Trade side: 'BUY' or 'SELL'") amount: Decimal = Field(description="Amount to swap (in base token for SELL, quote token for BUY)") - slippage_pct: Optional[Decimal] = Field(default=1.0, description="Maximum slippage percentage (default: 1.0)") + slippage_pct: Optional[Decimal] = Field( + default=None, description="Maximum slippage percentage; omit to use the connector's configured slippagePct") + extra_params: Optional[Dict[str, Any]] = Field( + default=None, + description="Connector-specific params passed through to Gateway under its own names: " + "approximateIfNoExactOut (Solana routers). Unknown keys are rejected.") class SwapQuoteResponse(BaseModel): @@ -49,7 +54,9 @@ class SwapQuoteResponse(BaseModel): default=None, description="Price impact of this trade size on the route") pool_address: Optional[str] = Field(default=None, description="Pool the quote was priced against") route_path: Optional[str] = Field(default=None, description="Route taken (router connectors)") - slippage_pct: Decimal = Field(description="Slippage percentage Gateway applied to the quote") + slippage_pct: Optional[Decimal] = Field( + default=None, + description="Slippage percentage Gateway applied to the quote (the request value when Gateway omits it)") class SwapExecuteRequest(BaseModel): @@ -59,8 +66,13 @@ class SwapExecuteRequest(BaseModel): trading_pair: str = Field(description="Trading pair (e.g., 'SOL-USDC')") side: str = Field(description="Trade side: 'BUY' or 'SELL'") amount: Decimal = Field(description="Amount to swap") - slippage_pct: Optional[Decimal] = Field(default=1.0, description="Maximum slippage percentage (default: 1.0)") + slippage_pct: Optional[Decimal] = Field( + default=None, description="Maximum slippage percentage; omit to use the connector's configured slippagePct") wallet_address: Optional[str] = Field(default=None, description="Wallet address (optional, uses default if not provided)") + extra_params: Optional[Dict[str, Any]] = Field( + default=None, + description="Connector-specific params passed through to Gateway under its own names: " + "approximateIfNoExactOut (Solana routers). Unknown keys are rejected.") class SwapExecuteResponse(BaseModel): @@ -89,7 +101,8 @@ class CLMMOpenPositionRequest(BaseModel): # Initial liquidity base_token_amount: Optional[Decimal] = Field(default=None, description="Amount of base token to add") quote_token_amount: Optional[Decimal] = Field(default=None, description="Amount of quote token to add") - slippage_pct: Optional[Decimal] = Field(default=1.0, description="Maximum slippage percentage (default: 1.0)") + slippage_pct: Optional[Decimal] = Field( + default=None, description="Maximum slippage percentage; omit to use the connector's configured slippagePct") wallet_address: Optional[str] = Field(default=None, description="Wallet address (optional, uses default if not provided)") # Connector-specific parameters (e.g., strategyType for Meteora) @@ -122,9 +135,13 @@ class CLMMAddLiquidityRequest(BaseModel): position_address: str = Field(description="Existing position address to add liquidity to") base_token_amount: Optional[Decimal] = Field(default=None, description="Amount of base token to add") quote_token_amount: Optional[Decimal] = Field(default=None, description="Amount of quote token to add") - slippage_pct: Optional[Decimal] = Field(default=1.0, description="Maximum slippage percentage (default: 1.0)") + slippage_pct: Optional[Decimal] = Field( + default=None, description="Maximum slippage percentage; omit to use the connector's configured slippagePct") wallet_address: Optional[str] = Field(default=None, description="Wallet address (optional, uses default if not provided)") + # Connector-specific parameters (e.g., strategyType for Meteora) + extra_params: Optional[Dict[str, Any]] = Field(default=None, description="Additional connector-specific parameters") + class CLMMRemoveLiquidityRequest(BaseModel): """Request to remove SOME liquidity from a CLMM position (partial removal)""" @@ -132,6 +149,10 @@ class CLMMRemoveLiquidityRequest(BaseModel): network: str = Field(description="Network ID in 'chain-network' format (e.g., 'solana-mainnet-beta')") position_address: str = Field(description="Position address to remove liquidity from") percentage: Decimal = Field(description="Percentage of liquidity to remove (0-100)") + slippage_pct: Optional[Decimal] = Field( + default=None, + description="Maximum slippage percentage. Only honored by the Orca connector; " + "omit to use the connector's configured slippagePct") wallet_address: Optional[str] = Field(default=None, description="Wallet address (optional, uses default if not provided)") @@ -228,8 +249,9 @@ class CLMMCreatePoolRequest(BaseModel): extra_params: Optional[Dict[str, Any]] = Field( default=None, description="Connector-specific create params, passed through to Gateway under its own " - "names: binStep/feeBps (meteora), ammConfigIndex (raydium), fee/tickSpacing (orca), " - "ammConfig (pancakeswap-sol), gasPrice/maxGas (EVM connectors). Unknown keys are rejected.") + "names: binStep (meteora, orca), feeBps (meteora; required for uniswap/pancakeswap — the " + "V3 fee tier in basis points), ammConfigIndex (raydium, pancakeswap-sol). " + "Unknown keys are rejected.") class CLMMPositionsOwnedRequest(BaseModel): @@ -420,7 +442,8 @@ class AMMExecuteSwapRequest(BaseModel): base_token: str = Field(description="Token that defines the swap direction (symbol or address)") side: str = Field(description="Trade direction: BUY or SELL") amount: Decimal = Field(description="Amount to swap") - slippage_pct: Optional[Decimal] = Field(default=1.0, description="Maximum slippage percentage (default: 1.0)") + slippage_pct: Optional[Decimal] = Field( + default=None, description="Maximum slippage percentage; omit to use the connector's configured slippagePct") wallet_address: Optional[str] = Field(default=None, description="Wallet address (optional, uses default)") @@ -461,7 +484,8 @@ class AMMAddLiquidityRequest(BaseModel): pool_address: str = Field(description="Pool contract address") base_token_amount: Decimal = Field(description="Amount of base token to add") quote_token_amount: Decimal = Field(description="Amount of quote token to add") - slippage_pct: Optional[Decimal] = Field(default=1.0, description="Maximum slippage percentage (default: 1.0)") + slippage_pct: Optional[Decimal] = Field( + default=None, description="Maximum slippage percentage; omit to use the connector's configured slippagePct") wallet_address: Optional[str] = Field(default=None, description="Wallet address (optional, uses default)") # Meteora DAMM v2: add to this specific NFT position; omit to open a NEW position. Ignored by fungible-LP AMMs. position_address: Optional[str] = Field(default=None, description="Meteora position to add to (omit = new position)") @@ -488,12 +512,16 @@ class AMMCreatePoolRequest(BaseModel): base_token_amount: Decimal = Field(description="Amount of base token to seed the pool with") quote_token_amount: Optional[Decimal] = Field(default=None, description="Amount of quote to seed (sets price if given)") initial_price: Optional[Decimal] = Field(default=None, description="Initial price (quote per base); overrides quote amount") + slippage_pct: Optional[Decimal] = Field( + default=None, + description="Seeding slippage percentage (uniswap/pancakeswap only); " + "omit to use the connector's configured slippagePct") wallet_address: Optional[str] = Field(default=None, description="Wallet address (optional, uses default)") extra_params: Optional[Dict[str, Any]] = Field( default=None, description="Connector-specific create params, passed through to Gateway under its own " - "names: configAddress (meteora DAMM v2, required there), feeConfigIndex/openTime (raydium " - "CPMM), gasPrice/maxGas/slippagePct (uniswap/EVM). Unknown keys are rejected.") + "names: configAddress (meteora DAMM v2, required there), ammConfigIndex (raydium CPMM). " + "Unknown keys are rejected.") class AMMCreatePoolResponse(BaseModel): diff --git a/routers/gateway_amm.py b/routers/gateway_amm.py index 5aeafa27..a290179d 100644 --- a/routers/gateway_amm.py +++ b/routers/gateway_amm.py @@ -291,15 +291,13 @@ async def create_amm_pool( Seed price priority: initial_price → quote_token_amount ratio → live market price (anti-snipe). Connector-specific params ride extra_params under Gateway's own names (configAddress for - meteora — required there, feeConfigIndex/openTime for raydium, gasPrice/maxGas/slippagePct - for uniswap) — the same contract as clmm open's extra_params. + meteora — required there, ammConfigIndex for raydium) — the same contract as clmm open's + extra_params. Seeding slippage for uniswap/pancakeswap is the standard slippage_pct field. """ try: # Gateway's unified create-pool destructures exactly these; anything else # would be silently ignored there, so reject it loudly here. - supported_extra_params = { - "configAddress", "feeConfigIndex", "openTime", "gasPrice", "maxGas", "slippagePct", - } + supported_extra_params = {"configAddress", "ammConfigIndex"} extra_params = request.extra_params or {} unknown = set(extra_params) - supported_extra_params if unknown: @@ -325,6 +323,7 @@ async def create_amm_pool( base_token_amount=float(request.base_token_amount), quote_token_amount=float(request.quote_token_amount) if request.quote_token_amount is not None else None, initial_price=float(request.initial_price) if request.initial_price is not None else None, + slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else None, extra_params=request.extra_params, )) return AMMCreatePoolResponse(**result) diff --git a/routers/gateway_clmm.py b/routers/gateway_clmm.py index eb5e6e3c..290a2b56 100644 --- a/routers/gateway_clmm.py +++ b/routers/gateway_clmm.py @@ -353,7 +353,7 @@ async def open_clmm_position( upper_price: 250 base_token_amount: 0.01 quote_token_amount: 2 - slippage_pct: 1 + slippage_pct: 1 (optional; omit to use the connector's configured slippagePct) wallet_address: (optional) extra_params: {"strategyType": 0} # Meteora-specific @@ -394,7 +394,8 @@ async def open_clmm_position( # opening a position without knowing its tokens would corrupt the position record. pool_info = check_gateway_error(await accounts_service.gateway_client.clmm_pool_info( connector=request.connector, - chain_network=request.network + chain_network=request.network, + pool_address=request.pool_address )) # Extract tokens from pool info @@ -421,7 +422,7 @@ async def open_clmm_position( upper_price=float(request.upper_price), base_token_amount=float(request.base_token_amount) if request.base_token_amount else None, quote_token_amount=float(request.quote_token_amount) if request.quote_token_amount else None, - slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else 1.0, + slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else None, extra_params=request.extra_params )) @@ -557,13 +558,29 @@ async def add_liquidity_to_clmm_position( position_address: '...' base_token_amount: 0.5 quote_token_amount: 50.0 - slippage_pct: 1 + slippage_pct: 1 (optional; omit to use the connector's configured slippagePct) wallet_address: (optional) + extra_params: {"strategyType": 0} # Meteora-specific Returns: Transaction hash """ try: + # Same contract as /clmm/open: Gateway's unified add destructures ONLY + # strategyType from the body and silently drops any other key. + supported_extra_params = {"strategyType"} + if request.extra_params: + unknown = set(request.extra_params) - supported_extra_params + if unknown: + raise HTTPException( + status_code=400, + detail=( + f"Unsupported extra_params {sorted(unknown)}: Gateway's unified " + f"/trading/clmm/add honors only {sorted(supported_extra_params)} " + "and silently ignores everything else." + ) + ) + if not await accounts_service.gateway_client.ping(): raise HTTPException(status_code=503, detail="Gateway service is not available") @@ -584,7 +601,8 @@ async def add_liquidity_to_clmm_position( position_address=request.position_address, base_token_amount=float(request.base_token_amount) if request.base_token_amount else None, quote_token_amount=float(request.quote_token_amount) if request.quote_token_amount else None, - slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else 1.0 + slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else None, + extra_params=request.extra_params )) transaction_hash = result.get("signature") or result.get("txHash") or result.get("hash") @@ -667,6 +685,7 @@ async def remove_liquidity_from_clmm_position( network: 'solana-mainnet-beta' position_address: '...' percentage: 50 + slippage_pct: 1 (optional; Orca only — other connectors ignore it) wallet_address: (optional) Returns: @@ -691,7 +710,8 @@ async def remove_liquidity_from_clmm_position( chain_network=request.network, wallet_address=wallet_address, position_address=request.position_address, - percentage=float(request.percentage) + percentage=float(request.percentage), + slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else None )) transaction_hash = result.get("signature") or result.get("txHash") or result.get("hash") @@ -1301,12 +1321,11 @@ async def create_clmm_pool( under Gateway's own names — the same contract as open's extra_params. """ try: - # Gateway's unified create-pool destructures exactly these; anything else - # would be silently ignored there, so reject it loudly here. - supported_extra_params = { - "binStep", "feeBps", "ammConfigIndex", "fee", "tickSpacing", - "ammConfig", "gasPrice", "maxGas", - } + # Gateway's unified create-pool destructures exactly these (binStep for + # meteora/orca, feeBps for meteora + EVM V3 fee tier, ammConfigIndex for + # raydium/pancakeswap-sol); anything else would be silently ignored there, + # so reject it loudly here. + supported_extra_params = {"binStep", "feeBps", "ammConfigIndex"} if request.extra_params: unknown = set(request.extra_params) - supported_extra_params if unknown: diff --git a/routers/gateway_swap.py b/routers/gateway_swap.py index 96c6b406..588bc674 100644 --- a/routers/gateway_swap.py +++ b/routers/gateway_swap.py @@ -44,6 +44,25 @@ def get_transaction_status_from_response(gateway_response: dict) -> str: return "SUBMITTED" +# Gateway's unified /trading/swap routes destructure a fixed set of connector-specific +# keys from the request and silently ignore anything else. Reject unknown extra_params +# here so a typo fails loudly instead of quoting/trading with the parameter dropped. +SUPPORTED_SWAP_EXTRA_PARAMS = {"approximateIfNoExactOut"} + + +def validate_swap_extra_params(extra_params: Optional[dict]) -> None: + unknown = set(extra_params or {}) - SUPPORTED_SWAP_EXTRA_PARAMS + if unknown: + raise HTTPException( + status_code=400, + detail=( + f"Unsupported extra_params {sorted(unknown)}: Gateway's unified " + f"/trading/swap routes honor only {sorted(SUPPORTED_SWAP_EXTRA_PARAMS)} " + "and silently ignore everything else." + ) + ) + + @router.post("/swap/quote", response_model=SwapQuoteResponse) async def get_swap_quote( request: SwapQuoteRequest, @@ -58,12 +77,15 @@ async def get_swap_quote( trading_pair: 'SOL-USDC' side: 'BUY' amount: 1 - slippage_pct: 1 + slippage_pct: 1 (optional; omit to use the connector's configured slippagePct) + extra_params: {"approximateIfNoExactOut": false} # Solana routers Returns: - Quote with price, expected output amount, and gas estimate + Quote with price, expected output amount, and execution-safety fields """ try: + validate_swap_extra_params(request.extra_params) + if not await accounts_service.gateway_client.ping(): raise HTTPException(status_code=503, detail="Gateway service is not available") @@ -78,7 +100,8 @@ async def get_swap_quote( quote_asset=quote, amount=float(request.amount), side=request.side, - slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else 1.0 + slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else None, + extra_params=request.extra_params )) # Re-frame Gateway's token-flow response (tokenIn/tokenOut) into pair terms, @@ -99,8 +122,8 @@ def _dec(key): price_impact_pct=_dec("priceImpactPct"), pool_address=result.get("poolAddress"), route_path=result.get("routePath"), - slippage_pct=(_dec("slippagePct") - or (request.slippage_pct if request.slippage_pct is not None else Decimal("1.0"))), + slippage_pct=(_dec("slippagePct") if result.get("slippagePct") is not None + else request.slippage_pct), ) except HTTPException: @@ -129,13 +152,16 @@ async def execute_swap( trading_pair: 'SOL-USDC' side: 'BUY' amount: 1 - slippage_pct: 1 + slippage_pct: 1 (optional; omit to use the connector's configured slippagePct) wallet_address: (optional, uses default if not provided) + extra_params: {"approximateIfNoExactOut": false} # Solana routers Returns: Transaction hash and swap details """ try: + validate_swap_extra_params(request.extra_params) + if not await accounts_service.gateway_client.ping(): raise HTTPException(status_code=503, detail="Gateway service is not available") @@ -160,7 +186,8 @@ async def execute_swap( quote_asset=quote, amount=float(request.amount), side=request.side, - slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else 1.0 + slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else None, + extra_params=request.extra_params )) transaction_hash = result.get("signature") or result.get("txHash") or result.get("hash") if not transaction_hash: @@ -200,7 +227,7 @@ async def execute_swap( "input_amount": float(input_amount), "output_amount": float(output_amount), "price": float(price), - "slippage_pct": float(request.slippage_pct) if request.slippage_pct is not None else 1.0, + "slippage_pct": float(request.slippage_pct) if request.slippage_pct is not None else None, "status": tx_status, "pool_address": result.get("poolAddress") or result.get("pool_address") } diff --git a/services/gateway_client.py b/services/gateway_client.py index 87ccdf68..58936393 100644 --- a/services/gateway_client.py +++ b/services/gateway_client.py @@ -440,7 +440,8 @@ async def quote_swap( quote_asset: str, amount: float, side: str, - slippage_pct: Optional[float] = None + slippage_pct: Optional[float] = None, + extra_params: Optional[Dict] = None ) -> Dict: """ Get a swap quote via Gateway's unified /trading/swap/quote endpoint. @@ -450,6 +451,8 @@ async def quote_swap( ('jupiter/router', 'raydium/amm', 'meteora/clmm'). chain_network: 'chain-network' format (e.g. 'solana-mainnet-beta'). For amm/clmm providers Gateway resolves the pool from its pool list. + extra_params: Connector-specific query params under Gateway's own names + (e.g. approximateIfNoExactOut). The router validates keys first. """ params = { "chainNetwork": chain_network, @@ -461,6 +464,11 @@ async def quote_swap( } if slippage_pct is not None: params["slippagePct"] = str(slippage_pct) + if extra_params: + # Query params must be strings for aiohttp; Gateway's schema coerces + # "true"/"false" back to booleans. + for key, value in extra_params.items(): + params[key] = str(value).lower() if isinstance(value, bool) else str(value) return await self._request("GET", "trading/swap/quote", params=params) @@ -473,9 +481,14 @@ async def execute_swap( quote_asset: str, amount: float, side: str, - slippage_pct: Optional[float] = None + slippage_pct: Optional[float] = None, + extra_params: Optional[Dict] = None ) -> Dict: - """Execute a swap via Gateway's unified /trading/swap/execute endpoint.""" + """Execute a swap via Gateway's unified /trading/swap/execute endpoint. + + extra_params carries connector-specific params under Gateway's own names + (e.g. approximateIfNoExactOut). The router validates keys first. + """ payload = { "chainNetwork": chain_network, "connector": self.normalize_swap_connector(connector), @@ -487,6 +500,8 @@ async def execute_swap( } if slippage_pct is not None: payload["slippagePct"] = slippage_pct + if extra_params: + payload.update(extra_params) return await self._request("POST", "trading/swap/execute", json=payload) @@ -537,7 +552,8 @@ async def clmm_add_liquidity( position_address: str, base_token_amount: Optional[float] = None, quote_token_amount: Optional[float] = None, - slippage_pct: Optional[float] = None + slippage_pct: Optional[float] = None, + extra_params: Optional[Dict] = None ) -> Dict: """Add more liquidity to an existing CLMM position""" payload = { @@ -553,6 +569,10 @@ async def clmm_add_liquidity( if slippage_pct is not None: payload["slippagePct"] = slippage_pct + # Connector-specific parameters (e.g. Meteora's strategyType) + if extra_params: + payload.update(extra_params) + return await self._request("POST", "trading/clmm/add", json=payload) async def clmm_close_position( @@ -576,16 +596,24 @@ async def clmm_remove_liquidity( chain_network: str, wallet_address: str, position_address: str, - percentage: float + percentage: float, + slippage_pct: Optional[float] = None ) -> Dict: - """Remove liquidity from a CLMM position (partial)""" - return await self._request("POST", "trading/clmm/remove", json={ + """Remove liquidity from a CLMM position (partial). + + slippage_pct is only honored by the Orca connector; others ignore it. + """ + payload = { "connector": connector, "chainNetwork": chain_network, "walletAddress": wallet_address, "positionAddress": position_address, "percentageToRemove": percentage - }) + } + if slippage_pct is not None: + payload["slippagePct"] = slippage_pct + + return await self._request("POST", "trading/clmm/remove", json=payload) async def clmm_position_info( self, @@ -689,9 +717,9 @@ async def clmm_create_pool( """Create a new (empty) CLMM pool. extra_params carries the connector-specific create params under Gateway's own - names (binStep, feeBps, ammConfigIndex, fee, tickSpacing, ammConfig, gasPrice, - maxGas) and is spread into the payload — the same contract as clmm open's - extra_params. The router validates keys before this is called. + names (binStep, feeBps, ammConfigIndex) and is spread into the payload — the + same contract as clmm open's extra_params. The router validates keys before + this is called. """ payload = { "connector": connector, @@ -939,14 +967,16 @@ async def amm_create_pool( base_token_amount: float, quote_token_amount: Optional[float] = None, initial_price: Optional[float] = None, + slippage_pct: Optional[float] = None, extra_params: Optional[Dict] = None, ) -> Dict: """Create and seed a new AMM pool. - extra_params carries the connector-specific create params under Gateway's own - names (configAddress, feeConfigIndex, openTime, gasPrice, maxGas, slippagePct) - and is spread into the payload — the same contract as clmm open's extra_params. - The router validates keys before this is called. + slippage_pct is the seeding slippage (uniswap/pancakeswap only); omitted, the + connector's configured slippagePct applies. extra_params carries the + connector-specific create params under Gateway's own names (configAddress, + ammConfigIndex) and is spread into the payload — the same contract as clmm + open's extra_params. The router validates keys before this is called. """ payload = { "connector": connector, @@ -961,6 +991,8 @@ async def amm_create_pool( payload["quoteTokenAmount"] = quote_token_amount if initial_price is not None: payload["initialPrice"] = initial_price + if slippage_pct is not None: + payload["slippagePct"] = slippage_pct if extra_params: payload.update(extra_params) return await self._request("POST", "trading/amm/create-pool", json=payload) diff --git a/test/test_gateway_client_contract.py b/test/test_gateway_client_contract.py index 0152f5cd..c2b8e305 100644 --- a/test/test_gateway_client_contract.py +++ b/test/test_gateway_client_contract.py @@ -110,6 +110,45 @@ async def test_quote_swap_zero_slippage_is_sent(client_and_calls): assert calls[0]["params"]["slippagePct"] == "0" +@pytest.mark.asyncio +async def test_quote_swap_omits_slippage_when_unset(client_and_calls): + """No slippage_pct => omit the key so Gateway applies the connector's configured default.""" + client, calls = client_and_calls + await client.quote_swap( + connector="jupiter", chain_network="solana-mainnet-beta", + base_asset="SOL", quote_asset="USDC", amount=1, side="SELL", + ) + assert "slippagePct" not in calls[0]["params"] + + +@pytest.mark.asyncio +async def test_quote_swap_extra_params_bool_as_query_string(client_and_calls): + """approximateIfNoExactOut rides extra_params; aiohttp needs query values as strings, + and Gateway's schema coerces 'false' back to boolean.""" + client, calls = client_and_calls + await client.quote_swap( + connector="jupiter", chain_network="solana-mainnet-beta", + base_asset="SOL", quote_asset="USDC", amount=1, side="BUY", + extra_params={"approximateIfNoExactOut": False}, + ) + assert calls[0]["params"]["approximateIfNoExactOut"] == "false" + + +@pytest.mark.asyncio +async def test_execute_swap_extra_params_and_slippage_omission(client_and_calls): + """extra_params keys land in the JSON body under Gateway's own names (booleans + intact); unset slippage_pct is omitted so the connector default applies.""" + client, calls = client_and_calls + await client.execute_swap( + connector="jupiter/router", chain_network="solana-mainnet-beta", + wallet_address="WALLET", base_asset="SOL", quote_asset="USDC", + amount=0.1, side="BUY", extra_params={"approximateIfNoExactOut": False}, + ) + body = calls[0]["json"] + assert body["approximateIfNoExactOut"] is False + assert "slippagePct" not in body + + # ============================================ # CLMM paths and payloads (unified /trading/clmm) # ============================================ @@ -151,6 +190,22 @@ async def test_clmm_add_liquidity_path_and_keys(client_and_calls): assert call["json"]["chainNetwork"] == "solana-mainnet-beta" +@pytest.mark.asyncio +async def test_clmm_add_liquidity_extra_params_and_slippage_omission(client_and_calls): + """strategyType rides extra_params into the body under Gateway's name; unset + slippage_pct is omitted so the connector default applies.""" + client, calls = client_and_calls + await client.clmm_add_liquidity( + connector="meteora", chain_network="solana-mainnet-beta", + wallet_address="WALLET", position_address="POS", + base_token_amount=0.5, quote_token_amount=50.0, + extra_params={"strategyType": 0}, + ) + body = calls[0]["json"] + assert body["strategyType"] == 0 + assert "slippagePct" not in body + + @pytest.mark.asyncio async def test_clmm_remove_liquidity_uses_percentage_to_remove(client_and_calls): client, calls = client_and_calls @@ -162,6 +217,20 @@ async def test_clmm_remove_liquidity_uses_percentage_to_remove(client_and_calls) assert (call["method"], call["path"]) == ("POST", "trading/clmm/remove") assert call["json"]["percentageToRemove"] == 50.0 assert "percentage" not in call["json"] + # No slippage_pct given => omitted (Orca falls back to its configured default) + assert "slippagePct" not in call["json"] + + +@pytest.mark.asyncio +async def test_clmm_remove_liquidity_sends_slippage_when_set(client_and_calls): + """Orca honors slippagePct on remove; the client must forward it.""" + client, calls = client_and_calls + await client.clmm_remove_liquidity( + connector="orca", chain_network="solana-mainnet-beta", + wallet_address="WALLET", position_address="POS", percentage=100.0, + slippage_pct=0.5, + ) + assert calls[0]["json"]["slippagePct"] == 0.5 @pytest.mark.asyncio @@ -348,32 +417,47 @@ async def test_amm_create_pool_meteora_extras(client_and_calls): c = calls[0] assert (c["method"], c["path"]) == ("POST", "trading/amm/create-pool") assert c["json"]["configAddress"] == "CFG123" - # Raydium/Uniswap extras and seed-price fields omitted when unset - for k in ("feeConfigIndex", "gasPrice", "maxGas", "quoteTokenAmount", "initialPrice"): + # Raydium extras, seeding slippage and seed-price fields omitted when unset + for k in ("ammConfigIndex", "slippagePct", "quoteTokenAmount", "initialPrice"): assert k not in c["json"] @pytest.mark.asyncio -async def test_amm_create_pool_raydium_fee_config_index(client_and_calls): +async def test_amm_create_pool_raydium_amm_config_index(client_and_calls): client, calls = client_and_calls await client.amm_create_pool(connector="raydium", chain_network=NET, wallet_address=WALLET, base_token="SOL", quote_token="USDC", base_token_amount=1.0, - extra_params={"feeConfigIndex": 0}, quote_token_amount=100.0) + extra_params={"ammConfigIndex": 0}, quote_token_amount=100.0) c = calls[0] - assert c["json"]["feeConfigIndex"] == 0 + assert c["json"]["ammConfigIndex"] == 0 assert c["json"]["quoteTokenAmount"] == 100.0 assert "configAddress" not in c["json"] @pytest.mark.asyncio -async def test_amm_create_pool_uniswap_gas_extras(client_and_calls): +async def test_amm_create_pool_uniswap_seeding_slippage(client_and_calls): + """EVM seeding slippage is the standard slippagePct field, not an extra param.""" client, calls = client_and_calls await client.amm_create_pool(connector="uniswap", chain_network="ethereum-mainnet", wallet_address=WALLET, base_token="WETH", quote_token="USDC", base_token_amount=1.0, - initial_price=3000.0, - extra_params={"gasPrice": 20.0, "maxGas": 500000}) + initial_price=3000.0, slippage_pct=0.5) c = calls[0] - assert c["json"]["gasPrice"] == 20.0 - assert c["json"]["maxGas"] == 500000 + assert c["json"]["slippagePct"] == 0.5 assert c["json"]["initialPrice"] == 3000.0 - assert "configAddress" not in c["json"] and "feeConfigIndex" not in c["json"] + assert "configAddress" not in c["json"] and "ammConfigIndex" not in c["json"] + + +@pytest.mark.asyncio +async def test_clmm_create_pool_meteora_extras(client_and_calls): + """CLMM create-pool extras ride extra_params under Gateway's names + (binStep/feeBps/ammConfigIndex — no gas keys, those don't exist on the route).""" + client, calls = client_and_calls + await client.clmm_create_pool(connector="meteora", chain_network=NET, wallet_address=WALLET, + base_token="SOL", quote_token="USDC", initial_price=100.0, + extra_params={"binStep": 20, "feeBps": 20}) + c = calls[0] + assert (c["method"], c["path"]) == ("POST", "trading/clmm/create-pool") + assert c["json"]["binStep"] == 20 + assert c["json"]["feeBps"] == 20 + assert c["json"]["initialPrice"] == 100.0 + assert "ammConfigIndex" not in c["json"] From cd3cab79586429572eacf5413a284f1d43f37ba0 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 18 Aug 2026 20:21:49 -0700 Subject: [PATCH 10/54] fix(gateway): close the audit findings on the trading-route contracts - Pending CLMM open no longer 500s with an orphaned on-chain position: Gateway's OpenPositionResponse carries positionAddress only inside the confirmed-only data object (the schema strips everything else, so the old top-level fallback keys could never arrive). A submitted-not- confirmed open now returns 200 with position_address=None and the signature to poll; the poller's discovery sweep records the position once it lands. A confirmed response without an address is still a loud 500, and the confirmed path now reports status "confirmed". - extra_params validation hardened via a shared routers/gateway_extras helper: unknown keys, keys sent to a connector that ignores them, and wrong-typed values (incl. null, and bool-vs-int subclass traps) all 400 locally instead of being silently dropped or reaching Gateway as the string "None". - ROUTER_CONNECTORS gains dflow/okx/titan so bare names route to /router instead of misrouting to /clmm and 404ing. - Swap DB record: price is quote-per-base for BOTH sides (BUY was inverted), the pending fallback keeps tokenIn/tokenOut denominations (BUY no longer stores a base amount in the quote-denominated input column), side is normalized to uppercase, and the dead poolAddress read (never in the execute response schema) is an explicit None. - clmm_fetch_pools speaks each connector's real schema: meteora page/includeUnverified + "field:direction" sortBy; orca sortBy/sortDirection/verifiedOnly, with page>0 rejected loudly for orca instead of a silent no-op that echoed the requested page. - AMM quote/execute swap uppercase side like the unified path. - Honesty fixes: swap amount documented as base-denominated for BUY (ExactOut), poll docstring documents txStatus -2 NOT_FOUND as terminal, activeBinId is not meteora-only, and swap listings report a recorded slippage of 0 as 0 instead of null. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- .../repositories/gateway_swap_repository.py | 3 +- models/gateway_trading.py | 15 +- routers/gateway_amm.py | 23 ++- routers/gateway_clmm.py | 179 ++++++++++-------- routers/gateway_extras.py | 53 ++++++ routers/gateway_swap.py | 63 +++--- services/gateway_client.py | 34 ++-- test/test_extra_params_validation.py | 84 ++++++++ test/test_gateway_client_contract.py | 35 +++- 9 files changed, 344 insertions(+), 145 deletions(-) create mode 100644 routers/gateway_extras.py create mode 100644 test/test_extra_params_validation.py diff --git a/database/repositories/gateway_swap_repository.py b/database/repositories/gateway_swap_repository.py index c5aea52f..b0a7d263 100644 --- a/database/repositories/gateway_swap_repository.py +++ b/database/repositories/gateway_swap_repository.py @@ -157,7 +157,8 @@ def to_dict(self, swap: GatewaySwap) -> Dict: "input_amount": float(swap.input_amount), "output_amount": float(swap.output_amount), "price": float(swap.price), - "slippage_pct": float(swap.slippage_pct) if swap.slippage_pct else None, + # `is not None`: a recorded slippage of 0 is a real value, not "unset" + "slippage_pct": float(swap.slippage_pct) if swap.slippage_pct is not None else None, "gas_fee": float(swap.gas_fee) if swap.gas_fee else None, "gas_token": swap.gas_token, "status": swap.status, diff --git a/models/gateway_trading.py b/models/gateway_trading.py index 748c89b1..ab944766 100644 --- a/models/gateway_trading.py +++ b/models/gateway_trading.py @@ -20,7 +20,9 @@ class SwapQuoteRequest(BaseModel): network: str = Field(description="Network ID in 'chain-network' format (e.g., 'solana-mainnet-beta', 'ethereum-mainnet')") trading_pair: str = Field(description="Trading pair in BASE-QUOTE format (e.g., 'SOL-USDC')") side: str = Field(description="Trade side: 'BUY' or 'SELL'") - amount: Decimal = Field(description="Amount to swap (in base token for SELL, quote token for BUY)") + amount: Decimal = Field( + description="Amount denominated in the BASE token (SELL: base to sell; BUY: base to receive — " + "Gateway quotes BUY as ExactOut)") slippage_pct: Optional[Decimal] = Field( default=None, description="Maximum slippage percentage; omit to use the connector's configured slippagePct") extra_params: Optional[Dict[str, Any]] = Field( @@ -65,7 +67,8 @@ class SwapExecuteRequest(BaseModel): network: str = Field(description="Network ID in 'chain-network' format (e.g., 'solana-mainnet-beta')") trading_pair: str = Field(description="Trading pair (e.g., 'SOL-USDC')") side: str = Field(description="Trade side: 'BUY' or 'SELL'") - amount: Decimal = Field(description="Amount to swap") + amount: Decimal = Field( + description="Amount denominated in the BASE token (SELL: base to sell; BUY: base to receive)") slippage_pct: Optional[Decimal] = Field( default=None, description="Maximum slippage percentage; omit to use the connector's configured slippagePct") wallet_address: Optional[str] = Field(default=None, description="Wallet address (optional, uses default if not provided)") @@ -112,7 +115,11 @@ class CLMMOpenPositionRequest(BaseModel): class CLMMOpenPositionResponse(BaseModel): """Response after opening a new CLMM position""" transaction_hash: str = Field(description="Transaction hash") - position_address: str = Field(description="Address of the newly created position") + position_address: Optional[str] = Field( + default=None, + description="Address of the newly created position. None when the transaction was " + "submitted but not yet confirmed (Gateway only knows the address once the tx lands) — " + "poll the transaction; the poller records the position once it appears on-chain") trading_pair: str = Field(description="Trading pair") pool_address: str = Field(description="Pool address") lower_price: Decimal = Field(description="Lower price bound") @@ -334,7 +341,7 @@ class CLMMPoolInfoResponse(BaseModel): price: Decimal = Field(description="Current pool price") base_token_amount: Decimal = Field(alias="baseTokenAmount", description="Total base token liquidity") quote_token_amount: Decimal = Field(alias="quoteTokenAmount", description="Total quote token liquidity") - active_bin_id: Optional[int] = Field(None, alias="activeBinId", description="Currently active bin ID (Meteora DLMM only)") + active_bin_id: Optional[int] = Field(None, alias="activeBinId", description="Currently active bin/tick ID") # No dynamicFeePct/minBinId/maxBinId: those are Meteora connector extensions that # Gateway's unified /trading/clmm/pool-info response schema strips before serialization, # so they can never arrive here — and nothing downstream consumes them. diff --git a/routers/gateway_amm.py b/routers/gateway_amm.py index a290179d..92ad1133 100644 --- a/routers/gateway_amm.py +++ b/routers/gateway_amm.py @@ -32,6 +32,7 @@ AMMRemoveLiquidityRequest, AMMTransactionResponse, ) +from routers.gateway_extras import ExtraParamsSpec, validate_extra_params from services.accounts_service import AccountsService from services.gateway_client import GatewayError, check_gateway_error @@ -39,6 +40,14 @@ router = APIRouter(tags=["Gateway AMM"], prefix="/gateway") +# Gateway's unified create-pool destructure, per consuming connector: +# configAddress (meteora DAMM v2 — required there), ammConfigIndex (raydium CPMM). +# EVM seeding slippage is the standard slippage_pct field, not an extra param. +AMM_CREATE_POOL_EXTRA_PARAMS_SPEC: ExtraParamsSpec = { + "configAddress": ((str,), {"meteora"}), + "ammConfigIndex": ((int,), {"raydium"}), +} + async def _require_gateway(accounts_service: AccountsService) -> None: if not await accounts_service.gateway_client.ping(): @@ -295,19 +304,9 @@ async def create_amm_pool( extra_params. Seeding slippage for uniswap/pancakeswap is the standard slippage_pct field. """ try: - # Gateway's unified create-pool destructures exactly these; anything else - # would be silently ignored there, so reject it loudly here. - supported_extra_params = {"configAddress", "ammConfigIndex"} + validate_extra_params(request.extra_params, AMM_CREATE_POOL_EXTRA_PARAMS_SPEC, + request.connector, "unified /trading/amm/create-pool") extra_params = request.extra_params or {} - unknown = set(extra_params) - supported_extra_params - if unknown: - raise HTTPException( - status_code=400, - detail=( - f"Unsupported extra_params {sorted(unknown)}: Gateway's unified " - f"/trading/amm/create-pool honors only {sorted(supported_extra_params)}." - ) - ) if request.connector == "meteora" and not extra_params.get("configAddress"): raise HTTPException( status_code=400, diff --git a/routers/gateway_clmm.py b/routers/gateway_clmm.py index 290a2b56..7a69a233 100644 --- a/routers/gateway_clmm.py +++ b/routers/gateway_clmm.py @@ -31,6 +31,7 @@ CLMMQuotePositionResponse, CLMMRemoveLiquidityRequest, ) +from routers.gateway_extras import ExtraParamsSpec, validate_extra_params from services.accounts_service import AccountsService from services.gateway_client import GatewayError, check_gateway_error @@ -38,6 +39,21 @@ router = APIRouter(tags=["Gateway CLMM"], prefix="/gateway") +# Gateway's unified open/add destructure ONLY strategyType, and only Meteora +# consumes it (Spot=0 / Curve=1). +CLMM_LIQUIDITY_EXTRA_PARAMS_SPEC: ExtraParamsSpec = { + "strategyType": ((int,), {"meteora"}), +} + +# Gateway's unified create-pool destructure, per consuming connector: binStep +# (meteora bin step / orca tick spacing), feeBps (meteora; required V3 fee tier +# for uniswap/pancakeswap), ammConfigIndex (raydium, pancakeswap-sol). +CLMM_CREATE_POOL_EXTRA_PARAMS_SPEC: ExtraParamsSpec = { + "binStep": ((int,), {"meteora", "orca"}), + "feeBps": ((int, float), {"meteora", "uniswap", "pancakeswap"}), + "ammConfigIndex": ((int,), {"raydium", "pancakeswap-sol"}), +} + def get_transaction_status_from_response(gateway_response: dict) -> str: """ @@ -275,25 +291,37 @@ async def get_clmm_pools( logger.info(f"Fetching pools from Gateway ({connector}, page={page}, limit={limit}, query={search_term})") - # Build sort_by for Gateway (connector-specific format) - sort_by = None - if sort_key: - if connector.lower() == "meteora": - time_suffix = "_24h" if sort_key in ["volume", "fees"] else "" - direction = order_by if order_by else "desc" - sort_by = f"{sort_key}{time_suffix}:{direction}" - else: # orca - sort_by = sort_key - - gateway_data = check_gateway_error(await accounts_service.gateway_client.clmm_fetch_pools( - connector=connector.lower(), - network="mainnet-beta", - page=page, - limit=limit, - query=search_term, - sort_by=sort_by, - include_unverified=include_unknown - )) + # The two fetch-pools routes take different params: meteora paginates and + # filters via page/includeUnverified with "field:direction" sortBy; orca does + # not paginate and uses sortBy + sortDirection + verifiedOnly. + if connector.lower() == "meteora": + time_suffix = "_24h" if sort_key in ["volume", "fees"] else "" + direction = order_by if order_by else "desc" + gateway_data = check_gateway_error(await accounts_service.gateway_client.clmm_fetch_pools( + connector="meteora", + network="mainnet-beta", + limit=limit, + query=search_term, + sort_by=f"{sort_key}{time_suffix}:{direction}" if sort_key else None, + page=page, + include_unverified=include_unknown + )) + else: # orca + if page > 0: + raise HTTPException( + status_code=400, + detail="Orca's pool listing does not paginate; page is meteora-only. " + "Raise limit instead (max 100)." + ) + gateway_data = check_gateway_error(await accounts_service.gateway_client.clmm_fetch_pools( + connector="orca", + network="mainnet-beta", + limit=limit, + query=search_term, + sort_by=sort_key, + sort_direction=order_by, + verified_only=not include_unknown + )) # Transform Gateway response to our format # Both Meteora and Orca now return same format: {pools: [...], total, page, pageSize} @@ -358,25 +386,13 @@ async def open_clmm_position( extra_params: {"strategyType": 0} # Meteora-specific Returns: - Transaction hash and position address + Transaction hash and position address. position_address is None when the + transaction was submitted but not yet confirmed — poll the transaction; the + poller's discovery sweep records the position once it lands on-chain. """ try: - # Gateway's unified open destructures ONLY strategyType from the body; any other - # extra_params key is silently dropped there. Reject unknown keys here so a typo - # (or a connector param the unified route does not carry) fails loudly instead of - # opening a position with the parameter ignored. - supported_extra_params = {"strategyType"} - if request.extra_params: - unknown = set(request.extra_params) - supported_extra_params - if unknown: - raise HTTPException( - status_code=400, - detail=( - f"Unsupported extra_params {sorted(unknown)}: Gateway's unified " - f"/trading/clmm/open honors only {sorted(supported_extra_params)} " - "and silently ignores everything else." - ) - ) + validate_extra_params(request.extra_params, CLMM_LIQUIDITY_EXTRA_PARAMS_SPEC, + request.connector, "unified /trading/clmm/open") if not await accounts_service.gateway_client.ping(): raise HTTPException(status_code=503, detail="Gateway service is not available") @@ -426,23 +442,51 @@ async def open_clmm_position( extra_params=request.extra_params )) - transaction_hash = result.get("signature") or result.get("txHash") or result.get("hash") + transaction_hash = result.get("signature") + if not transaction_hash: + raise HTTPException(status_code=500, detail="No transaction signature returned from Gateway") - # Position address can be at root level or nested in data object - data = result.get("data", {}) - position_address = (result.get("positionAddress") or result.get("position") - or data.get("positionAddress") or data.get("position")) + # Gateway's OpenPositionResponse carries position details only inside `data`, + # which is present only for CONFIRMED transactions (the response schema strips + # any other key, so there is no top-level fallback to read). + data = result.get("data") or {} + position_address = data.get("positionAddress") + tx_status = get_transaction_status_from_response(result) + + if not position_address: + if tx_status == "CONFIRMED": + raise HTTPException( + status_code=500, + detail="Gateway confirmed the open but returned no position address") + # Submitted-not-confirmed: the position address is unknowable until the tx + # lands. The tx IS in flight, so failing here would report a false failure + # and orphan the position; instead return the signature for the caller to + # poll — the transaction poller's discovery sweep records the position in + # the database once it appears on-chain. + logger.warning( + f"CLMM open submitted but not confirmed ({transaction_hash}); position address " + "unknown — the poller's discovery sweep will record the position once it lands") + return CLMMOpenPositionResponse( + transaction_hash=transaction_hash, + position_address=None, + trading_pair=trading_pair, + pool_address=request.pool_address, + lower_price=request.lower_price, + upper_price=request.upper_price, + base_token_amount_added=request.base_token_amount, + quote_token_amount_added=request.quote_token_amount, + position_rent=None, + status="submitted", + ) # Extract position rent (SOL locked for position NFT) position_rent = data.get("positionRent") if position_rent: logger.info(f"Position rent: {position_rent} SOL") - # Prefer the CONFIRMED on-chain amounts over the requested ones: slippage and - # rounding make them differ, and persisting the request silently diverges the - # DB from the chain. data is only present when Gateway confirmed the tx, so - # the requested amounts remain the fallback for submitted-not-confirmed - # (reconciled later by the poller). + # CONFIRMED path: prefer the on-chain amounts over the requested ones — + # slippage and rounding make them differ, and persisting the request + # silently diverges the DB from the chain. base_amount_added = data.get("baseTokenAmountAdded") if base_amount_added is None: base_amount_added = float(request.base_token_amount) if request.base_token_amount else 0 @@ -450,20 +494,12 @@ async def open_clmm_position( if quote_amount_added is None: quote_amount_added = float(request.quote_token_amount) if request.quote_token_amount else 0 - if not transaction_hash: - raise HTTPException(status_code=500, detail="No transaction hash returned from Gateway") - if not position_address: - raise HTTPException(status_code=500, detail="No position address returned from Gateway") - # Calculate percentage: (upper_price - lower_price) / lower_price percentage = None if request.lower_price and request.upper_price and request.lower_price > 0: percentage = float((request.upper_price - request.lower_price) / request.lower_price) logger.info(f"Position price range percentage: {percentage:.4f} ({percentage*100:.2f}%)") - # Get transaction status from Gateway response - tx_status = get_transaction_status_from_response(result) - # Extract gas fee from Gateway response gas_fee = data.get("fee") gas_token = get_native_gas_token(chain) @@ -529,7 +565,7 @@ async def open_clmm_position( base_token_amount_added=Decimal(str(base_amount_added)) if base_amount_added else None, quote_token_amount_added=Decimal(str(quote_amount_added)) if quote_amount_added else None, position_rent=Decimal(str(position_rent)) if position_rent else None, - status="submitted" + status="confirmed" ) except HTTPException: @@ -566,20 +602,8 @@ async def add_liquidity_to_clmm_position( Transaction hash """ try: - # Same contract as /clmm/open: Gateway's unified add destructures ONLY - # strategyType from the body and silently drops any other key. - supported_extra_params = {"strategyType"} - if request.extra_params: - unknown = set(request.extra_params) - supported_extra_params - if unknown: - raise HTTPException( - status_code=400, - detail=( - f"Unsupported extra_params {sorted(unknown)}: Gateway's unified " - f"/trading/clmm/add honors only {sorted(supported_extra_params)} " - "and silently ignores everything else." - ) - ) + validate_extra_params(request.extra_params, CLMM_LIQUIDITY_EXTRA_PARAMS_SPEC, + request.connector, "unified /trading/clmm/add") if not await accounts_service.gateway_client.ping(): raise HTTPException(status_code=503, detail="Gateway service is not available") @@ -1321,21 +1345,8 @@ async def create_clmm_pool( under Gateway's own names — the same contract as open's extra_params. """ try: - # Gateway's unified create-pool destructures exactly these (binStep for - # meteora/orca, feeBps for meteora + EVM V3 fee tier, ammConfigIndex for - # raydium/pancakeswap-sol); anything else would be silently ignored there, - # so reject it loudly here. - supported_extra_params = {"binStep", "feeBps", "ammConfigIndex"} - if request.extra_params: - unknown = set(request.extra_params) - supported_extra_params - if unknown: - raise HTTPException( - status_code=400, - detail=( - f"Unsupported extra_params {sorted(unknown)}: Gateway's unified " - f"/trading/clmm/create-pool honors only {sorted(supported_extra_params)}." - ) - ) + validate_extra_params(request.extra_params, CLMM_CREATE_POOL_EXTRA_PARAMS_SPEC, + request.connector, "unified /trading/clmm/create-pool") if not await accounts_service.gateway_client.ping(): raise HTTPException(status_code=503, detail="Gateway service is not available") diff --git a/routers/gateway_extras.py b/routers/gateway_extras.py new file mode 100644 index 00000000..94148d42 --- /dev/null +++ b/routers/gateway_extras.py @@ -0,0 +1,53 @@ +""" +Shared validation for connector-specific extra_params forwarded to Gateway's +unified /trading routes. + +Gateway destructures a fixed set of connector-specific keys from each request +body and silently ignores everything else, so hapi rejects loudly instead of +letting a typo'd key, a key sent to a connector that ignores it, or a value of +the wrong type get silently dropped or misparsed downstream. +""" +from typing import Any, Dict, Optional, Set, Tuple + +from fastapi import HTTPException + +# Spec entry: key -> (allowed value types, connectors that honor the key). +ExtraParamsSpec = Dict[str, Tuple[Tuple[type, ...], Set[str]]] + + +def validate_extra_params( + extra_params: Optional[Dict[str, Any]], + spec: ExtraParamsSpec, + connector: str, + route: str, +) -> None: + """Reject extra_params Gateway's `route` would silently drop or misparse. + + Typed swap providers like 'jupiter/router' validate on their base name. + """ + for key, value in (extra_params or {}).items(): + if key not in spec: + raise HTTPException( + status_code=400, + detail=( + f"Unsupported extra_params key '{key}': Gateway's {route} honors " + f"only {sorted(spec)} and silently ignores everything else." + ), + ) + allowed_types, connectors = spec[key] + if connector.split("/")[0] not in connectors: + raise HTTPException( + status_code=400, + detail=( + f"extra_params.{key} only applies to {sorted(connectors)}; " + f"'{connector}' would silently ignore it." + ), + ) + # bool subclasses int: check it explicitly so True never passes as an int. + type_ok = (bool in allowed_types) if isinstance(value, bool) else isinstance(value, allowed_types) + if not type_ok: + expected = "/".join(t.__name__ for t in allowed_types) + raise HTTPException( + status_code=400, + detail=f"extra_params.{key} must be {expected}, got {type(value).__name__} ({value!r}).", + ) diff --git a/routers/gateway_swap.py b/routers/gateway_swap.py index 588bc674..0183357b 100644 --- a/routers/gateway_swap.py +++ b/routers/gateway_swap.py @@ -14,6 +14,7 @@ from database.repositories import GatewaySwapRepository from deps import get_accounts_service, get_database_manager from models import SwapExecuteRequest, SwapExecuteResponse, SwapQuoteRequest, SwapQuoteResponse +from routers.gateway_extras import ExtraParamsSpec, validate_extra_params from services.accounts_service import AccountsService from services.gateway_client import GatewayError, check_gateway_error @@ -44,23 +45,11 @@ def get_transaction_status_from_response(gateway_response: dict) -> str: return "SUBMITTED" -# Gateway's unified /trading/swap routes destructure a fixed set of connector-specific -# keys from the request and silently ignore anything else. Reject unknown extra_params -# here so a typo fails loudly instead of quoting/trading with the parameter dropped. -SUPPORTED_SWAP_EXTRA_PARAMS = {"approximateIfNoExactOut"} - - -def validate_swap_extra_params(extra_params: Optional[dict]) -> None: - unknown = set(extra_params or {}) - SUPPORTED_SWAP_EXTRA_PARAMS - if unknown: - raise HTTPException( - status_code=400, - detail=( - f"Unsupported extra_params {sorted(unknown)}: Gateway's unified " - f"/trading/swap routes honor only {sorted(SUPPORTED_SWAP_EXTRA_PARAMS)} " - "and silently ignore everything else." - ) - ) +# Gateway's unified /trading/swap routes pass approximateIfNoExactOut only to the +# Solana router connectors' quote path; every other provider silently ignores it. +SWAP_EXTRA_PARAMS_SPEC: ExtraParamsSpec = { + "approximateIfNoExactOut": ((bool,), {"jupiter", "dflow", "okx", "titan"}), +} @router.post("/swap/quote", response_model=SwapQuoteResponse) @@ -84,7 +73,8 @@ async def get_swap_quote( Quote with price, expected output amount, and execution-safety fields """ try: - validate_swap_extra_params(request.extra_params) + validate_extra_params(request.extra_params, SWAP_EXTRA_PARAMS_SPEC, + request.connector, "unified /trading/swap/quote") if not await accounts_service.gateway_client.ping(): raise HTTPException(status_code=503, detail="Gateway service is not available") @@ -160,7 +150,8 @@ async def execute_swap( Transaction hash and swap details """ try: - validate_swap_extra_params(request.extra_params) + validate_extra_params(request.extra_params, SWAP_EXTRA_PARAMS_SPEC, + request.connector, "unified /trading/swap/execute") if not await accounts_service.gateway_client.ping(): raise HTTPException(status_code=503, detail="Gateway service is not available") @@ -193,19 +184,30 @@ async def execute_swap( if not transaction_hash: raise HTTPException(status_code=500, detail="No transaction hash returned from Gateway") - # Extract swap data from Gateway response - # Gateway returns amounts nested under 'data' object + # Gateway's `data` (present only when it confirmed the tx) speaks token flow: + # amountIn is the tokenIn amount — quote for BUY, base for SELL — and the DB + # columns keep that tokenIn/tokenOut denomination. data = result.get("data", {}) amount_in_raw = data.get("amountIn") amount_out_raw = data.get("amountOut") - # Use amounts from Gateway response, fallback to request amount if not available - input_amount = Decimal(str(amount_in_raw)) if amount_in_raw is not None else request.amount - output_amount = Decimal(str(amount_out_raw)) if amount_out_raw is not None else Decimal("0") - - # Calculate price from actual swap amounts - # Price = output / input (how much quote you get/pay per base) - price = output_amount / input_amount if input_amount > 0 else Decimal("0") + side = request.side.upper() + if amount_in_raw is not None and amount_out_raw is not None: + input_amount = Decimal(str(amount_in_raw)) + output_amount = Decimal(str(amount_out_raw)) + # Price in quote-per-base for both sides: SELL flows base->quote (out/in), + # BUY flows quote->base (in/out). + if side == "SELL": + price = output_amount / input_amount if input_amount > 0 else Decimal("0") + else: + price = input_amount / output_amount if output_amount > 0 else Decimal("0") + else: + # Submitted-not-confirmed: only the request leg of the flow is known — + # request.amount is base-denominated (SELL: base in, BUY: base out). + # The unknown leg and price stay 0 placeholders. + input_amount = request.amount if side == "SELL" else Decimal("0") + output_amount = request.amount if side == "BUY" else Decimal("0") + price = Decimal("0") # Get transaction status from Gateway response tx_status = get_transaction_status_from_response(result) @@ -223,13 +225,14 @@ async def execute_swap( "trading_pair": request.trading_pair, "base_token": base, "quote_token": quote, - "side": request.side, + "side": side, "input_amount": float(input_amount), "output_amount": float(output_amount), "price": float(price), "slippage_pct": float(request.slippage_pct) if request.slippage_pct is not None else None, "status": tx_status, - "pool_address": result.get("poolAddress") or result.get("pool_address") + # Gateway's execute response schema carries no pool information + "pool_address": None } await swap_repo.create_swap(swap_data) diff --git a/services/gateway_client.py b/services/gateway_client.py index 58936393..5e0eb8e2 100644 --- a/services/gateway_client.py +++ b/services/gateway_client.py @@ -8,7 +8,7 @@ # Connectors whose bare name maps to a router-type swap provider on Gateway. # All other connectors default to their CLMM route (meteora, orca, raydium, pancakeswap-sol). -ROUTER_CONNECTORS = {"jupiter", "0x", "uniswap", "pancakeswap"} +ROUTER_CONNECTORS = {"jupiter", "0x", "uniswap", "pancakeswap", "dflow", "okx", "titan"} class GatewayError(Exception): @@ -775,30 +775,40 @@ async def clmm_fetch_pools( self, connector: str, network: str, - page: int = 0, limit: int = 50, query: Optional[str] = None, sort_by: Optional[str] = None, - include_unverified: bool = True + page: Optional[int] = None, + include_unverified: Optional[bool] = None, + sort_direction: Optional[str] = None, + verified_only: Optional[bool] = None ) -> Dict: """ Discover CLMM pools from the connector's own listing API (meteora, orca). This is a per-connector Gateway route (no unified equivalent): it proxies the - DEX's pool-discovery API rather than Gateway's saved pool list. + DEX's pool-discovery API rather than Gateway's saved pool list. The schemas + differ per connector — meteora takes page/includeUnverified and a + "field:direction" sortBy; orca takes sortDirection/verifiedOnly and does not + paginate. Only keys the caller sets are sent; AJV strips unknown keys + Gateway-side, so sending the wrong connector's knob would be a silent no-op. """ params = { "network": network, "limit": limit, } - if page > 0: - params["page"] = page if query: params["query"] = query if sort_by: params["sortBy"] = sort_by - if not include_unverified: - params["includeUnverified"] = "false" + if page is not None and page > 0: + params["page"] = page + if include_unverified is not None: + params["includeUnverified"] = "true" if include_unverified else "false" + if sort_direction: + params["sortDirection"] = sort_direction + if verified_only is not None: + params["verifiedOnly"] = "true" if verified_only else "false" return await self._request("GET", f"connectors/{connector}/clmm/fetch-pools", params=params) @@ -854,7 +864,7 @@ async def amm_quote_swap( "chainNetwork": chain_network, "poolAddress": pool_address, "baseToken": base_token, - "side": side, + "side": side.upper(), "amount": amount, } if slippage_pct is not None: @@ -879,7 +889,7 @@ async def amm_execute_swap( "walletAddress": wallet_address, "poolAddress": pool_address, "baseToken": base_token, - "side": side, + "side": side.upper(), "amount": amount, } if slippage_pct is not None: @@ -1015,7 +1025,9 @@ async def poll_transaction( Returns: Transaction status dict with fields: - - txStatus: 1 for confirmed, 0 for pending, -1 for failed + - txStatus: 1 confirmed, 0 pending, -1 failed, -2 not found + (-2 is terminal on Solana once the blockhash expires — treat it + as dropped, not merely pending) - fee: Transaction fee amount - error: Parsed error message if transaction failed (e.g., "SLIPPAGE_EXCEEDED (0x1771): ...") - txData: Full transaction data including meta.err diff --git a/test/test_extra_params_validation.py b/test/test_extra_params_validation.py new file mode 100644 index 00000000..6d1bdf50 --- /dev/null +++ b/test/test_extra_params_validation.py @@ -0,0 +1,84 @@ +""" +Unit tests for routers.gateway_extras.validate_extra_params — the shared guard +that keeps connector-specific extra_params from being silently dropped or +misparsed by Gateway's unified /trading routes. +""" +import pytest +from fastapi import HTTPException + +from routers.gateway_extras import validate_extra_params + +SPEC = { + "approximateIfNoExactOut": ((bool,), {"jupiter", "dflow", "okx", "titan"}), + "strategyType": ((int,), {"meteora"}), + "configAddress": ((str,), {"meteora"}), + "feeBps": ((int, float), {"meteora", "uniswap"}), +} + + +def _detail(exc_info): + return exc_info.value.detail + + +def test_none_and_empty_pass(): + validate_extra_params(None, SPEC, "jupiter", "route") + validate_extra_params({}, SPEC, "jupiter", "route") + + +def test_valid_params_pass(): + validate_extra_params({"approximateIfNoExactOut": False}, SPEC, "jupiter", "route") + validate_extra_params({"strategyType": 0}, SPEC, "meteora", "route") + validate_extra_params({"feeBps": 0.25}, SPEC, "uniswap", "route") + + +def test_typed_provider_validates_on_base_name(): + validate_extra_params({"approximateIfNoExactOut": True}, SPEC, "jupiter/router", "route") + + +def test_unknown_key_rejected(): + with pytest.raises(HTTPException) as exc: + validate_extra_params({"gasPrice": 20}, SPEC, "uniswap", "the route") + assert exc.value.status_code == 400 + assert "gasPrice" in _detail(exc) + + +def test_wrong_connector_rejected(): + """A key the connector would silently ignore must 400, not pass.""" + with pytest.raises(HTTPException) as exc: + validate_extra_params({"strategyType": 0}, SPEC, "orca", "route") + assert exc.value.status_code == 400 + assert "orca" in _detail(exc) + + +def test_wrong_connector_rejected_for_typed_provider(): + with pytest.raises(HTTPException) as exc: + validate_extra_params({"approximateIfNoExactOut": True}, SPEC, "meteora/clmm", "route") + assert exc.value.status_code == 400 + + +def test_none_value_rejected(): + """A null value would reach Gateway as the string 'None' on GET paths.""" + with pytest.raises(HTTPException) as exc: + validate_extra_params({"approximateIfNoExactOut": None}, SPEC, "jupiter", "route") + assert exc.value.status_code == 400 + assert "bool" in _detail(exc) + + +def test_wrong_type_rejected(): + with pytest.raises(HTTPException) as exc: + validate_extra_params({"approximateIfNoExactOut": "yes"}, SPEC, "jupiter", "route") + assert exc.value.status_code == 400 + + +def test_bool_does_not_pass_as_int(): + """bool subclasses int — True must not satisfy an int-typed key.""" + with pytest.raises(HTTPException) as exc: + validate_extra_params({"strategyType": True}, SPEC, "meteora", "route") + assert exc.value.status_code == 400 + assert "int" in _detail(exc) + + +def test_int_does_not_pass_as_bool(): + with pytest.raises(HTTPException) as exc: + validate_extra_params({"approximateIfNoExactOut": 1}, SPEC, "jupiter", "route") + assert exc.value.status_code == 400 diff --git a/test/test_gateway_client_contract.py b/test/test_gateway_client_contract.py index c2b8e305..de32b138 100644 --- a/test/test_gateway_client_contract.py +++ b/test/test_gateway_client_contract.py @@ -42,6 +42,11 @@ async def fake_request(method, path, params=None, json=None): ("0x", "0x/router"), ("uniswap", "uniswap/router"), ("pancakeswap", "pancakeswap/router"), + # The full Solana router roster Gateway routes as first-class providers — + # a bare name missing here would misroute to /clmm and 404. + ("dflow", "dflow/router"), + ("okx", "okx/router"), + ("titan", "titan/router"), ("meteora", "meteora/clmm"), ("orca", "orca/clmm"), ("raydium", "raydium/clmm"), @@ -269,11 +274,33 @@ async def test_clmm_pool_info_uses_unified_endpoint(client_and_calls): @pytest.mark.asyncio -async def test_clmm_fetch_pools_path(client_and_calls): +async def test_clmm_fetch_pools_meteora_params(client_and_calls): + """Meteora's fetch-pools paginates and filters via page/includeUnverified.""" client, calls = client_and_calls - await client.clmm_fetch_pools(connector="meteora", network="mainnet-beta", limit=10) + await client.clmm_fetch_pools(connector="meteora", network="mainnet-beta", limit=10, + sort_by="volume_24h:desc", page=2, include_unverified=False) call = calls[0] assert (call["method"], call["path"]) == ("GET", "connectors/meteora/clmm/fetch-pools") + assert call["params"]["page"] == 2 + assert call["params"]["includeUnverified"] == "false" + assert call["params"]["sortBy"] == "volume_24h:desc" + for orca_only in ("sortDirection", "verifiedOnly"): + assert orca_only not in call["params"] + + +@pytest.mark.asyncio +async def test_clmm_fetch_pools_orca_params(client_and_calls): + """Orca's fetch-pools takes sortDirection/verifiedOnly and has no pagination — + sending meteora's knobs would be silently stripped by Gateway's AJV.""" + client, calls = client_and_calls + await client.clmm_fetch_pools(connector="orca", network="mainnet-beta", limit=10, + sort_by="volume", sort_direction="desc", verified_only=True) + call = calls[0] + assert (call["method"], call["path"]) == ("GET", "connectors/orca/clmm/fetch-pools") + assert call["params"]["sortDirection"] == "desc" + assert call["params"]["verifiedOnly"] == "true" + for meteora_only in ("page", "includeUnverified"): + assert meteora_only not in call["params"] # ============================================ @@ -362,12 +389,14 @@ async def test_amm_quote_swap_path_and_slippage_omitted(client_and_calls): async def test_amm_execute_swap_path(client_and_calls): client, calls = client_and_calls await client.amm_execute_swap(connector="uniswap", chain_network="ethereum-mainnet", wallet_address=WALLET, - pool_address=POOL, base_token="WETH", side="BUY", amount=1.0, slippage_pct=0.5) + pool_address=POOL, base_token="WETH", side="buy", amount=1.0, slippage_pct=0.5) c = calls[0] assert (c["method"], c["path"]) == ("POST", "trading/amm/execute-swap") assert c["json"]["walletAddress"] == WALLET assert c["json"]["chainNetwork"] == "ethereum-mainnet" assert c["json"]["slippagePct"] == 0.5 + # Gateway's schema enum-rejects lowercase; the client normalizes like the unified swap path + assert c["json"]["side"] == "BUY" @pytest.mark.asyncio From 09c7df7af633a2ec9f8cf7540691be5d67186453 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 18 Aug 2026 20:36:03 -0700 Subject: [PATCH 11/54] fix(gateway): make the pending-open reconciliation real; stop reporting failed txs as submitted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups from the re-audit of cd3cab7: - The position discovery sweep now covers every Solana CLMM connector whose open can return submitted-not-confirmed (meteora, raydium, pancakeswap-sol, plus orca for externally-created positions) — previously meteora-only, so the pending-open path's "the poller records it once it lands" contract was false for raydium/pancakeswap-sol and those positions were permanently orphaned from the DB. - get_transaction_status_from_response (both routers) maps Gateway's negative statuses to FAILED instead of folding them into SUBMITTED: a failed EVM swap (status -1, zeroed amounts) is now recorded and returned as failed, and the swap execute response reports confirmed/submitted/failed honestly instead of a hardcoded "submitted". - The CLMM open pending branch rejects the EVM late-revert shape (data present without a position address, or negative status) with a loud 500 instead of returning 200 "submitted" for a tx that definitively failed on-chain. - gas_fee falsy-zero in swap listings fixed the same way as slippage_pct one line above it (a recorded 0 must not report as null). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- .../repositories/gateway_swap_repository.py | 2 +- routers/gateway_clmm.py | 27 ++++++++++--------- routers/gateway_swap.py | 25 ++++++++--------- services/gateway_transaction_poller.py | 14 +++++++--- 4 files changed, 38 insertions(+), 30 deletions(-) diff --git a/database/repositories/gateway_swap_repository.py b/database/repositories/gateway_swap_repository.py index b0a7d263..51c75ed6 100644 --- a/database/repositories/gateway_swap_repository.py +++ b/database/repositories/gateway_swap_repository.py @@ -159,7 +159,7 @@ def to_dict(self, swap: GatewaySwap) -> Dict: "price": float(swap.price), # `is not None`: a recorded slippage of 0 is a real value, not "unset" "slippage_pct": float(swap.slippage_pct) if swap.slippage_pct is not None else None, - "gas_fee": float(swap.gas_fee) if swap.gas_fee else None, + "gas_fee": float(swap.gas_fee) if swap.gas_fee is not None else None, "gas_token": swap.gas_token, "status": swap.status, "pool_address": swap.pool_address, diff --git a/routers/gateway_clmm.py b/routers/gateway_clmm.py index 7a69a233..0e11a2b4 100644 --- a/routers/gateway_clmm.py +++ b/routers/gateway_clmm.py @@ -57,23 +57,18 @@ def get_transaction_status_from_response(gateway_response: dict) -> str: """ - Determine transaction status from Gateway response. - - Gateway returns status field in the response: - - status: 1 = confirmed - - status: 0 = pending/submitted - - Returns: - "CONFIRMED" if status == 1 - "SUBMITTED" if status == 0 or not present + Determine transaction status from Gateway response: + status 1 -> CONFIRMED, negative (-1 failed, -2 dropped) -> FAILED, + 0 or missing -> SUBMITTED. """ status = gateway_response.get("status") - # Status 1 means transaction is confirmed on-chain if status == 1: return "CONFIRMED" - - # Status 0 or missing means submitted but not confirmed yet + # Gateway's TransactionStatus uses negative values for terminal failures + # (e.g. an EVM revert surfaced through the extended-poll path). + if isinstance(status, (int, float)) and status < 0: + return "FAILED" return "SUBMITTED" @@ -458,6 +453,14 @@ async def open_clmm_position( raise HTTPException( status_code=500, detail="Gateway confirmed the open but returned no position address") + # data present without a position address is the EVM revert shape + # (uniswap/pancakeswap return the receipt with an empty address when the + # tx landed but reverted); a negative status is a terminal failure on any + # chain. Both are definitive failures, not pending submissions. + if tx_status == "FAILED" or result.get("data") is not None: + raise HTTPException( + status_code=500, + detail=f"Open position transaction failed on-chain ({transaction_hash})") # Submitted-not-confirmed: the position address is unknowable until the tx # lands. The tx IS in flight, so failing here would report a false failure # and orphan the position; instead return the signature for the caller to diff --git a/routers/gateway_swap.py b/routers/gateway_swap.py index 0183357b..6df96c4c 100644 --- a/routers/gateway_swap.py +++ b/routers/gateway_swap.py @@ -25,23 +25,18 @@ def get_transaction_status_from_response(gateway_response: dict) -> str: """ - Determine transaction status from Gateway response. - - Gateway returns status field in the response: - - status: 1 = confirmed - - status: 0 = pending/submitted - - Returns: - "CONFIRMED" if status == 1 - "SUBMITTED" if status == 0 or not present + Determine transaction status from Gateway response: + status 1 -> CONFIRMED, negative (-1 failed, -2 dropped) -> FAILED, + 0 or missing -> SUBMITTED. """ status = gateway_response.get("status") - # Status 1 means transaction is confirmed on-chain if status == 1: return "CONFIRMED" - - # Status 0 or missing means submitted but not confirmed yet + # Gateway's TransactionStatus uses negative values for terminal failures + # (e.g. a failed EVM swap returns status -1 with zeroed amounts). + if isinstance(status, (int, float)) and status < 0: + return "FAILED" return "SUBMITTED" @@ -244,9 +239,11 @@ async def execute_swap( return SwapExecuteResponse( transaction_hash=transaction_hash, trading_pair=request.trading_pair, - side=request.side, + side=side, amount=request.amount, - status="submitted" + # "confirmed" / "submitted" / "failed" — a failed EVM swap comes back as + # status -1 with zeroed amounts, which must not read as in-flight. + status=tx_status.lower() ) except HTTPException: diff --git a/services/gateway_transaction_poller.py b/services/gateway_transaction_poller.py index ba6e254f..0bf5902f 100644 --- a/services/gateway_transaction_poller.py +++ b/services/gateway_transaction_poller.py @@ -379,11 +379,19 @@ async def poll_transaction_once(self, tx_hash: str, network_id: str) -> Optional # Position State Polling & Discovery # ============================================ - # Supported CLMM connectors and their default networks + # Supported CLMM connectors and their default networks. The discovery sweep is + # also the reconciliation path for opens that returned submitted-not-confirmed + # (position_address unknown at open time), so every Solana connector whose open + # can pend (meteora, raydium, pancakeswap-sol) must be listed; orca rides along + # to reconcile externally-created positions. All four speak the unified + # /trading/clmm/positions-owned schema. SUPPORTED_CLMM_CONFIGS = [ {"connector": "meteora", "chain": "solana", "network": "mainnet-beta"}, - # Add more connectors as they become supported: - # {"connector": "raydium", "chain": "solana", "network": "mainnet-beta"}, + {"connector": "raydium", "chain": "solana", "network": "mainnet-beta"}, + {"connector": "pancakeswap-sol", "chain": "solana", "network": "mainnet-beta"}, + {"connector": "orca", "chain": "solana", "network": "mainnet-beta"}, + # EVM CLMM opens never return the pending shape (data always present), so + # discovery is not load-bearing there: # {"connector": "uniswap", "chain": "ethereum", "network": "mainnet"}, ] From c3bc48a201cf6098fd37cd1ff8f658233c2ebc40 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 18 Aug 2026 21:22:14 -0700 Subject: [PATCH 12/54] fix(gateway): remediate the complete-audit findings; document accepted residuals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Poller (the load-bearing cluster): - _check_transaction_status classifies on txStatus ALONE: Gateway returns txStatus 0 WITH an error message for transient poll failures ("poll again, don't give up") — the error field no longer promotes a pending tx to FAILED (one RPC hiccup used to permanently fail in-flight swaps and close events). -2 NOT_FOUND is DROPPED (terminal after a 180s blockhash grace) instead of polled for an hour and mislabeled timeout; 0 is an explicit PENDING distinct from None (no information). - The age timeout fires only after a successful poll says pending — never while Gateway is unreachable (an outage used to mass-FAIL everything over an hour old, including confirmed txs). One availability gate per cycle replaces per-call pings. - Position close detection: a single position-info 404/500 no longer closes a position — 3 consecutive misses required (mirrors the lp_executor gate); the router refresh no longer closes on absence from one positions-owned read; discovery skips reopening positions closed within a 300s grace so a lagging listing can't flap CLOSED->OPEN. - Failed txs record their gas fee; fee of exactly 0 survives. Fee bookkeeping (double-count / phantom-fee cluster): - close/collect endpoints mutate the position ONLY when Gateway confirmed inline; submitted txs are booked once by the poller's confirm path (which now books close fees before closing); failed txs mutate nothing. Previously every pending collect double-counted and every failed close permanently inflated *_fee_collected. - ADD_LIQUIDITY raises the PnL baseline (initial amounts) on confirm — pnl_summary no longer counts added capital as profit. Honesty and contract: - add/remove/close/collect responses report confirmed/submitted/failed instead of hardcoded "submitted"; writes on unrecorded positions log loudly instead of silently dropping the event. - Swap summary: quote-denominated volume per quote token (was summing the base leg across mixed pairs while claiming quote); status filter case-insensitive; tz-aware time filters; 10k-row cap logged. - CLMM remove renames percentage -> percentage_to_remove (matches AMM and Gateway; position.percentage still means range width). - close/collect honor an explicit request wallet (same precedence as open/add) and drop the required-but-unused pool_address 400. - get_native_gas_token single-sourced in gateway_client (three drifted copies produced MATIC/None/UNKNOWN for one chain); status mapping single-sourced in gateway_extras. - Wallet placeholder check matches Gateway's real "" template; unreachable Gateway raises 503 instead of "No wallet configured" 400 or "'error' in None" crashes; hardware wallet addresses included in discovery/balance sweeps; deprecated /pools maps Solana routers to solana; position-info 503s on connection error. - Dead code removed per convention: unused request models, legacy _poll_open_positions wrapper, poll_transaction_once, unused repo helpers. DISCOVERED event type documented everywhere event types are enumerated. Accepted residuals documented in place, NOT fixed (by decision): pending-tx amounts/price never backfilled from txData; discovery-time entry price/synthetic history for pending opens; int-only extra_params strictness; legacy lowercase side rows. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- .../generic/lp_rebalancer/lp_rebalancer.py | 24 +- database/models.py | 3 +- .../repositories/gateway_clmm_repository.py | 111 +++--- .../repositories/gateway_swap_repository.py | 38 +- models/__init__.py | 10 - models/gateway_trading.py | 81 +--- routers/gateway.py | 8 +- routers/gateway_clmm.py | 372 +++++++++--------- routers/gateway_extras.py | 29 +- routers/gateway_swap.py | 19 +- services/gateway_client.py | 53 ++- services/gateway_transaction_poller.py | 328 +++++++++------ services/gateway_wallet_service.py | 19 +- test/test_gateway_client_contract.py | 4 +- test/test_gateway_error_masking.py | 34 +- 15 files changed, 622 insertions(+), 511 deletions(-) diff --git a/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py b/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py index 8793f4ce..c8cd6a6b 100644 --- a/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py +++ b/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py @@ -591,10 +591,11 @@ def determine_executor_actions(self) -> List[ExecutorAction]: self._current_executor_id = None # Determine side for new position - if executor_failed and failed_executor_side is not None: - # Retry with same side on failure + if (executor_failed or involuntary_hold) and failed_executor_side is not None: + # Retry with same side after any abnormal terminal (FAILED, or an + # involuntary hold that left no on-chain position to recover) side = failed_executor_side - self.logger().info(f"Retrying with same side={side} after executor failure") + self.logger().info(f"Retrying with same side={side} after abnormal executor end") elif not self._initial_position_created: # Initial position: use configured side side = self.config.side @@ -822,24 +823,25 @@ def _is_price_within_limits(self, price: Decimal, side: TradeType) -> bool: """ Check if price is within configured limits for the position type. """ + # `is not None`: a limit set to exactly 0 is a real bound, not "unset" if side == TradeType.SELL: - if self.config.sell_price_min and price < self.config.sell_price_min: + if self.config.sell_price_min is not None and price < self.config.sell_price_min: return False - if self.config.sell_price_max and price > self.config.sell_price_max: + if self.config.sell_price_max is not None and price > self.config.sell_price_max: return False elif side == TradeType.BUY: - if self.config.buy_price_min and price < self.config.buy_price_min: + if self.config.buy_price_min is not None and price < self.config.buy_price_min: return False - if self.config.buy_price_max and price > self.config.buy_price_max: + if self.config.buy_price_max is not None and price > self.config.buy_price_max: return False else: # RANGE - if self.config.buy_price_min and price < self.config.buy_price_min: + if self.config.buy_price_min is not None and price < self.config.buy_price_min: return False - if self.config.buy_price_max and price > self.config.buy_price_max: + if self.config.buy_price_max is not None and price > self.config.buy_price_max: return False - if self.config.sell_price_min and price < self.config.sell_price_min: + if self.config.sell_price_min is not None and price < self.config.sell_price_min: return False - if self.config.sell_price_max and price > self.config.sell_price_max: + if self.config.sell_price_max is not None and price > self.config.sell_price_max: return False return True diff --git a/database/models.py b/database/models.py index 6b85af06..84698835 100644 --- a/database/models.py +++ b/database/models.py @@ -325,7 +325,8 @@ class GatewayCLMMEvent(Base): # Event type event_type = Column(String, nullable=False, - index=True) # OPEN, ADD_LIQUIDITY, REMOVE_LIQUIDITY, COLLECT_FEES, CLOSE + index=True) # OPEN, ADD_LIQUIDITY, REMOVE_LIQUIDITY, COLLECT_FEES, CLOSE, + # DISCOVERED (written by the poller with a synthetic tx hash) # Event amounts base_token_amount = Column(Numeric(precision=30, scale=18), nullable=True) diff --git a/database/repositories/gateway_clmm_repository.py b/database/repositories/gateway_clmm_repository.py index f292c69f..c5281767 100644 --- a/database/repositories/gateway_clmm_repository.py +++ b/database/repositories/gateway_clmm_repository.py @@ -1,8 +1,8 @@ -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from decimal import Decimal from typing import Dict, List, Optional, Set -from sqlalchemy import distinct, select +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from database.models import GatewayCLMMEvent, GatewayCLMMPosition @@ -93,7 +93,7 @@ async def close_position(self, position_address: str) -> Optional[GatewayCLMMPos position = result.scalar_one_or_none() if position: position.status = "CLOSED" - position.closed_at = datetime.utcnow() + position.closed_at = datetime.now(timezone.utc) await self.session.flush() return position @@ -114,6 +114,44 @@ async def reopen_position(self, position_address: str) -> Optional[GatewayCLMMPo await self.session.flush() return position + async def add_to_initial_amounts( + self, + position_address: str, + base_delta: Decimal, + quote_delta: Decimal + ) -> Optional[GatewayCLMMPosition]: + """Raise the PnL baseline when liquidity is ADDED to an existing position. + + Without this, pnl_summary compares post-add value against the original + deposit only and overstates PnL by exactly the added capital. + """ + result = await self.session.execute( + select(GatewayCLMMPosition).where(GatewayCLMMPosition.position_address == position_address) + ) + position = result.scalar_one_or_none() + if position: + position.initial_base_token_amount = float(position.initial_base_token_amount or 0) + float(base_delta) + position.initial_quote_token_amount = float(position.initial_quote_token_amount or 0) + float(quote_delta) + await self.session.flush() + return position + + async def get_recently_closed_addresses(self, within_seconds: int) -> Set[str]: + """Addresses of positions closed within the last `within_seconds`. + + Used by the discovery sweep's reopen logic: a lagging positions-owned RPC + read can still show a position the user just closed, and reopening it would + flap the record CLOSED -> OPEN -> CLOSED. + """ + cutoff = datetime.now(timezone.utc) - timedelta(seconds=within_seconds) + result = await self.session.execute( + select(GatewayCLMMPosition.position_address).where( + GatewayCLMMPosition.status == "CLOSED", + GatewayCLMMPosition.closed_at.isnot(None), + GatewayCLMMPosition.closed_at >= cutoff, + ) + ) + return set(result.scalars().all()) + async def get_positions( self, network: Optional[str] = None, @@ -162,32 +200,6 @@ async def get_open_positions( limit=1000 ) - async def get_unique_wallet_configs(self) -> List[Dict]: - """ - Get unique combinations of connector/network/wallet from all positions. - - Returns: - List of dicts with keys: connector, network, wallet_address - This is useful for discovering which wallets to poll for positions. - """ - query = select( - distinct(GatewayCLMMPosition.connector), - GatewayCLMMPosition.network, - GatewayCLMMPosition.wallet_address - ).distinct() - - result = await self.session.execute(query) - rows = result.all() - - return [ - { - "connector": row[0], - "network": row[1], - "wallet_address": row[2] - } - for row in rows - ] - async def get_position_addresses_set(self, status: Optional[str] = None) -> Set[str]: """ Get a set of position addresses in the database. @@ -216,19 +228,6 @@ async def create_event(self, event_data: Dict) -> GatewayCLMMEvent: await self.session.flush() return event - async def get_event_by_tx_hash( - self, - transaction_hash: str, - event_type: Optional[str] = None - ) -> Optional[GatewayCLMMEvent]: - """Get an event by transaction hash.""" - query = select(GatewayCLMMEvent).where(GatewayCLMMEvent.transaction_hash == transaction_hash) - if event_type: - query = query.where(GatewayCLMMEvent.event_type == event_type) - - result = await self.session.execute(query) - return result.scalar_one_or_none() - async def update_event_status( self, transaction_hash: str, @@ -294,14 +293,14 @@ def position_to_dict(self, position: GatewayCLMMPosition) -> Dict: pnl_summary = None # Get prices for PnL calculation - entry_price = float(position.entry_price) if position.entry_price else None - current_price = float(position.current_price) if position.current_price else None + entry_price = float(position.entry_price) if position.entry_price is not None else None + current_price = float(position.current_price) if position.current_price is not None else None # Calculate PnL if we have initial amounts and prices - if (position.initial_base_token_amount is not None and - position.initial_quote_token_amount is not None and - entry_price and entry_price > 0 and - current_price and current_price > 0): + if (position.initial_base_token_amount is not None + and position.initial_quote_token_amount is not None + and entry_price and entry_price > 0 + and current_price and current_price > 0): # Initial amounts initial_base = float(position.initial_base_token_amount) @@ -411,8 +410,10 @@ def position_to_dict(self, position: GatewayCLMMPosition) -> Dict: "entry_price": entry_price, "current_price": current_price, "percentage": float(position.percentage) if position.percentage is not None else None, - "initial_base_token_amount": float(position.initial_base_token_amount) if position.initial_base_token_amount is not None else None, - "initial_quote_token_amount": float(position.initial_quote_token_amount) if position.initial_quote_token_amount is not None else None, + "initial_base_token_amount": (float(position.initial_base_token_amount) + if position.initial_base_token_amount is not None else None), + "initial_quote_token_amount": (float(position.initial_quote_token_amount) + if position.initial_quote_token_amount is not None else None), "position_rent": float(position.position_rent) if position.position_rent is not None else None, "base_token_amount": float(position.base_token_amount), "quote_token_amount": float(position.quote_token_amount), @@ -431,11 +432,11 @@ def event_to_dict(self, event: GatewayCLMMEvent) -> Dict: "transaction_hash": event.transaction_hash, "timestamp": event.timestamp.isoformat(), "event_type": event.event_type, - "base_token_amount": float(event.base_token_amount) if event.base_token_amount else None, - "quote_token_amount": float(event.quote_token_amount) if event.quote_token_amount else None, - "base_fee_collected": float(event.base_fee_collected) if event.base_fee_collected else None, - "quote_fee_collected": float(event.quote_fee_collected) if event.quote_fee_collected else None, - "gas_fee": float(event.gas_fee) if event.gas_fee else None, + "base_token_amount": float(event.base_token_amount) if event.base_token_amount is not None else None, + "quote_token_amount": float(event.quote_token_amount) if event.quote_token_amount is not None else None, + "base_fee_collected": float(event.base_fee_collected) if event.base_fee_collected is not None else None, + "quote_fee_collected": float(event.quote_fee_collected) if event.quote_fee_collected is not None else None, + "gas_fee": float(event.gas_fee) if event.gas_fee is not None else None, "gas_token": event.gas_token, "status": event.status, "error_message": event.error_message, diff --git a/database/repositories/gateway_swap_repository.py b/database/repositories/gateway_swap_repository.py index 51c75ed6..4547cc31 100644 --- a/database/repositories/gateway_swap_repository.py +++ b/database/repositories/gateway_swap_repository.py @@ -1,4 +1,5 @@ -from datetime import datetime +import logging +from datetime import datetime, timezone from decimal import Decimal from typing import Dict, List, Optional @@ -7,6 +8,8 @@ from database.models import GatewaySwap +logger = logging.getLogger(__name__) + class GatewaySwapRepository: def __init__(self, session: AsyncSession): @@ -75,12 +78,15 @@ async def get_swaps( if trading_pair: query = query.where(GatewaySwap.trading_pair == trading_pair) if status: - query = query.where(GatewaySwap.status == status) + # DB statuses are uppercase (SUBMITTED/CONFIRMED/FAILED); accept either + # casing so a status copied from a write response matches. + query = query.where(GatewaySwap.status == status.upper()) if start_time: - start_dt = datetime.fromtimestamp(start_time) + # tz-aware: the timestamp column is TIMESTAMP(timezone=True) + start_dt = datetime.fromtimestamp(start_time, tz=timezone.utc) query = query.where(GatewaySwap.timestamp >= start_dt) if end_time: - end_dt = datetime.fromtimestamp(end_time) + end_dt = datetime.fromtimestamp(end_time, tz=timezone.utc) query = query.where(GatewaySwap.timestamp <= end_dt) # Apply ordering and pagination @@ -112,19 +118,29 @@ async def get_swaps_summary( wallet_address=wallet_address, start_time=start_time, end_time=end_time, - limit=10000 # Get all for summary + limit=10000 ) + if len(swaps) == 10000: + logger.warning("Swap summary hit the 10,000-row cap; totals cover only the most recent rows") total_swaps = len(swaps) confirmed_swaps = sum(1 for s in swaps if s.status == "CONFIRMED") failed_swaps = sum(1 for s in swaps if s.status == "FAILED") pending_swaps = sum(1 for s in swaps if s.status == "SUBMITTED") - # Calculate total volume (in quote token) - total_volume = sum( - float(s.output_amount if s.side == "BUY" else s.input_amount) - for s in swaps if s.status == "CONFIRMED" - ) + # Quote-denominated volume per quote token. Amount columns are token-flow + # (BUY: input=quote, output=base; SELL: input=base, output=quote), so the + # quote leg is input for BUY and output for SELL — and volumes are only + # comparable within one quote token, never summed across denominations. + # side.upper() tolerates legacy lowercase rows written before normalization. + volume_by_quote_token: Dict[str, float] = {} + for s in swaps: + if s.status != "CONFIRMED": + continue + quote_amount = s.input_amount if (s.side or "").upper() == "BUY" else s.output_amount + volume_by_quote_token[s.quote_token] = ( + volume_by_quote_token.get(s.quote_token, 0.0) + float(quote_amount) + ) # Calculate total gas fees total_gas_fees = sum( @@ -138,7 +154,7 @@ async def get_swaps_summary( "failed_swaps": failed_swaps, "pending_swaps": pending_swaps, "success_rate": confirmed_swaps / total_swaps if total_swaps > 0 else 0, - "total_volume": total_volume, + "volume_by_quote_token": volume_by_quote_token, "total_gas_fees": total_gas_fees, } diff --git a/models/__init__.py b/models/__init__.py index 5991d18b..b0742cf6 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -111,11 +111,9 @@ CLMMCollectFeesRequest, CLMMCollectFeesResponse, CLMMCreatePoolRequest, - CLMMGetPositionInfoRequest, CLMMOpenPositionRequest, CLMMOpenPositionResponse, CLMMPoolBin, - CLMMPoolInfoRequest, CLMMPoolInfoResponse, CLMMPoolListItem, CLMMPoolListResponse, @@ -124,13 +122,10 @@ CLMMQuotePositionRequest, CLMMQuotePositionResponse, CLMMRemoveLiquidityRequest, - GetPoolInfoRequest, - PoolInfo, SwapExecuteRequest, SwapExecuteResponse, SwapQuoteRequest, SwapQuoteResponse, - TimeBasedMetrics, ) # Market data models @@ -352,13 +347,8 @@ "CLMMQuotePositionRequest", "CLMMQuotePositionResponse", "CLMMPositionInfo", - "CLMMGetPositionInfoRequest", - "CLMMPoolInfoRequest", "CLMMPoolBin", "CLMMPoolInfoResponse", - "GetPoolInfoRequest", - "PoolInfo", - "TimeBasedMetrics", "CLMMPoolListItem", "CLMMPoolListResponse", # Portfolio models diff --git a/models/gateway_trading.py b/models/gateway_trading.py index ab944766..c1ddc1a2 100644 --- a/models/gateway_trading.py +++ b/models/gateway_trading.py @@ -155,7 +155,9 @@ class CLMMRemoveLiquidityRequest(BaseModel): connector: str = Field(description="CLMM connector (e.g., 'meteora', 'raydium', 'uniswap')") network: str = Field(description="Network ID in 'chain-network' format (e.g., 'solana-mainnet-beta')") position_address: str = Field(description="Position address to remove liquidity from") - percentage: Decimal = Field(description="Percentage of liquidity to remove (0-100)") + # Same name as the AMM remove model and Gateway's percentageToRemove — and distinct + # from the position row's `percentage`, which means price-range width. + percentage_to_remove: Decimal = Field(description="Percentage of liquidity to remove (0-100)") slippage_pct: Optional[Decimal] = Field( default=None, description="Maximum slippage percentage. Only honored by the Orca connector; " @@ -170,8 +172,8 @@ class CLMMClosePositionRequest(BaseModel): position_address: str = Field(description="Position address to close") pool_address: Optional[str] = Field( default=None, - description="Pool the position belongs to. Only needed for positions this API never recorded " - "(e.g. opened by an lp_executor straight against Gateway); otherwise read from the database" + description="Pool the position belongs to. Informational only — neither Gateway's call " + "nor the fee snapshot needs it, and unrecorded positions work without it" ) wallet_address: Optional[str] = Field(default=None, description="Wallet address (optional, uses default if not provided)") @@ -183,8 +185,8 @@ class CLMMCollectFeesRequest(BaseModel): position_address: str = Field(description="Position address to collect fees from") pool_address: Optional[str] = Field( default=None, - description="Pool the position belongs to. Only needed for positions this API never recorded " - "(e.g. opened by an lp_executor straight against Gateway); otherwise read from the database" + description="Pool the position belongs to. Informational only — neither Gateway's call " + "nor the fee snapshot needs it, and unrecorded positions work without it" ) wallet_address: Optional[str] = Field(default=None, description="Wallet address (optional, uses default if not provided)") @@ -274,12 +276,17 @@ class CLMMPositionsOwnedRequest(BaseModel): class CLMMPositionInfo(BaseModel): - """Information about a CLMM liquidity position""" + """Information about a CLMM liquidity position. + + Note: in_range here is a bool (live Gateway read); the DB-backed + /clmm/positions/search endpoint reports in_range as the string enum + IN_RANGE / OUT_OF_RANGE / UNKNOWN (three states, so not collapsible to bool). + """ position_address: str = Field(description="Position address") pool_address: str = Field(description="Pool address") - trading_pair: str = Field(description="Trading pair") - base_token: str = Field(description="Base token symbol") - quote_token: str = Field(description="Quote token symbol") + trading_pair: str = Field(description="Trading pair (address-derived identifiers, not symbols)") + base_token: str = Field(description="Base token identifier (derived from the token address; not a symbol)") + quote_token: str = Field(description="Quote token identifier (derived from the token address; not a symbol)") base_token_amount: Decimal = Field(description="Base token amount in position") quote_token_amount: Decimal = Field(description="Quote token amount in position") current_price: Decimal = Field(description="Current pool price") @@ -292,25 +299,6 @@ class CLMMPositionInfo(BaseModel): in_range: bool = Field(description="Whether position is currently in range") -class CLMMGetPositionInfoRequest(BaseModel): - """Request to get detailed info about a specific CLMM position""" - connector: str = Field(description="CLMM connector (e.g., 'meteora', 'raydium', 'uniswap')") - network: str = Field(description="Network ID in 'chain-network' format (e.g., 'solana-mainnet-beta')") - position_address: str = Field(description="Position address to query") - - -class CLMMPoolInfoRequest(BaseModel): - """Request to get CLMM pool information by pool address""" - connector: str = Field(description="CLMM connector (e.g., 'meteora', 'raydium')") - network: str = Field(description="Network ID in 'chain-network' format (e.g., 'solana-mainnet-beta')") - pool_address: str = Field(description="Pool contract address") - bin_count: int = Field( - default=0, - description="If > 0, include the per-tick liquidity distribution (bins) around the active " - "price — Gateway's binCount. Meteora always returns its bins and ignores this; orca, " - "raydium, uniswap and pancakeswap compute them on request.") - - class CLMMPoolBin(BaseModel): """Individual bin in a CLMM pool (e.g., Meteora)""" bin_id: int = Field(alias="binId", description="Bin identifier") @@ -549,47 +537,10 @@ class AMMPositionsOwnedRequest(BaseModel): wallet_address: Optional[str] = Field(default=None, description="Wallet address (optional, uses default)") -# ============================================ -# Pool Information Models -# ============================================ - -class GetPoolInfoRequest(BaseModel): - """Request to get pool information""" - connector: str = Field(description="DEX connector (e.g., 'meteora', 'raydium', 'jupiter')") - network: str = Field(description="Network ID in 'chain-network' format (e.g., 'solana-mainnet-beta')") - trading_pair: str = Field(description="Trading pair (e.g., 'SOL-USDC')") - - -class PoolInfo(BaseModel): - """Information about a liquidity pool""" - type: str = Field(description="Pool type: 'clmm' or 'router'") - address: str = Field(description="Pool address") - trading_pair: str = Field(description="Trading pair") - base_token: str = Field(description="Base token symbol") - quote_token: str = Field(description="Quote token symbol") - current_price: Decimal = Field(description="Current pool price") - base_token_amount: Decimal = Field(description="Base token liquidity in pool") - quote_token_amount: Decimal = Field(description="Quote token liquidity in pool") - fee_pct: Decimal = Field(description="Pool fee percentage") - - # CLMM-specific - bin_step: Optional[int] = Field(default=None, description="Bin step (CLMM)") - active_bin_id: Optional[int] = Field(default=None, description="Active bin ID (CLMM)") - - # ============================================ # CLMM Pool Listing Models # ============================================ -class TimeBasedMetrics(BaseModel): - """Time-based metrics (volume, fees, fee-to-TVL ratio) for different time periods""" - min_30: Optional[Decimal] = Field(default=None, description="30 minute metric") - hour_1: Optional[Decimal] = Field(default=None, description="1 hour metric") - hour_2: Optional[Decimal] = Field(default=None, description="2 hour metric") - hour_4: Optional[Decimal] = Field(default=None, description="4 hour metric") - hour_12: Optional[Decimal] = Field(default=None, description="12 hour metric") - hour_24: Optional[Decimal] = Field(default=None, description="24 hour metric") - class CLMMPoolListItem(BaseModel): """Individual pool item in CLMM pool listing - matches Gateway fetch-pools response""" diff --git a/routers/gateway.py b/routers/gateway.py index ec5f4419..e864efc8 100644 --- a/routers/gateway.py +++ b/routers/gateway.py @@ -369,9 +369,11 @@ async def list_pools_legacy( if not await accounts_service.gateway_client.ping(): raise HTTPException(status_code=503, detail="Gateway service is not available") - # Determine chain from connector (legacy behavior) - # This is a simple mapping - in production, you'd want to look this up - chain = "solana" if connector_name in ["raydium", "meteora", "orca", "pancakeswap-sol"] else "ethereum" + # Determine chain from connector (legacy behavior). Solana routers + # (jupiter/dflow/okx/titan) must map to solana too, or this returns the + # (empty) ethereum pool list for them. + solana_connectors = {"raydium", "meteora", "orca", "pancakeswap-sol", "jupiter", "dflow", "okx", "titan"} + chain = "solana" if connector_name in solana_connectors else "ethereum" pools = check_gateway_error( await accounts_service.gateway_client.get_pools(chain, network, connector=connector_name) diff --git a/routers/gateway_clmm.py b/routers/gateway_clmm.py index 0e11a2b4..cb5a56df 100644 --- a/routers/gateway_clmm.py +++ b/routers/gateway_clmm.py @@ -31,9 +31,9 @@ CLMMQuotePositionResponse, CLMMRemoveLiquidityRequest, ) -from routers.gateway_extras import ExtraParamsSpec, validate_extra_params +from routers.gateway_extras import ExtraParamsSpec, get_transaction_status_from_response, validate_extra_params from services.accounts_service import AccountsService -from services.gateway_client import GatewayError, check_gateway_error +from services.gateway_client import GatewayError, check_gateway_error, get_native_gas_token logger = logging.getLogger(__name__) @@ -55,47 +55,6 @@ } -def get_transaction_status_from_response(gateway_response: dict) -> str: - """ - Determine transaction status from Gateway response: - status 1 -> CONFIRMED, negative (-1 failed, -2 dropped) -> FAILED, - 0 or missing -> SUBMITTED. - """ - status = gateway_response.get("status") - - if status == 1: - return "CONFIRMED" - # Gateway's TransactionStatus uses negative values for terminal failures - # (e.g. an EVM revert surfaced through the extended-poll path). - if isinstance(status, (int, float)) and status < 0: - return "FAILED" - return "SUBMITTED" - - -def get_native_gas_token(chain: str) -> str: - """ - Get the native gas token symbol for a blockchain. - - Args: - chain: Blockchain name (e.g., 'solana', 'ethereum', 'polygon') - - Returns: - Gas token symbol (e.g., 'SOL', 'ETH', 'MATIC') - """ - gas_token_map = { - "solana": "SOL", - "ethereum": "ETH", - "polygon": "MATIC", - "avalanche": "AVAX", - "optimism": "ETH", - "arbitrum": "ETH", - "base": "ETH", - "bsc": "BNB", - "cronos": "CRO", - } - return gas_token_map.get(chain.lower(), "UNKNOWN") - - async def _refresh_position_data(position, accounts_service: AccountsService, clmm_repo: GatewayCLMMRepository): """ Refresh position data from Gateway and update database. @@ -128,10 +87,13 @@ async def _refresh_position_data(position, accounts_service: AccountsService, cl result = pos break - # If position not found, it was closed externally + # Absent from a single positions-owned read: could be closed externally, + # could be a lagging RPC node. Closing is owned by the poller's + # consecutive-miss gate (and the zero-liquidity check below) so one + # refresh can never close a live position. if result is None: - logger.info(f"Position {position.position_address} not found on Gateway, marking as CLOSED") - await clmm_repo.close_position(position.position_address) + logger.info(f"Position {position.position_address} absent from positions-owned; " + "skipping update (poller's miss-gate owns close detection)") return except Exception as e: @@ -171,16 +133,16 @@ async def _refresh_position_data(position, accounts_service: AccountsService, cl current_price=current_price ) - # Update pending fees if available + # Always write pending fees — 0 is a real value (e.g. right after an + # external collect); the old non-zero guard left stale pendings forever. base_fee_pending = Decimal(str(result.get("baseFeeAmount", 0))) quote_fee_pending = Decimal(str(result.get("quoteFeeAmount", 0))) - if base_fee_pending or quote_fee_pending: - await clmm_repo.update_position_fees( - position_address=position.position_address, - base_fee_pending=base_fee_pending, - quote_fee_pending=quote_fee_pending - ) + await clmm_repo.update_position_fees( + position_address=position.position_address, + base_fee_pending=base_fee_pending, + quote_fee_pending=quote_fee_pending + ) logger.debug(f"Refreshed position {position.position_address}: price={current_price}, in_range={in_range}, " f"base={base_token_amount}, quote={quote_token_amount}") @@ -271,7 +233,7 @@ async def get_clmm_pools( include_unknown: Include pools with unverified tokens Example: - GET /gateway/clmm/pools?connector=meteora&query=SOL&limit=20 + GET /gateway/clmm/pools?connector=meteora&search_term=SOL&limit=20 Returns: List of available pools with trading pairs, addresses, liquidity, volume, APR, etc. @@ -544,8 +506,8 @@ async def open_clmm_position( "position_id": position.id, "transaction_hash": transaction_hash, "event_type": "OPEN", - "base_token_amount": float(base_amount_added) if base_amount_added else None, - "quote_token_amount": float(quote_amount_added) if quote_amount_added else None, + "base_token_amount": float(base_amount_added) if base_amount_added is not None else None, + "quote_token_amount": float(quote_amount_added) if quote_amount_added is not None else None, "gas_fee": float(gas_fee) if gas_fee else None, "gas_token": gas_token, "status": tx_status @@ -565,8 +527,8 @@ async def open_clmm_position( pool_address=request.pool_address, lower_price=request.lower_price, upper_price=request.upper_price, - base_token_amount_added=Decimal(str(base_amount_added)) if base_amount_added else None, - quote_token_amount_added=Decimal(str(quote_amount_added)) if quote_amount_added else None, + base_token_amount_added=Decimal(str(base_amount_added)) if base_amount_added is not None else None, + quote_token_amount_added=Decimal(str(quote_amount_added)) if quote_amount_added is not None else None, position_rent=Decimal(str(position_rent)) if position_rent else None, status="confirmed" ) @@ -642,7 +604,7 @@ async def add_liquidity_to_clmm_position( # Extract gas fee from Gateway response data = result.get("data", {}) gas_fee = data.get("fee") - gas_token = "SOL" if chain == "solana" else "ETH" if chain == "ethereum" else None + gas_token = get_native_gas_token(chain) # Prefer the CONFIRMED on-chain amounts (data is only present when Gateway # confirmed the tx); the requested amounts are the submitted-not-confirmed @@ -666,15 +628,32 @@ async def add_liquidity_to_clmm_position( "position_id": position.id, "transaction_hash": transaction_hash, "event_type": "ADD_LIQUIDITY", - "base_token_amount": float(base_amount_added) if base_amount_added else None, - "quote_token_amount": float(quote_amount_added) if quote_amount_added else None, - "gas_fee": float(gas_fee) if gas_fee else None, + # `is not None`: 0 is a real amount on single-sided adds + "base_token_amount": float(base_amount_added) if base_amount_added is not None else None, + "quote_token_amount": float(quote_amount_added) if quote_amount_added is not None else None, + "gas_fee": float(gas_fee) if gas_fee is not None else None, "gas_token": gas_token, "status": tx_status } await clmm_repo.create_event(event_data) logger.info(f"Recorded CLMM ADD_LIQUIDITY event: {transaction_hash} " f"(status: {tx_status}, gas: {gas_fee} {gas_token})") + + # Added capital raises the PnL baseline. Book here only when the + # tx confirmed inline (the event is created CONFIRMED and the + # poller never re-processes it); SUBMITTED events are booked by + # the poller's confirm path. + if tx_status == "CONFIRMED": + await clmm_repo.add_to_initial_amounts( + position_address=request.position_address, + base_delta=Decimal(str(base_amount_added or 0)), + quote_delta=Decimal(str(quote_amount_added or 0)), + ) + else: + logger.warning(f"ADD_LIQUIDITY {transaction_hash} executed for position " + f"{request.position_address} with no database record — " + "no event recorded (position may be a pending open " + "not yet discovered)") except Exception as db_error: logger.error(f"Error recording ADD_LIQUIDITY event: {db_error}", exc_info=True) @@ -684,7 +663,7 @@ async def add_liquidity_to_clmm_position( "base_token_amount_added": base_amount_added, "quote_token_amount_added": quote_amount_added, "gas_fee": gas_fee, - "status": "submitted" + "status": tx_status.lower() } except HTTPException: @@ -711,7 +690,7 @@ async def remove_liquidity_from_clmm_position( connector: 'meteora' network: 'solana-mainnet-beta' position_address: '...' - percentage: 50 + percentage_to_remove: 50 slippage_pct: 1 (optional; Orca only — other connectors ignore it) wallet_address: (optional) @@ -737,7 +716,7 @@ async def remove_liquidity_from_clmm_position( chain_network=request.network, wallet_address=wallet_address, position_address=request.position_address, - percentage=float(request.percentage), + percentage_to_remove=float(request.percentage_to_remove), slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else None )) @@ -751,7 +730,7 @@ async def remove_liquidity_from_clmm_position( # Extract gas fee from Gateway response data = result.get("data", {}) gas_fee = data.get("fee") - gas_token = "SOL" if chain == "solana" else "ETH" if chain == "ethereum" else None + gas_token = get_native_gas_token(chain) # The CONFIRMED on-chain amounts (data is only present when Gateway confirmed # the tx). A percentage alone says nothing about what actually left the pool. @@ -775,24 +754,29 @@ async def remove_liquidity_from_clmm_position( "event_type": "REMOVE_LIQUIDITY", "base_token_amount": float(base_amount_removed) if base_amount_removed is not None else None, "quote_token_amount": float(quote_amount_removed) if quote_amount_removed is not None else None, - "gas_fee": float(gas_fee) if gas_fee else None, + "gas_fee": float(gas_fee) if gas_fee is not None else None, "gas_token": gas_token, "status": tx_status } await clmm_repo.create_event(event_data) logger.info(f"Recorded CLMM REMOVE_LIQUIDITY event: {transaction_hash} " f"(status: {tx_status}, gas: {gas_fee} {gas_token})") + else: + logger.warning(f"REMOVE_LIQUIDITY {transaction_hash} executed for position " + f"{request.position_address} with no database record — " + "no event recorded (position may be a pending open " + "not yet discovered)") except Exception as db_error: logger.error(f"Error recording REMOVE_LIQUIDITY event: {db_error}", exc_info=True) return { "transaction_hash": transaction_hash, "position_address": request.position_address, - "percentage": float(request.percentage), + "percentage_to_remove": float(request.percentage_to_remove), "base_token_amount_removed": base_amount_removed, "quote_token_amount_removed": quote_amount_removed, "gas_fee": gas_fee, - "status": "submitted" + "status": tx_status.lower() } except HTTPException: @@ -831,38 +815,23 @@ async def close_clmm_position( # Parse network_id chain, _ = accounts_service.gateway_client.parse_network_id(request.network) - # Get pool_address and wallet_address from database - pool_address = None - wallet_address = None - + # Wallet resolution: an explicit request value wins (same precedence as + # open/add/remove), then the DB row's wallet, then the default wallet. + db_wallet = None async with db_manager.get_session_context() as session: clmm_repo = GatewayCLMMRepository(session) db_position = await clmm_repo.get_position_by_address(request.position_address) if db_position: - pool_address = db_position.pool_address - wallet_address = db_position.wallet_address - - # If not in database, use default wallet - if not wallet_address: - wallet_address = await accounts_service.gateway_client.get_wallet_address_or_default( - chain=chain, - wallet_address=request.wallet_address - ) - - # Positions this API never recorded (an lp_executor opens straight against Gateway) have no - # row to read the pool from, so accept it on the request. Gateway's close needs only - # position_address - pool_address is for the pre-close fee snapshot below. - pool_address = pool_address or request.pool_address + db_wallet = db_position.wallet_address - if not pool_address: - raise HTTPException( - status_code=400, - detail=( - f"Position {request.position_address} is not in the database, so its pool is unknown. " - "Pass pool_address explicitly - LP-executor positions are never recorded here, and " - "/executors/positions/orphaned reports the pool for each orphan." - ) - ) + wallet_address = request.wallet_address or db_wallet + wallet_address = await accounts_service.gateway_client.get_wallet_address_or_default( + chain=chain, + wallet_address=wallet_address + ) + # Note: neither Gateway's close nor the pre-close snapshot (positions_owned) + # needs the pool — unrecorded positions (e.g. lp_executor opens) close fine + # without one, so pool_address is informational only. # Fetch pending fees and current price BEFORE closing (Gateway doesn't always return these in response) base_fee_to_collect = Decimal("0") @@ -956,70 +925,86 @@ async def close_clmm_position( logger.info(f"Recorded CLMM CLOSE event: {transaction_hash} " f"(status: {tx_status}, gas: {gas_fee} {gas_token})") - # Update position: add to collected, reset pending to 0, mark as CLOSED - new_base_collected = Decimal(str(position.base_fee_collected)) + base_fee_collected - new_quote_collected = Decimal(str(position.quote_fee_collected)) + quote_fee_collected - - await clmm_repo.update_position_fees( - position_address=request.position_address, - base_fee_collected=new_base_collected, - quote_fee_collected=new_quote_collected, - base_fee_pending=Decimal("0"), - quote_fee_pending=Decimal("0") - ) - - # Update current_price with close price - if close_price: - await clmm_repo.update_position_liquidity( + # Position bookkeeping happens exactly once, when the tx is known + # good: CONFIRMED here (the event is created CONFIRMED, so the + # poller never touches it), or in the poller's confirm path for + # SUBMITTED events. A FAILED tx mutates nothing — the old + # unconditional booking permanently inflated *_fee_collected on + # failed closes. + if tx_status == "CONFIRMED": + new_base_collected = Decimal(str(position.base_fee_collected)) + base_fee_collected + new_quote_collected = Decimal(str(position.quote_fee_collected)) + quote_fee_collected + + await clmm_repo.update_position_fees( position_address=request.position_address, - base_token_amount=Decimal(str(position.base_token_amount)), - quote_token_amount=Decimal(str(position.quote_token_amount)), - current_price=Decimal(str(close_price)) - ) - - # Verify position is actually closed by checking if it still exists on Gateway - # Gateway returns 500 (or 404) when position doesn't exist - try: - await asyncio.sleep(2) # Wait for transaction to propagate - - verify_result = await accounts_service.gateway_client.clmm_position_info( - connector=request.connector, - chain_network=request.network, - position_address=request.position_address + base_fee_collected=new_base_collected, + quote_fee_collected=new_quote_collected, + base_fee_pending=Decimal("0"), + quote_fee_pending=Decimal("0") ) - # If we get an error response (404 or 500), position is closed - if verify_result and isinstance(verify_result, dict) and "error" in verify_result: - status_code = verify_result.get("status") - if status_code in (404, 500): - await clmm_repo.close_position(request.position_address) - logger.info(f"Position {request.position_address} verified as closed " - f"(Gateway returned {status_code})") + # Update current_price with close price + if close_price: + await clmm_repo.update_position_liquidity( + position_address=request.position_address, + base_token_amount=Decimal(str(position.base_token_amount)), + quote_token_amount=Decimal(str(position.quote_token_amount)), + current_price=Decimal(str(close_price)) + ) + + # Verify position is actually gone on Gateway before marking + # CLOSED (some connectors 500 instead of 404 for a + # nonexistent position — right after our own close, either + # means gone). + try: + await asyncio.sleep(2) # Wait for transaction to propagate + + verify_result = await accounts_service.gateway_client.clmm_position_info( + connector=request.connector, + chain_network=request.network, + position_address=request.position_address + ) + + if verify_result and isinstance(verify_result, dict) and "error" in verify_result: + status_code = verify_result.get("status") + if status_code in (404, 500): + await clmm_repo.close_position(request.position_address) + logger.info(f"Position {request.position_address} verified as closed " + f"(Gateway returned {status_code})") + else: + logger.warning(f"Unexpected error verifying position close: {verify_result}") + elif verify_result and "address" in verify_result: + # Position still exists - might be a failed close or delayed propagation + logger.warning(f"Position {request.position_address} still exists after close " + "transaction. Will be handled by poller.") else: - logger.warning(f"Unexpected error verifying position close: {verify_result}") - elif verify_result and "address" in verify_result: - # Position still exists - might be a failed close or delayed propagation - logger.warning(f"Position {request.position_address} still exists after close " - "transaction. Will be handled by poller.") - else: - logger.debug("Could not verify position close status, will be handled by poller") - - except Exception as verify_error: - logger.warning(f"Error verifying position close: {verify_error}. Will be handled by poller.") - - logger.info(f"Updated position {request.position_address}: collected fees updated, pending fees reset to 0.") + logger.debug("Could not verify position close status, will be handled by poller") + + except Exception as verify_error: + logger.warning(f"Error verifying position close: {verify_error}. Will be handled by poller.") + + logger.info(f"Updated position {request.position_address}: " + "collected fees updated, pending fees reset to 0.") + else: + # H8 window: a close on a position hapi has no row for (e.g. a + # pending open awaiting the discovery sweep) leaves no event — + # say so loudly instead of silently skipping. + logger.warning(f"CLOSE {transaction_hash} executed for position " + f"{request.position_address} with no database record — " + "no CLOSE event recorded (position may be a pending open " + "not yet discovered)") except Exception as db_error: logger.error(f"Error recording CLOSE event: {db_error}", exc_info=True) return CLMMClosePositionResponse( transaction_hash=transaction_hash, position_address=request.position_address, - base_fee_collected=Decimal(str(base_fee_collected)) if base_fee_collected else None, - quote_fee_collected=Decimal(str(quote_fee_collected)) if quote_fee_collected else None, + base_fee_collected=Decimal(str(base_fee_collected)) if base_fee_collected is not None else None, + quote_fee_collected=Decimal(str(quote_fee_collected)) if quote_fee_collected is not None else None, base_token_amount_removed=Decimal(str(base_amount_removed)) if base_amount_removed is not None else None, quote_token_amount_removed=Decimal(str(quote_amount_removed)) if quote_amount_removed is not None else None, position_rent_refunded=Decimal(str(position_rent_refunded)) if position_rent_refunded is not None else None, - status="submitted" + status=tx_status.lower() ) except HTTPException: @@ -1058,37 +1043,23 @@ async def collect_fees_from_clmm_position( # Parse network_id chain, _ = accounts_service.gateway_client.parse_network_id(request.network) - # Get pool_address and wallet_address from database - pool_address = None - wallet_address = None - + # Wallet resolution: an explicit request value wins (same precedence as + # open/add/remove), then the DB row's wallet, then the default wallet. + db_wallet = None async with db_manager.get_session_context() as session: clmm_repo = GatewayCLMMRepository(session) db_position = await clmm_repo.get_position_by_address(request.position_address) if db_position: - pool_address = db_position.pool_address - wallet_address = db_position.wallet_address - - # If not in database, use default wallet - if not wallet_address: - wallet_address = await accounts_service.gateway_client.get_wallet_address_or_default( - chain=chain, - wallet_address=request.wallet_address - ) + db_wallet = db_position.wallet_address - # Positions this API never recorded (an lp_executor opens straight against Gateway) have no - # row to read the pool from, so accept it on the request. - pool_address = pool_address or request.pool_address - - if not pool_address: - raise HTTPException( - status_code=400, - detail=( - f"Position {request.position_address} is not in the database, so its pool is unknown. " - "Pass pool_address explicitly - LP-executor positions are never recorded here, and " - "/executors/positions/orphaned reports the pool for each orphan." - ) - ) + wallet_address = request.wallet_address or db_wallet + wallet_address = await accounts_service.gateway_client.get_wallet_address_or_default( + chain=chain, + wallet_address=wallet_address + ) + # Note: neither Gateway's collect nor the fee snapshot (positions_owned) needs + # the pool — unrecorded positions work without one; pool_address is + # informational only. # Fetch pending fees BEFORE collecting (Gateway doesn't always return collected amounts in response) base_fee_to_collect = Decimal("0") @@ -1168,27 +1139,37 @@ async def collect_fees_from_clmm_position( logger.info(f"Recorded CLMM COLLECT_FEES event: {transaction_hash} " f"(status: {tx_status}, gas: {gas_fee} {gas_token})") - # Update position: add to collected, reset pending to 0 - new_base_collected = Decimal(str(position.base_fee_collected)) + base_fee_collected - new_quote_collected = Decimal(str(position.quote_fee_collected)) + quote_fee_collected - - await clmm_repo.update_position_fees( - position_address=request.position_address, - base_fee_collected=new_base_collected, - quote_fee_collected=new_quote_collected, - base_fee_pending=Decimal("0"), - quote_fee_pending=Decimal("0") - ) - logger.info(f"Updated position {request.position_address}: collected fees updated, pending fees reset to 0") + # Book fees exactly once: CONFIRMED here (event created CONFIRMED, + # never re-processed), SUBMITTED in the poller's confirm path. + # The old unconditional booking double-counted every pending + # collect (endpoint + poller) and kept phantom fees on failures. + if tx_status == "CONFIRMED": + new_base_collected = Decimal(str(position.base_fee_collected)) + base_fee_collected + new_quote_collected = Decimal(str(position.quote_fee_collected)) + quote_fee_collected + + await clmm_repo.update_position_fees( + position_address=request.position_address, + base_fee_collected=new_base_collected, + quote_fee_collected=new_quote_collected, + base_fee_pending=Decimal("0"), + quote_fee_pending=Decimal("0") + ) + logger.info(f"Updated position {request.position_address}: " + "collected fees updated, pending fees reset to 0") + else: + logger.warning(f"COLLECT_FEES {transaction_hash} executed for position " + f"{request.position_address} with no database record — " + "no event recorded (position may be a pending open " + "not yet discovered)") except Exception as db_error: logger.error(f"Error recording COLLECT_FEES event: {db_error}", exc_info=True) return CLMMCollectFeesResponse( transaction_hash=transaction_hash, position_address=request.position_address, - base_fee_collected=Decimal(str(base_fee_collected)) if base_fee_collected else None, - quote_fee_collected=Decimal(str(quote_fee_collected)) if quote_fee_collected else None, - status="submitted" + base_fee_collected=Decimal(str(base_fee_collected)) if base_fee_collected is not None else None, + quote_fee_collected=Decimal(str(quote_fee_collected)) if quote_fee_collected is not None else None, + status=tx_status.lower() ) except HTTPException: @@ -1404,7 +1385,10 @@ async def get_clmm_position_info( chain_network=network, position_address=position_address ) - if isinstance(pos, dict) and "error" in pos: + if pos is None or not isinstance(pos, dict): + # Connection error: the client returns None — a 503, not a crash. + raise HTTPException(status_code=503, detail="Gateway service is not available") + if "error" in pos: status_code = pos.get("status") if status_code in (404, 500): raise HTTPException(status_code=404, detail=f"Position {position_address} not found or closed") @@ -1431,8 +1415,8 @@ async def get_clmm_position_info( current_price=current_price, lower_price=lower_price, upper_price=upper_price, - base_fee_amount=Decimal(str(pos.get("baseFeeAmount", 0))) if pos.get("baseFeeAmount") else None, - quote_fee_amount=Decimal(str(pos.get("quoteFeeAmount", 0))) if pos.get("quoteFeeAmount") else None, + base_fee_amount=Decimal(str(pos["baseFeeAmount"])) if pos.get("baseFeeAmount") is not None else None, + quote_fee_amount=Decimal(str(pos["quoteFeeAmount"])) if pos.get("quoteFeeAmount") is not None else None, lower_bin_id=pos.get("lowerBinId"), upper_bin_id=pos.get("upperBinId"), in_range=in_range @@ -1459,7 +1443,9 @@ async def get_clmm_position_events( Args: position_address: Position NFT address - event_type: Filter by event type (OPEN, ADD_LIQUIDITY, REMOVE_LIQUIDITY, COLLECT_FEES, CLOSE) + event_type: Filter by event type (OPEN, ADD_LIQUIDITY, REMOVE_LIQUIDITY, COLLECT_FEES, CLOSE, + DISCOVERED — written by the poller for positions it found on-chain, with a + synthetic discovered_ transaction hash) limit: Max events to return Returns: @@ -1505,7 +1491,7 @@ async def search_clmm_positions( network: Filter by network (e.g., 'solana-mainnet-beta') connector: Filter by connector (e.g., 'meteora') wallet_address: Filter by wallet address - trading_pair: Filter by trading pair (e.g., 'SOL-USDC') + trading_pair: Filter by trading pair (address-derived identifiers as stored; symbol pairs like 'SOL-USDC' will not match) status: Filter by status (OPEN, CLOSED) position_addresses: Filter by specific position addresses (list of addresses) limit: Max results (default 50, max 1000) diff --git a/routers/gateway_extras.py b/routers/gateway_extras.py index 94148d42..f8ac83de 100644 --- a/routers/gateway_extras.py +++ b/routers/gateway_extras.py @@ -1,6 +1,6 @@ """ -Shared validation for connector-specific extra_params forwarded to Gateway's -unified /trading routes. +Shared helpers for the gateway trading routers: extra_params validation and +Gateway write-response status mapping. Gateway destructures a fixed set of connector-specific keys from each request body and silently ignores everything else, so hapi rejects loudly instead of @@ -11,7 +11,32 @@ from fastapi import HTTPException + +def get_transaction_status_from_response(gateway_response: dict) -> str: + """Map a Gateway write response's status to hapi's transaction vocabulary: + status 1 -> CONFIRMED, negative (-1 failed, -2 dropped) -> FAILED, + 0 or missing -> SUBMITTED. + + Single source: both the swap and CLMM routers import this — two drifting + copies of load-bearing status mapping is how a failed tx gets recorded as + submitted on one surface but not the other. + """ + status = gateway_response.get("status") + + if status == 1: + return "CONFIRMED" + # Gateway's TransactionStatus uses negative values for terminal failures + # (e.g. a failed EVM swap returns status -1 with zeroed amounts). + if isinstance(status, (int, float)) and status < 0: + return "FAILED" + return "SUBMITTED" + + # Spec entry: key -> (allowed value types, connectors that honor the key). +# Deliberate strictness (accepted residual): numeric keys are typed int even though +# Gateway's TypeBox says Number (JS has one number type) — the domains are integral +# (enum values, bin steps, config indexes), so a float like 1.0 gets a loud 400 +# here rather than reaching Gateway; no semantically distinct value is blocked. ExtraParamsSpec = Dict[str, Tuple[Tuple[type, ...], Set[str]]] diff --git a/routers/gateway_swap.py b/routers/gateway_swap.py index 6df96c4c..8c4dadfd 100644 --- a/routers/gateway_swap.py +++ b/routers/gateway_swap.py @@ -14,7 +14,7 @@ from database.repositories import GatewaySwapRepository from deps import get_accounts_service, get_database_manager from models import SwapExecuteRequest, SwapExecuteResponse, SwapQuoteRequest, SwapQuoteResponse -from routers.gateway_extras import ExtraParamsSpec, validate_extra_params +from routers.gateway_extras import ExtraParamsSpec, get_transaction_status_from_response, validate_extra_params from services.accounts_service import AccountsService from services.gateway_client import GatewayError, check_gateway_error @@ -23,23 +23,6 @@ router = APIRouter(tags=["Gateway Swaps"], prefix="/gateway") -def get_transaction_status_from_response(gateway_response: dict) -> str: - """ - Determine transaction status from Gateway response: - status 1 -> CONFIRMED, negative (-1 failed, -2 dropped) -> FAILED, - 0 or missing -> SUBMITTED. - """ - status = gateway_response.get("status") - - if status == 1: - return "CONFIRMED" - # Gateway's TransactionStatus uses negative values for terminal failures - # (e.g. a failed EVM swap returns status -1 with zeroed amounts). - if isinstance(status, (int, float)) and status < 0: - return "FAILED" - return "SUBMITTED" - - # Gateway's unified /trading/swap routes pass approximateIfNoExactOut only to the # Solana router connectors' quote path; every other provider silently ignores it. SWAP_EXTRA_PARAMS_SPEC: ExtraParamsSpec = { diff --git a/services/gateway_client.py b/services/gateway_client.py index 5e0eb8e2..d5547b3f 100644 --- a/services/gateway_client.py +++ b/services/gateway_client.py @@ -10,6 +10,26 @@ # All other connectors default to their CLMM route (meteora, orca, raydium, pancakeswap-sol). ROUTER_CONNECTORS = {"jupiter", "0x", "uniswap", "pancakeswap", "dflow", "okx", "titan"} +# The single source for chain -> native gas token. Every writer of gas_token +# columns (routers and the transaction poller) must use this — drifted local +# copies previously produced "MATIC", None, and "UNKNOWN" for the same chain. +_NATIVE_GAS_TOKENS = { + "solana": "SOL", + "ethereum": "ETH", + "polygon": "MATIC", + "avalanche": "AVAX", + "optimism": "ETH", + "arbitrum": "ETH", + "base": "ETH", + "bsc": "BNB", + "cronos": "CRO", +} + + +def get_native_gas_token(chain: str) -> str: + """Native gas token symbol for a chain (e.g. 'solana' -> 'SOL').""" + return _NATIVE_GAS_TOKENS.get(chain.lower(), "UNKNOWN") + class GatewayError(Exception): """A Gateway HTTP request that completed with a non-OK status.""" @@ -87,8 +107,10 @@ async def get_wallet_address_or_default(self, chain: str, wallet_address: Option default_wallet = await self.get_default_wallet_address(chain) if not default_wallet: raise ValueError(f"No wallet configured for chain '{chain}'") - # Skip placeholder wallet addresses (e.g., "ethereum-default-wallet", "solana-default-wallet") - if default_wallet.endswith("-default-wallet"): + # Gateway's fresh config templates write "" / + # "" as the placeholder — passing that literal + # string on would surface as an opaque Gateway address-validation error. + if default_wallet.startswith("<") and default_wallet.endswith(">"): raise ValueError(f"No valid wallet configured for chain '{chain}' (found placeholder: {default_wallet})") return default_wallet @@ -195,13 +217,18 @@ async def get_wallets(self) -> List[Dict]: return await self._request("GET", "wallet") async def get_default_wallet_address(self, chain: str) -> Optional[str]: - """Get default wallet address for a chain from Gateway config""" - try: - config = await self._request("GET", "config", params={"namespace": chain}) - return config.get("defaultWallet") - except Exception as e: - logger.error(f"Error getting default wallet for chain {chain}: {e}") - return None + """Get default wallet address for a chain from Gateway config. + + Raises GatewayError(503) when Gateway is unreachable — an unreachable + Gateway must not masquerade as "no wallet configured" (a 400 that sends + the operator chasing the wrong problem). + """ + config = await self._request("GET", "config", params={"namespace": chain}) + if config is None: + raise GatewayError("Gateway service is not available", status=503) + if isinstance(config, dict) and set(config.keys()) == {"error", "status"}: + raise GatewayError(str(config["error"]), status=config.get("status", 502)) + return config.get("defaultWallet") async def get_all_wallet_addresses(self, chain: Optional[str] = None) -> Dict[str, List[str]]: """ @@ -226,7 +253,9 @@ async def get_all_wallet_addresses(self, chain: Optional[str] = None) -> Dict[st if chain and wallet_chain != chain: continue - addresses = wallet.get("walletAddresses", []) + # Hardware (Ledger) wallets live in a separate list — without them + # the discovery/balance sweeps never see hardware-held positions. + addresses = list(wallet.get("walletAddresses", [])) + list(wallet.get("hardwareWalletAddresses", [])) if addresses and wallet_chain: result[wallet_chain] = addresses @@ -596,7 +625,7 @@ async def clmm_remove_liquidity( chain_network: str, wallet_address: str, position_address: str, - percentage: float, + percentage_to_remove: float, slippage_pct: Optional[float] = None ) -> Dict: """Remove liquidity from a CLMM position (partial). @@ -608,7 +637,7 @@ async def clmm_remove_liquidity( "chainNetwork": chain_network, "walletAddress": wallet_address, "positionAddress": position_address, - "percentageToRemove": percentage + "percentageToRemove": percentage_to_remove } if slippage_pct is not None: payload["slippagePct"] = slippage_pct diff --git a/services/gateway_transaction_poller.py b/services/gateway_transaction_poller.py index 0bf5902f..4589203c 100644 --- a/services/gateway_transaction_poller.py +++ b/services/gateway_transaction_poller.py @@ -15,7 +15,7 @@ from database import AsyncDatabaseManager from database.models import GatewayCLMMPosition from database.repositories import GatewayCLMMRepository, GatewaySwapRepository -from services.gateway_client import GatewayClient +from services.gateway_client import GatewayClient, get_native_gas_token logger = logging.getLogger(__name__) @@ -48,6 +48,10 @@ def __init__( self._poll_task: Optional[asyncio.Task] = None self._position_poll_task: Optional[asyncio.Task] = None self._last_position_poll: Optional[datetime] = None + # Consecutive position-info misses per position. A single 404/500 can be a + # transient RPC problem (Gateway 500s on upstream hiccups), so a position is + # only marked CLOSED after MISSING_STRIKES_TO_CLOSE consecutive misses. + self._position_missing_strikes: Dict[str, int] = {} async def start(self): """Start the polling service.""" @@ -99,9 +103,28 @@ async def _poll_loop(self): except asyncio.CancelledError: break + # A tx reported NOT_FOUND (-2) is only terminal once its blockhash can no longer + # be valid (~90s on Solana); before that, -2 can just mean "not visible yet". + DROPPED_GRACE_SECONDS = 180 + + # Consecutive position-info misses before a position is marked CLOSED + # (mirrors the lp_executor's external-close gate). + MISSING_STRIKES_TO_CLOSE = 3 + + # Don't reopen a DB-CLOSED position seen in positions-owned if it was closed + # within this window: the listing RPC node may simply lag the close. + REOPEN_GRACE_SECONDS = 300 + async def _poll_pending_transactions(self): """Poll all pending transactions and update their status.""" try: + # One availability gate per cycle. When Gateway is unreachable nothing is + # polled AND nothing is aged out: the age timeout must never fire on a + # transaction we could not actually check (it may have confirmed on-chain). + if not await self.gateway_client.ping(): + logger.warning("Gateway not available; skipping transaction poll cycle") + return + async with self.db_manager.get_session_context() as session: swap_repo = GatewaySwapRepository(session) clmm_repo = GatewayCLMMRepository(session) @@ -111,18 +134,6 @@ async def _poll_pending_transactions(self): logger.debug(f"Found {len(pending_swaps)} pending swaps") for swap in pending_swaps: - # Skip if too old (likely failed without proper error) - age = (datetime.now(timezone.utc) - swap.timestamp).total_seconds() - if age > self.max_retry_age: - logger.warning(f"Swap {swap.transaction_hash} exceeded max retry age, marking as FAILED") - await swap_repo.update_swap_status( - transaction_hash=swap.transaction_hash, - status="FAILED", - error_message="Transaction confirmation timeout" - ) - continue - - # Poll transaction status await self._poll_swap_transaction(swap, swap_repo) # Get pending CLMM events @@ -130,18 +141,6 @@ async def _poll_pending_transactions(self): logger.debug(f"Found {len(pending_events)} pending CLMM events") for event in pending_events: - # Skip if too old - age = (datetime.now(timezone.utc) - event.timestamp).total_seconds() - if age > self.max_retry_age: - logger.warning(f"CLMM event {event.transaction_hash} exceeded max retry age, marking as FAILED") - await clmm_repo.update_event_status( - transaction_hash=event.transaction_hash, - status="FAILED", - error_message="Transaction confirmation timeout" - ) - continue - - # Poll transaction status await self._poll_clmm_event_transaction(event, clmm_repo) except Exception as e: @@ -158,31 +157,59 @@ async def _poll_swap_transaction(self, swap, swap_repo: GatewaySwapRepository): chain, network = parts - # Check transaction status on Gateway/blockchain - # Note: This is a placeholder - actual implementation depends on Gateway API status_result = await self._check_transaction_status( chain=chain, network=network, tx_hash=swap.transaction_hash ) + if status_result is None: + # Transient (Gateway/RPC hiccup): no information, no state change. + return - if status_result: - if status_result["status"] == "CONFIRMED": - logger.info(f"Swap transaction confirmed: {swap.transaction_hash}") - await swap_repo.update_swap_status( - transaction_hash=swap.transaction_hash, - status="CONFIRMED", - gas_fee=Decimal(str(status_result.get("gas_fee", 0))) if status_result.get("gas_fee") else None, - gas_token=status_result.get("gas_token") - ) - elif status_result["status"] == "FAILED": - logger.warning(f"Swap transaction failed: {swap.transaction_hash}") - await swap_repo.update_swap_status( - transaction_hash=swap.transaction_hash, - status="FAILED", - error_message=status_result.get("error_message", "Transaction failed on-chain") - ) - # If status is still pending, do nothing and retry later + age = (datetime.now(timezone.utc) - swap.timestamp).total_seconds() + status = status_result["status"] + gas_fee_raw = status_result.get("gas_fee") + gas_fee = Decimal(str(gas_fee_raw)) if gas_fee_raw is not None else None + + if status == "CONFIRMED": + # Accepted residual: amounts/price are NOT backfilled from the poll's + # txData — a swap recorded while pending keeps its request-side leg + # and 0 placeholders after confirmation. Backfilling requires parsing + # balance changes from txData (deferred by design). + logger.info(f"Swap transaction confirmed: {swap.transaction_hash}") + await swap_repo.update_swap_status( + transaction_hash=swap.transaction_hash, + status="CONFIRMED", + gas_fee=gas_fee, + gas_token=status_result.get("gas_token") + ) + elif status == "FAILED": + # A landed-but-failed tx still paid gas — record it. + logger.warning(f"Swap transaction failed: {swap.transaction_hash}") + await swap_repo.update_swap_status( + transaction_hash=swap.transaction_hash, + status="FAILED", + error_message=status_result.get("error_message", "Transaction failed on-chain"), + gas_fee=gas_fee, + gas_token=status_result.get("gas_token") + ) + elif status == "DROPPED" and age > self.DROPPED_GRACE_SECONDS: + logger.warning(f"Swap transaction dropped (not found on-chain): {swap.transaction_hash}") + await swap_repo.update_swap_status( + transaction_hash=swap.transaction_hash, + status="FAILED", + error_message="Transaction not found on-chain (dropped after blockhash expiry)" + ) + elif status == "PENDING" and age > self.max_retry_age: + # Genuinely still unconfirmed after a successful poll — only now may + # the age timeout fire. + logger.warning(f"Swap {swap.transaction_hash} exceeded max retry age, marking as FAILED") + await swap_repo.update_swap_status( + transaction_hash=swap.transaction_hash, + status="FAILED", + error_message="Transaction confirmation timeout" + ) + # PENDING within age / DROPPED within grace: retry next cycle. except Exception as e: logger.error(f"Error polling swap transaction {swap.transaction_hash}: {e}") @@ -205,33 +232,54 @@ async def _poll_clmm_event_transaction(self, event, clmm_repo: GatewayCLMMReposi chain, network = parts - # Check transaction status status_result = await self._check_transaction_status( chain=chain, network=network, tx_hash=event.transaction_hash ) + if status_result is None: + # Transient (Gateway/RPC hiccup): no information, no state change. + return - if status_result: - if status_result["status"] == "CONFIRMED": - logger.info(f"CLMM event transaction confirmed: {event.transaction_hash}") - await clmm_repo.update_event_status( - transaction_hash=event.transaction_hash, - status="CONFIRMED", - gas_fee=Decimal(str(status_result.get("gas_fee", 0))) if status_result.get("gas_fee") else None, - gas_token=status_result.get("gas_token") - ) - - # Update position state based on event type - await self._update_position_from_event(event, clmm_repo) - - elif status_result["status"] == "FAILED": - logger.warning(f"CLMM event transaction failed: {event.transaction_hash}") - await clmm_repo.update_event_status( - transaction_hash=event.transaction_hash, - status="FAILED", - error_message=status_result.get("error_message", "Transaction failed on-chain") - ) + age = (datetime.now(timezone.utc) - event.timestamp).total_seconds() + status = status_result["status"] + gas_fee_raw = status_result.get("gas_fee") + gas_fee = Decimal(str(gas_fee_raw)) if gas_fee_raw is not None else None + + if status == "CONFIRMED": + logger.info(f"CLMM event transaction confirmed: {event.transaction_hash}") + await clmm_repo.update_event_status( + transaction_hash=event.transaction_hash, + status="CONFIRMED", + gas_fee=gas_fee, + gas_token=status_result.get("gas_token") + ) + # Update position state based on event type + await self._update_position_from_event(event, clmm_repo) + elif status == "FAILED": + logger.warning(f"CLMM event transaction failed: {event.transaction_hash}") + await clmm_repo.update_event_status( + transaction_hash=event.transaction_hash, + status="FAILED", + error_message=status_result.get("error_message", "Transaction failed on-chain"), + gas_fee=gas_fee, + gas_token=status_result.get("gas_token") + ) + elif status == "DROPPED" and age > self.DROPPED_GRACE_SECONDS: + logger.warning(f"CLMM event transaction dropped (not found on-chain): {event.transaction_hash}") + await clmm_repo.update_event_status( + transaction_hash=event.transaction_hash, + status="FAILED", + error_message="Transaction not found on-chain (dropped after blockhash expiry)" + ) + elif status == "PENDING" and age > self.max_retry_age: + logger.warning(f"CLMM event {event.transaction_hash} exceeded max retry age, marking as FAILED") + await clmm_repo.update_event_status( + transaction_hash=event.transaction_hash, + status="FAILED", + error_message="Transaction confirmation timeout" + ) + # PENDING within age / DROPPED within grace: retry next cycle. except Exception as e: logger.error(f"Error polling CLMM event transaction {event.transaction_hash}: {e}") @@ -247,10 +295,37 @@ async def _update_position_from_event(self, event, clmm_repo: GatewayCLMMReposit return if event.event_type == "CLOSE": + # Fee booking happens exactly once, on confirmation: the endpoints + # only mutate the position when Gateway confirmed the tx inline, and + # leave submitted-not-confirmed booking to this path. + if event.base_fee_collected is not None or event.quote_fee_collected is not None: + new_base = float(position.base_fee_collected or 0) + float(event.base_fee_collected or 0) + new_quote = float(position.quote_fee_collected or 0) + float(event.quote_fee_collected or 0) + await clmm_repo.update_position_fees( + position_address=position.position_address, + base_fee_collected=Decimal(str(new_base)), + quote_fee_collected=Decimal(str(new_quote)), + base_fee_pending=Decimal("0"), + quote_fee_pending=Decimal("0") + ) await clmm_repo.close_position(position.position_address) + elif event.event_type == "ADD_LIQUIDITY": + # Added capital raises the PnL baseline. Event amounts may be the + # requested figures (recorded at submit time) rather than on-chain + # actuals — the accepted residual is that pending-tx amounts are not + # backfilled from txData; requested amounts are the best available. + if event.base_token_amount or event.quote_token_amount: + await clmm_repo.add_to_initial_amounts( + position_address=position.position_address, + base_delta=Decimal(str(event.base_token_amount or 0)), + quote_delta=Decimal(str(event.quote_token_amount or 0)), + ) + elif event.event_type == "COLLECT_FEES": - # Add collected fees to cumulative total + # Add collected fees to cumulative total (endpoints book inline only + # for txs Gateway confirmed at submit time — those events are created + # CONFIRMED and never reach this path, so there is no double count). if event.base_fee_collected or event.quote_fee_collected: new_base_collected = float(position.base_fee_collected or 0) + float(event.base_fee_collected or 0) new_quote_collected = float(position.quote_fee_collected or 0) + float(event.quote_fee_collected or 0) @@ -276,16 +351,14 @@ async def _check_transaction_status( Check transaction status on blockchain via Gateway. Returns: - Dict with status, gas_fee, gas_token, and error_message if available. - None if transaction not yet confirmed or pending. + Dict with status ("CONFIRMED" | "FAILED" | "DROPPED" | "PENDING"), + gas_fee, gas_token, and error_message. + None only when no information could be obtained (Gateway/RPC hiccup) — + callers must treat that as "no state change", never as pending-with-age. """ try: - # Check if Gateway is available - if not await self.gateway_client.ping(): - logger.warning("Gateway not available for transaction polling") - return None - # Reconstruct network_id from chain and network + # (Gateway availability is gated once per cycle by the caller.) network_id = f"{chain}-{network}" # Poll transaction status from Gateway @@ -310,71 +383,62 @@ async def _check_transaction_status( logger.debug(f"Polled transaction {tx_hash} on {network_id}: txStatus={result.get('txStatus')}") - # Parse the response with defensive checks + # Classify on txStatus ALONE. Gateway deliberately returns txStatus 0 + # (pending) WITH a non-null `error` for transient poll failures — "the + # caller should poll again, not give up" — so the error field must never + # promote a pending transaction to FAILED. tx_status = result.get("txStatus") + gas_token = get_native_gas_token(chain) + gas_fee = result.get("fee") - # Determine gas token based on chain - gas_token = { - "solana": "SOL", - "ethereum": "ETH", - "arbitrum": "ETH", - "optimism": "ETH", - "polygon": "MATIC", - "avalanche": "AVAX" - }.get(chain, "UNKNOWN") - - # Transaction is confirmed if txStatus == 1 if tx_status == 1: return { "status": "CONFIRMED", - "gas_fee": result.get("fee", 0), + "gas_fee": gas_fee, "gas_token": gas_token, "error_message": None } - # Transaction failed if txStatus == -1 or there's an error field - # Gateway now returns parsed error messages like "SLIPPAGE_EXCEEDED (0x1771): ..." - error_msg = result.get("error") - if tx_status == -1 or error_msg: + if tx_status == -1: + # Landed on-chain but failed. Gateway returns parsed error messages + # like "SLIPPAGE_EXCEEDED (0x1771): ..."; fall back to meta.err. + error_msg = result.get("error") if not error_msg: - # Fallback to meta.err if no parsed error tx_data = result.get("txData") or {} meta = tx_data.get("meta") if isinstance(tx_data, dict) else {} raw_error = meta.get("err") if isinstance(meta, dict) else None error_msg = str(raw_error) if raw_error else "Transaction failed on-chain" return { "status": "FAILED", - "gas_fee": result.get("fee", 0), + "gas_fee": gas_fee, "gas_token": gas_token, "error_message": error_msg } - # Transaction still pending (txStatus == 0 or not finalized) - return None + if tx_status == -2: + # NOT_FOUND — terminal on Solana once the blockhash expires; can also + # appear briefly right after submission. The caller applies the grace + # window before treating it as dropped. + return { + "status": "DROPPED", + "gas_fee": None, + "gas_token": gas_token, + "error_message": "Transaction not found on-chain" + } + + # txStatus 0 (or anything unrecognized): a successful poll that says the + # transaction is still unconfirmed — distinct from None (no information). + return { + "status": "PENDING", + "gas_fee": None, + "gas_token": gas_token, + "error_message": None + } except Exception as e: logger.error(f"Error checking transaction status for {tx_hash}: {e}") return None - async def poll_transaction_once(self, tx_hash: str, network_id: str) -> Optional[Dict]: - """ - Poll a specific transaction once (useful for immediate status checks). - - Args: - tx_hash: Transaction hash - network_id: Network ID in format 'chain-network' (e.g., 'solana-mainnet-beta') - - Returns: - Transaction status dict or None if pending - """ - parts = network_id.split('-', 1) - if len(parts) != 2: - logger.error(f"Invalid network format: {network_id}") - return None - - chain, network = parts - return await self._check_transaction_status(chain, network, tx_hash) - # ============================================ # Position State Polling & Discovery # ============================================ @@ -469,6 +533,8 @@ async def _discover_positions_from_gateway(self) -> int: open_positions = await clmm_repo.get_position_addresses_set(status="OPEN") # Get CLOSED positions (to potentially reopen if still on-chain) closed_positions = await clmm_repo.get_position_addresses_set(status="CLOSED") + # Positions closed moments ago are exempt from reopening (lag guard) + recently_closed = await clmm_repo.get_recently_closed_addresses(self.REOPEN_GRACE_SECONDS) # Poll each supported connector/chain/wallet combination for config in self.SUPPORTED_CLMM_CONFIGS: @@ -506,6 +572,13 @@ async def _discover_positions_from_gateway(self) -> int: # Check if position was incorrectly marked as CLOSED if position_address in closed_positions: + if position_address in recently_closed: + # Just closed — the positions-owned RPC node may + # lag the close confirmation; reopening now would + # flap the record CLOSED -> OPEN -> CLOSED. + logger.debug(f"Position {position_address} closed recently; " + "skipping reopen (listing may lag the close)") + continue # Position exists on-chain but is CLOSED in DB → reopen it async with self.db_manager.get_session_context() as session: clmm_repo = GatewayCLMMRepository(session) @@ -684,11 +757,6 @@ async def _update_all_open_positions(self): except Exception as e: logger.error(f"Error updating open positions: {e}", exc_info=True) - # Legacy method name for backwards compatibility - async def _poll_open_positions(self): - """Poll all open CLMM positions and update their state. (Legacy wrapper)""" - await self._poll_and_discover_positions() - async def _refresh_position_state(self, position: GatewayCLMMPosition, clmm_repo: GatewayCLMMRepository): """ Refresh a single position's state from Gateway. @@ -735,12 +803,23 @@ async def _refresh_position_state(self, position: GatewayCLMMPosition, clmm_repo if "error" in result: status_code = result.get("status") - # Gateway returns 500 instead of 404 when position doesn't exist (closed) - # Treat any error (404 or 500) on position-info as "position closed" + # Some connectors 500 instead of 404 for a nonexistent position, + # but Gateway ALSO 500s on transient RPC problems — so a miss only + # counts as a strike, and the position closes after + # MISSING_STRIKES_TO_CLOSE consecutive misses, never on one. if status_code in (404, 500): - logger.info(f"Position {position.position_address} not found on Gateway " - f"(status: {status_code}), marking as CLOSED") - await clmm_repo.close_position(position.position_address) + strikes = self._position_missing_strikes.get(position.position_address, 0) + 1 + self._position_missing_strikes[position.position_address] = strikes + if strikes >= self.MISSING_STRIKES_TO_CLOSE: + logger.info(f"Position {position.position_address} missing from Gateway " + f"{strikes} consecutive times (last status: {status_code}), " + "marking as CLOSED") + await clmm_repo.close_position(position.position_address) + self._position_missing_strikes.pop(position.position_address, None) + else: + logger.debug(f"Position {position.position_address} miss " + f"{strikes}/{self.MISSING_STRIKES_TO_CLOSE} " + f"(status: {status_code}), not closing yet") return # Other errors → skip update, don't close logger.debug(f"Gateway error for position {position.position_address}: " @@ -752,6 +831,9 @@ async def _refresh_position_state(self, position: GatewayCLMMPosition, clmm_repo logger.warning(f"Invalid response for position {position.position_address}, missing 'address' field") return + # Successful read: the position exists — reset the miss counter. + self._position_missing_strikes.pop(position.position_address, None) + except Exception as e: logger.warning(f"Error fetching position {position.position_address} from Gateway: {e}") return diff --git a/services/gateway_wallet_service.py b/services/gateway_wallet_service.py index c12d84e3..b06e07c1 100644 --- a/services/gateway_wallet_service.py +++ b/services/gateway_wallet_service.py @@ -5,7 +5,7 @@ from fastapi import HTTPException -from services.gateway_client import GatewayClient, check_gateway_error +from services.gateway_client import GatewayClient, GatewayError, check_gateway_error from services.gecko_price_source import GeckoPriceSource # Create module-specific logger @@ -74,11 +74,16 @@ async def get_gateway_wallets(self) -> List[Dict]: try: wallets = check_gateway_error(await self.gateway_client.get_wallets()) - # Enrich with default wallet info for each chain + # Enrich with default wallet info for each chain; a per-chain config + # error degrades that chain's default to "" rather than failing the list. for wallet_group in wallets: chain = wallet_group.get("chain") if chain: - default_wallet = await self.gateway_client.get_default_wallet_address(chain) + try: + default_wallet = await self.gateway_client.get_default_wallet_address(chain) + except GatewayError as e: + logger.warning(f"Could not read default wallet for {chain}: {e}") + default_wallet = None wallet_group["default_address"] = default_wallet or "" return wallets @@ -103,6 +108,9 @@ async def add_gateway_wallet(self, chain: str, private_key: str, set_default: bo try: result = await self.gateway_client.add_wallet(chain, private_key, set_default=set_default) + if result is None: + # Connection error: the client returns None — 503, not a crash on `in None`. + raise HTTPException(status_code=503, detail="Gateway service is not available") if "error" in result: raise HTTPException(status_code=400, detail=f"Gateway error: {result['error']}") @@ -131,6 +139,9 @@ async def remove_gateway_wallet(self, chain: str, address: str) -> Dict: try: result = await self.gateway_client.remove_wallet(chain, address) + if result is None: + # Connection error: the client returns None — 503, not a crash on `in None`. + raise HTTPException(status_code=503, detail="Gateway service is not available") if "error" in result: raise HTTPException(status_code=400, detail=f"Gateway error: {result['error']}") @@ -169,6 +180,8 @@ async def get_gateway_balances(self, chain: str, address: str, network: Optional # Get balances from Gateway balances_response = await self.gateway_client.get_balances(chain, network, address, tokens=tokens) + if balances_response is None: + raise HTTPException(status_code=503, detail="Gateway service is not available") if "error" in balances_response: raise HTTPException(status_code=400, detail=f"Gateway error: {balances_response['error']}") diff --git a/test/test_gateway_client_contract.py b/test/test_gateway_client_contract.py index de32b138..03875c69 100644 --- a/test/test_gateway_client_contract.py +++ b/test/test_gateway_client_contract.py @@ -216,7 +216,7 @@ async def test_clmm_remove_liquidity_uses_percentage_to_remove(client_and_calls) client, calls = client_and_calls await client.clmm_remove_liquidity( connector="meteora", chain_network="solana-mainnet-beta", - wallet_address="WALLET", position_address="POS", percentage=50.0, + wallet_address="WALLET", position_address="POS", percentage_to_remove=50.0, ) call = calls[0] assert (call["method"], call["path"]) == ("POST", "trading/clmm/remove") @@ -232,7 +232,7 @@ async def test_clmm_remove_liquidity_sends_slippage_when_set(client_and_calls): client, calls = client_and_calls await client.clmm_remove_liquidity( connector="orca", chain_network="solana-mainnet-beta", - wallet_address="WALLET", position_address="POS", percentage=100.0, + wallet_address="WALLET", position_address="POS", percentage_to_remove=100.0, slippage_pct=0.5, ) assert calls[0]["json"]["slippagePct"] == 0.5 diff --git a/test/test_gateway_error_masking.py b/test/test_gateway_error_masking.py index 0a306428..15f1259d 100644 --- a/test/test_gateway_error_masking.py +++ b/test/test_gateway_error_masking.py @@ -109,7 +109,10 @@ async def test_refresh_does_not_close_position_on_gateway_error(): @pytest.mark.asyncio -async def test_refresh_closes_position_missing_from_valid_list(): +async def test_refresh_skips_position_missing_from_valid_list(): + """Absence from ONE positions-owned read is not proof of closure (RPC lag): + the refresh skips the update and leaves close detection to the poller's + consecutive-miss gate — a single read must never close a live position.""" from routers.gateway_clmm import _refresh_position_data accounts_service = _mock_accounts_service(clmm_positions_owned=[{"address": "OTHER"}]) @@ -120,7 +123,8 @@ async def test_refresh_closes_position_missing_from_valid_list(): ) await _refresh_position_data(_position(), accounts_service, clmm_repo) - clmm_repo.close_position.assert_awaited_once_with("POS") + clmm_repo.close_position.assert_not_awaited() + clmm_repo.update_position_liquidity.assert_not_awaited() # ============================================ @@ -162,3 +166,29 @@ async def test_poller_confirms_transaction(): poller = _poller_with_result({"txStatus": 1, "fee": 0.00001, "error": None}) result = await poller._check_transaction_status("solana", "mainnet-beta", "TX") assert result["status"] == "CONFIRMED" + # fee of exactly 0 must survive (no truthiness drop) + poller = _poller_with_result({"txStatus": 1, "fee": 0, "error": None}) + result = await poller._check_transaction_status("solana", "mainnet-beta", "TX") + assert result["gas_fee"] == 0 + + +@pytest.mark.asyncio +async def test_poller_pending_with_transient_error_stays_pending(): + """Gateway deliberately returns txStatus 0 WITH an error message for transient + poll failures ("poll again, don't give up") — the error field must never + promote a pending transaction to FAILED.""" + poller = _poller_with_result({ + "txStatus": 0, + "error": "Error polling transaction: 429 Too Many Requests", + }) + result = await poller._check_transaction_status("solana", "mainnet-beta", "TX") + assert result["status"] == "PENDING" + + +@pytest.mark.asyncio +async def test_poller_reports_not_found_as_dropped(): + """txStatus -2 is NOT_FOUND — terminal on Solana after blockhash expiry; the + caller applies the grace window, but the classifier must not call it pending.""" + poller = _poller_with_result({"txStatus": -2, "error": None}) + result = await poller._check_transaction_status("solana", "mainnet-beta", "TX") + assert result["status"] == "DROPPED" From 7d03dafa954da6c4056bd9e5ce847e194f4b4ba8 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 18 Aug 2026 22:08:01 -0700 Subject: [PATCH 13/54] fix(clmm): finish the M8 falsy-zero sweep on close/collect event fee and gas fields A fee or gas value of exactly 0 was stored as None (unknown) on close and collect events, contradicting the is-not-None convention adopted everywhere else and leaving pending-fee columns stale on the poller's confirm path. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- routers/gateway_clmm.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/routers/gateway_clmm.py b/routers/gateway_clmm.py index cb5a56df..15ccdd54 100644 --- a/routers/gateway_clmm.py +++ b/routers/gateway_clmm.py @@ -508,7 +508,7 @@ async def open_clmm_position( "event_type": "OPEN", "base_token_amount": float(base_amount_added) if base_amount_added is not None else None, "quote_token_amount": float(quote_amount_added) if quote_amount_added is not None else None, - "gas_fee": float(gas_fee) if gas_fee else None, + "gas_fee": float(gas_fee) if gas_fee is not None else None, "gas_token": gas_token, "status": tx_status } @@ -915,9 +915,9 @@ async def close_clmm_position( "event_type": "CLOSE", "base_token_amount": float(base_amount_removed) if base_amount_removed is not None else None, "quote_token_amount": float(quote_amount_removed) if quote_amount_removed is not None else None, - "base_fee_collected": float(base_fee_collected) if base_fee_collected else None, - "quote_fee_collected": float(quote_fee_collected) if quote_fee_collected else None, - "gas_fee": float(gas_fee) if gas_fee else None, + "base_fee_collected": float(base_fee_collected) if base_fee_collected is not None else None, + "quote_fee_collected": float(quote_fee_collected) if quote_fee_collected is not None else None, + "gas_fee": float(gas_fee) if gas_fee is not None else None, "gas_token": gas_token, "status": tx_status } @@ -1129,9 +1129,9 @@ async def collect_fees_from_clmm_position( "position_id": position.id, "transaction_hash": transaction_hash, "event_type": "COLLECT_FEES", - "base_fee_collected": float(base_fee_collected) if base_fee_collected else None, - "quote_fee_collected": float(quote_fee_collected) if quote_fee_collected else None, - "gas_fee": float(gas_fee) if gas_fee else None, + "base_fee_collected": float(base_fee_collected) if base_fee_collected is not None else None, + "quote_fee_collected": float(quote_fee_collected) if quote_fee_collected is not None else None, + "gas_fee": float(gas_fee) if gas_fee is not None else None, "gas_token": gas_token, "status": tx_status } From 1a293382dd3045e25345ffa76f4c2414f9a90354 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 18 Aug 2026 22:20:00 -0700 Subject: [PATCH 14/54] fix(gateway): treat unreachable-Gateway results as failures in update_api_keys A None result from the client (connection error mid-batch) was filtered out as if it succeeded, so the endpoint reported keys updated that never reached Gateway. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- routers/gateway.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/routers/gateway.py b/routers/gateway.py index e864efc8..cab5cbc7 100644 --- a/routers/gateway.py +++ b/routers/gateway.py @@ -306,8 +306,14 @@ async def update_api_keys( results = await accounts_service.gateway_client.update_api_keys(request.api_keys) - # Check for any errors in the results - errors = [r for r in results if r and "error" in r] + # A None result means the request never reached Gateway (connection + # error mid-batch) — that is a failure, not a success to filter out. + if any(r is None for r in results): + raise HTTPException( + status_code=503, + detail="Gateway became unreachable while updating API keys; not all keys were applied", + ) + errors = [r for r in results if "error" in r] if errors: raise HTTPException(status_code=400, detail=f"Failed to update some API keys: {errors}") From e96594b6f36e9480573cfc5d29a20d77c0084f65 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 18 Aug 2026 22:33:22 -0700 Subject: [PATCH 15/54] refactor(amm): drop pool-scoped swap proxies; network param on clmm pools; uppercase side on read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gateway folded /trading/amm/quote-swap and execute-swap into the unified /trading/swap route (connector as name/type, pool resolved internally), so the hapi proxies, models, and client methods go with them — swaps always go through /gateway/swap regardless of connector type. /gateway/clmm/pools takes a network parameter instead of hardcoding mainnet-beta (the last endpoint on the surface without one), and the swap repository serves side uppercase so legacy lowercase rows cannot leak into strict consumers. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- .../repositories/gateway_swap_repository.py | 3 +- models/__init__.py | 6 -- models/gateway_trading.py | 40 -------------- routers/gateway_amm.py | 55 ------------------- routers/gateway_clmm.py | 8 ++- services/gateway_client.py | 48 ---------------- test/test_gateway_client_contract.py | 26 --------- 7 files changed, 8 insertions(+), 178 deletions(-) diff --git a/database/repositories/gateway_swap_repository.py b/database/repositories/gateway_swap_repository.py index 4547cc31..ad407b5e 100644 --- a/database/repositories/gateway_swap_repository.py +++ b/database/repositories/gateway_swap_repository.py @@ -169,7 +169,8 @@ def to_dict(self, swap: GatewaySwap) -> Dict: "trading_pair": swap.trading_pair, "base_token": swap.base_token, "quote_token": swap.quote_token, - "side": swap.side, + # Legacy rows written before normalization may hold lowercase; serve uppercase + "side": (swap.side or "").upper(), "input_amount": float(swap.input_amount), "output_amount": float(swap.output_amount), "price": float(swap.price), diff --git a/models/__init__.py b/models/__init__.py index b0742cf6..e7ef8444 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -94,15 +94,12 @@ AMMAddLiquidityRequest, AMMCreatePoolRequest, AMMCreatePoolResponse, - AMMExecuteSwapRequest, AMMPoolInfoResponse, AMMPositionDetail, AMMPositionInfoResponse, AMMPositionsOwnedRequest, AMMQuoteLiquidityRequest, AMMQuoteLiquidityResponse, - AMMQuoteSwapRequest, - AMMQuoteSwapResponse, AMMRemoveLiquidityRequest, AMMTransactionResponse, CLMMAddLiquidityRequest, @@ -321,9 +318,6 @@ "AMMPoolInfoResponse", "AMMPositionDetail", "AMMPositionInfoResponse", - "AMMQuoteSwapRequest", - "AMMQuoteSwapResponse", - "AMMExecuteSwapRequest", "AMMTransactionResponse", "AMMQuoteLiquidityRequest", "AMMQuoteLiquidityResponse", diff --git a/models/gateway_trading.py b/models/gateway_trading.py index c1ddc1a2..93a8ef66 100644 --- a/models/gateway_trading.py +++ b/models/gateway_trading.py @@ -402,46 +402,6 @@ class AMMPositionInfoResponse(BaseModel): model_config = {"populate_by_name": True} -class AMMQuoteSwapRequest(BaseModel): - """Request to quote a swap against a specific AMM pool.""" - connector: str = Field(description="AMM connector (e.g., 'meteora', 'raydium', 'uniswap')") - network: str = Field(description="Network ID in 'chain-network' format (e.g., 'solana-mainnet-beta')") - pool_address: str = Field(description="Pool contract address") - base_token: str = Field(description="Token that defines the swap direction (symbol or address)") - side: str = Field(description="Trade direction: BUY or SELL") - amount: Decimal = Field(description="Amount to swap (of base for SELL, of base to receive for BUY)") - slippage_pct: Optional[Decimal] = Field(default=None, description="Maximum slippage percentage") - - -class AMMQuoteSwapResponse(BaseModel): - """Response with an AMM swap quote.""" - pool_address: str = Field(alias="poolAddress", description="Pool address") - token_in: str = Field(alias="tokenIn", description="Input token address") - token_out: str = Field(alias="tokenOut", description="Output token address") - amount_in: Decimal = Field(alias="amountIn", description="Input amount") - amount_out: Decimal = Field(alias="amountOut", description="Output amount") - price: Decimal = Field(description="Execution price") - min_amount_out: Decimal = Field(alias="minAmountOut", description="Minimum output after slippage") - max_amount_in: Decimal = Field(alias="maxAmountIn", description="Maximum input after slippage") - price_impact_pct: Decimal = Field(alias="priceImpactPct", description="Price impact percentage") - slippage_pct: Optional[Decimal] = Field(default=None, alias="slippagePct", description="Slippage percentage used") - - model_config = {"populate_by_name": True} - - -class AMMExecuteSwapRequest(BaseModel): - """Request to execute a swap against a specific AMM pool.""" - connector: str = Field(description="AMM connector (e.g., 'meteora', 'raydium', 'uniswap')") - network: str = Field(description="Network ID in 'chain-network' format (e.g., 'solana-mainnet-beta')") - pool_address: str = Field(description="Pool contract address") - base_token: str = Field(description="Token that defines the swap direction (symbol or address)") - side: str = Field(description="Trade direction: BUY or SELL") - amount: Decimal = Field(description="Amount to swap") - slippage_pct: Optional[Decimal] = Field( - default=None, description="Maximum slippage percentage; omit to use the connector's configured slippagePct") - wallet_address: Optional[str] = Field(default=None, description="Wallet address (optional, uses default)") - - class AMMTransactionResponse(BaseModel): """Chain-neutral write response. `signature` holds the tx signature (Solana) or tx hash (EVM).""" signature: str = Field(description="Transaction signature (Solana) or transaction hash (EVM)") diff --git a/routers/gateway_amm.py b/routers/gateway_amm.py index 92ad1133..607c1a4f 100644 --- a/routers/gateway_amm.py +++ b/routers/gateway_amm.py @@ -21,14 +21,11 @@ AMMAddLiquidityRequest, AMMCreatePoolRequest, AMMCreatePoolResponse, - AMMExecuteSwapRequest, AMMPoolInfoResponse, AMMPositionInfoResponse, AMMPositionsOwnedRequest, AMMQuoteLiquidityRequest, AMMQuoteLiquidityResponse, - AMMQuoteSwapRequest, - AMMQuoteSwapResponse, AMMRemoveLiquidityRequest, AMMTransactionResponse, ) @@ -146,31 +143,6 @@ async def get_amm_positions_owned( raise HTTPException(status_code=500, detail=f"Error getting AMM positions owned: {str(e)}") -@router.post("/amm/quote-swap", response_model=AMMQuoteSwapResponse, response_model_by_alias=False) -async def quote_amm_swap( - request: AMMQuoteSwapRequest, - accounts_service: AccountsService = Depends(get_accounts_service), -): - """Quote a swap against a specific AMM pool (pool-scoped, not router).""" - try: - await _require_gateway(accounts_service) - result = check_gateway_error(await accounts_service.gateway_client.amm_quote_swap( - connector=request.connector, chain_network=request.network, pool_address=request.pool_address, - base_token=request.base_token, side=request.side, amount=float(request.amount), - slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else None, - )) - return AMMQuoteSwapResponse(**result) - except HTTPException: - raise - except GatewayError as e: - raise HTTPException(status_code=e.status, detail=f"Gateway error quoting AMM swap: {e}") - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - logger.error(f"Error quoting AMM swap: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Error quoting AMM swap: {str(e)}") - - @router.post("/amm/quote-liquidity", response_model=AMMQuoteLiquidityResponse, response_model_by_alias=False) async def quote_amm_liquidity( request: AMMQuoteLiquidityRequest, @@ -198,33 +170,6 @@ async def quote_amm_liquidity( # ----------------------------- Writes ----------------------------- -@router.post("/amm/execute-swap", response_model=AMMTransactionResponse) -async def execute_amm_swap( - request: AMMExecuteSwapRequest, - accounts_service: AccountsService = Depends(get_accounts_service), -): - """Execute a swap against a specific AMM pool.""" - try: - await _require_gateway(accounts_service) - wallet_address = await _resolve_wallet(accounts_service, request.network, request.wallet_address) - result = check_gateway_error(await accounts_service.gateway_client.amm_execute_swap( - connector=request.connector, chain_network=request.network, wallet_address=wallet_address, - pool_address=request.pool_address, base_token=request.base_token, side=request.side, - amount=float(request.amount), - slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else None, - )) - return AMMTransactionResponse(**result) - except HTTPException: - raise - except GatewayError as e: - raise HTTPException(status_code=e.status, detail=f"Gateway error executing AMM swap: {e}") - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - logger.error(f"Error executing AMM swap: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Error executing AMM swap: {str(e)}") - - @router.post("/amm/add-liquidity", response_model=AMMTransactionResponse) async def add_amm_liquidity( request: AMMAddLiquidityRequest, diff --git a/routers/gateway_clmm.py b/routers/gateway_clmm.py index 15ccdd54..211ccc5f 100644 --- a/routers/gateway_clmm.py +++ b/routers/gateway_clmm.py @@ -210,6 +210,9 @@ async def get_clmm_pool_info( @router.get("/clmm/pools", response_model=CLMMPoolListResponse) async def get_clmm_pools( connector: str, + network: str = Query( + "mainnet-beta", + description="Solana network name (bare, e.g. 'mainnet-beta'); meteora/orca are Solana-only"), page: int = Query(0, ge=0, description="Page number"), limit: int = Query(50, ge=1, le=100, description="Results per page (max 100)"), search_term: Optional[str] = Query(None, description="Search query to filter pools"), @@ -225,6 +228,7 @@ async def get_clmm_pools( Args: connector: CLMM connector (meteora, orca) + network: Solana network name (bare, default 'mainnet-beta') page: Page number (default: 0) limit: Results per page (default: 50, max: 100) search_term: Search query to filter pools (optional) @@ -256,7 +260,7 @@ async def get_clmm_pools( direction = order_by if order_by else "desc" gateway_data = check_gateway_error(await accounts_service.gateway_client.clmm_fetch_pools( connector="meteora", - network="mainnet-beta", + network=network, limit=limit, query=search_term, sort_by=f"{sort_key}{time_suffix}:{direction}" if sort_key else None, @@ -272,7 +276,7 @@ async def get_clmm_pools( ) gateway_data = check_gateway_error(await accounts_service.gateway_client.clmm_fetch_pools( connector="orca", - network="mainnet-beta", + network=network, limit=limit, query=search_term, sort_by=sort_key, diff --git a/services/gateway_client.py b/services/gateway_client.py index d5547b3f..824f693a 100644 --- a/services/gateway_client.py +++ b/services/gateway_client.py @@ -877,54 +877,6 @@ async def amm_positions_owned( "walletAddress": wallet_address, }) - async def amm_quote_swap( - self, - connector: str, - chain_network: str, - pool_address: str, - base_token: str, - side: str, - amount: float, - slippage_pct: Optional[float] = None, - ) -> Dict: - """Quote a swap against a specific AMM pool.""" - params = { - "connector": connector, - "chainNetwork": chain_network, - "poolAddress": pool_address, - "baseToken": base_token, - "side": side.upper(), - "amount": amount, - } - if slippage_pct is not None: - params["slippagePct"] = slippage_pct - return await self._request("GET", "trading/amm/quote-swap", params=params) - - async def amm_execute_swap( - self, - connector: str, - chain_network: str, - wallet_address: str, - pool_address: str, - base_token: str, - side: str, - amount: float, - slippage_pct: Optional[float] = None, - ) -> Dict: - """Execute a swap against a specific AMM pool.""" - payload = { - "connector": connector, - "chainNetwork": chain_network, - "walletAddress": wallet_address, - "poolAddress": pool_address, - "baseToken": base_token, - "side": side.upper(), - "amount": amount, - } - if slippage_pct is not None: - payload["slippagePct"] = slippage_pct - return await self._request("POST", "trading/amm/execute-swap", json=payload) - async def amm_quote_liquidity( self, connector: str, diff --git a/test/test_gateway_client_contract.py b/test/test_gateway_client_contract.py index 03875c69..30b860a3 100644 --- a/test/test_gateway_client_contract.py +++ b/test/test_gateway_client_contract.py @@ -373,32 +373,6 @@ async def test_amm_positions_owned_path(client_and_calls): assert c["params"] == {"connector": "meteora", "chainNetwork": NET, "walletAddress": WALLET} -@pytest.mark.asyncio -async def test_amm_quote_swap_path_and_slippage_omitted(client_and_calls): - client, calls = client_and_calls - await client.amm_quote_swap(connector="raydium", chain_network=NET, pool_address=POOL, - base_token="SOL", side="SELL", amount=0.01) - c = calls[0] - assert (c["method"], c["path"]) == ("GET", "trading/amm/quote-swap") - assert c["params"] == {"connector": "raydium", "chainNetwork": NET, "poolAddress": POOL, - "baseToken": "SOL", "side": "SELL", "amount": 0.01} - assert "slippagePct" not in c["params"] - - -@pytest.mark.asyncio -async def test_amm_execute_swap_path(client_and_calls): - client, calls = client_and_calls - await client.amm_execute_swap(connector="uniswap", chain_network="ethereum-mainnet", wallet_address=WALLET, - pool_address=POOL, base_token="WETH", side="buy", amount=1.0, slippage_pct=0.5) - c = calls[0] - assert (c["method"], c["path"]) == ("POST", "trading/amm/execute-swap") - assert c["json"]["walletAddress"] == WALLET - assert c["json"]["chainNetwork"] == "ethereum-mainnet" - assert c["json"]["slippagePct"] == 0.5 - # Gateway's schema enum-rejects lowercase; the client normalizes like the unified swap path - assert c["json"]["side"] == "BUY" - - @pytest.mark.asyncio async def test_amm_add_liquidity_omits_position_when_unset(client_and_calls): client, calls = client_and_calls From ebe2a22b3d22cb027095dd8a0a50a17ca876cb61 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 18 Aug 2026 22:57:28 -0700 Subject: [PATCH 16/54] fix(gateway): resolve swap connector types from Gateway; preserve error codes The hardcoded ROUTER_CONNECTORS roster silently misrouted every connector Gateway added after it was written (a bare name fell through to /clmm and 404'd). Connector trading types now come from Gateway's own config/connectors listing, cached per client, preferring router then clmm then amm; an unknown name raises instead of guessing. Gateway's machine-readable error code (TRANSACTION_TIMEOUT, SLIPPAGE_EXCEEDED, ...) was flattened into prose before GatewayError was raised, leaving callers unable to tell retryable from terminal failures. It now rides GatewayError.code. Also: the swap execute response no longer lower-cases its status (every read surface reports uppercase), and swaps file under the base venue name so 'jupiter' and 'jupiter/router' land in one history bucket. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- .../repositories/gateway_swap_repository.py | 4 +- routers/gateway_swap.py | 6 +- services/gateway_client.py | 90 +++++++++++++------ test/test_gateway_client_contract.py | 42 ++++++++- 4 files changed, 109 insertions(+), 33 deletions(-) diff --git a/database/repositories/gateway_swap_repository.py b/database/repositories/gateway_swap_repository.py index ad407b5e..1dd29625 100644 --- a/database/repositories/gateway_swap_repository.py +++ b/database/repositories/gateway_swap_repository.py @@ -72,7 +72,9 @@ async def get_swaps( if network: query = query.where(GatewaySwap.network == network) if connector: - query = query.where(GatewaySwap.connector == connector) + # Rows store the base venue name; accept a typed provider + # ("jupiter/router") as the same filter. + query = query.where(GatewaySwap.connector == connector.split("/")[0]) if wallet_address: query = query.where(GatewaySwap.wallet_address == wallet_address) if trading_pair: diff --git a/routers/gateway_swap.py b/routers/gateway_swap.py index 8c4dadfd..17381bd8 100644 --- a/routers/gateway_swap.py +++ b/routers/gateway_swap.py @@ -198,7 +198,9 @@ async def execute_swap( swap_data = { "transaction_hash": transaction_hash, "network": request.network, - "connector": request.connector, + # Store the base venue name: a swap on "jupiter" and one on + # "jupiter/router" are the same venue and must file together. + "connector": request.connector.split("/")[0], "wallet_address": wallet_address, "trading_pair": request.trading_pair, "base_token": base, @@ -226,7 +228,7 @@ async def execute_swap( amount=request.amount, # "confirmed" / "submitted" / "failed" — a failed EVM swap comes back as # status -1 with zeroed amounts, which must not read as in-flight. - status=tx_status.lower() + status=tx_status ) except HTTPException: diff --git a/services/gateway_client.py b/services/gateway_client.py index 824f693a..037e60fb 100644 --- a/services/gateway_client.py +++ b/services/gateway_client.py @@ -6,9 +6,11 @@ logger = logging.getLogger(__name__) -# Connectors whose bare name maps to a router-type swap provider on Gateway. -# All other connectors default to their CLMM route (meteora, orca, raydium, pancakeswap-sol). -ROUTER_CONNECTORS = {"jupiter", "0x", "uniswap", "pancakeswap", "dflow", "okx", "titan"} +# When a caller names a connector without a trading type, Gateway's own +# config/connectors listing decides the type — preferring a router route, then +# CLMM, then AMM. A hardcoded roster silently misrouted every connector Gateway +# added after it was written. +_SWAP_TYPE_PREFERENCE = ("router", "clmm", "amm") # The single source for chain -> native gas token. Every writer of gas_token # columns (routers and the transaction poller) must use this — drifted local @@ -34,9 +36,13 @@ def get_native_gas_token(chain: str) -> str: class GatewayError(Exception): """A Gateway HTTP request that completed with a non-OK status.""" - def __init__(self, message: str, status: int): + def __init__(self, message: str, status: int, code: Optional[str] = None): super().__init__(message) self.status = status + # Gateway's machine-readable error code (TRANSACTION_TIMEOUT, + # SLIPPAGE_EXCEEDED, ...). Callers branch on this instead of + # string-matching the message. + self.code = code def check_gateway_error(result: Optional[Any]) -> Any: @@ -50,8 +56,8 @@ def check_gateway_error(result: Optional[Any]) -> Any: """ if result is None: raise GatewayError("No response from Gateway (connection error)", 503) - if isinstance(result, dict) and set(result.keys()) == {"error", "status"}: - raise GatewayError(str(result["error"]), int(result["status"])) + if isinstance(result, dict) and {"error", "status"} <= set(result.keys()) <= {"error", "status", "code"}: + raise GatewayError(str(result["error"]), int(result["status"]), result.get("code")) return result @@ -84,6 +90,8 @@ def __init__( # while the Gateway is simply not started. Logged once on the transition, then suppressed # until certs become available again. self._certs_unavailable_warned = False + # Gateway's connector -> trading_types listing, fetched on first use. + self._connector_trading_types: Optional[Dict[str, List[str]]] = None @staticmethod def parse_network_id(network_id: str) -> tuple[str, str]: @@ -166,23 +174,23 @@ async def _request(self, method: str, path: str, params: Dict = None, json: Dict if method == "GET": async with session.get(url, params=params) as response: if not response.ok: - error_body = await self._get_error_body(response) + error_body, error_code = await self._get_error_body(response) logger.warning(f"Gateway request failed: {method} {url} - {response.status} - {error_body}") - return {"error": error_body, "status": response.status} + return {"error": error_body, "status": response.status, "code": error_code} return await response.json() elif method == "POST": async with session.post(url, params=params, json=json) as response: if not response.ok: - error_body = await self._get_error_body(response) + error_body, error_code = await self._get_error_body(response) logger.warning(f"Gateway request failed: {method} {url} - {response.status} - {error_body}") - return {"error": error_body, "status": response.status} + return {"error": error_body, "status": response.status, "code": error_code} return await response.json() elif method == "DELETE": async with session.delete(url, params=params, json=json) as response: if not response.ok: - error_body = await self._get_error_body(response) + error_body, error_code = await self._get_error_body(response) logger.warning(f"Gateway request failed: {method} {url} - {response.status} - {error_body}") - return {"error": error_body, "status": response.status} + return {"error": error_body, "status": response.status, "code": error_code} return await response.json() except aiohttp.ClientError as e: logger.debug(f"Gateway request error: {method} {url} - {e}") @@ -191,18 +199,23 @@ async def _request(self, method: str, path: str, params: Dict = None, json: Dict logger.debug(f"Gateway request failed: {method} {url} - {e}") raise - async def _get_error_body(self, response: aiohttp.ClientResponse) -> str: - """Extract error message from response body""" + async def _get_error_body(self, response: aiohttp.ClientResponse) -> tuple: + """Extract (message, code) from an error response body. + + Gateway's error envelope is {statusCode, error, message, code?}; the + code is machine-readable (TRANSACTION_TIMEOUT, SLIPPAGE_EXCEEDED, ...) + and is what callers should branch on. + """ try: data = await response.json() if isinstance(data, dict): - return data.get("message") or data.get("error") or str(data) - return str(data) + return (data.get("message") or data.get("error") or str(data), data.get("code")) + return (str(data), None) except Exception: try: - return await response.text() + return (await response.text(), None) except Exception: - return f"HTTP {response.status}" + return (f"HTTP {response.status}", None) async def ping(self) -> bool: """Check if Gateway is online""" @@ -448,18 +461,43 @@ async def delete_pool(self, chain: str, network: str, address: str) -> Dict: # Swap Operations (unified /trading/swap endpoints) # ============================================ - @staticmethod - def normalize_swap_connector(connector: str) -> str: + async def _get_connector_trading_types(self) -> Dict[str, List[str]]: + """Gateway's connector -> trading_types map, fetched once per client.""" + if self._connector_trading_types is None: + listing = check_gateway_error(await self.get_connectors()) + self._connector_trading_types = { + entry["name"]: list(entry.get("trading_types", [])) + for entry in listing.get("connectors", []) + if entry.get("name") + } + return self._connector_trading_types + + async def normalize_swap_connector(self, connector: str) -> str: """ Normalize a connector name to Gateway's 'name/type' swap-provider format. - 'jupiter' -> 'jupiter/router', 'meteora' -> 'meteora/clmm', - 'raydium/amm' -> 'raydium/amm' (already typed, passed through). + An already-typed value passes through untouched ('raydium/amm'). A bare + name takes the connector's most swap-appropriate trading type as Gateway + reports it: router, else clmm, else amm. Unknown names raise rather than + guessing a type that Gateway would reject with an opaque 400. """ if "/" in connector: return connector - connector_type = "router" if connector in ROUTER_CONNECTORS else "clmm" - return f"{connector}/{connector_type}" + trading_types = (await self._get_connector_trading_types()).get(connector) + if trading_types is None: + raise GatewayError( + f"Unknown swap connector '{connector}'. Gateway reports: " + f"{', '.join(sorted((await self._get_connector_trading_types()).keys()))}", + 400, + ) + for candidate in _SWAP_TYPE_PREFERENCE: + if candidate in trading_types: + return f"{connector}/{candidate}" + raise GatewayError( + f"Connector '{connector}' supports no swap trading type " + f"(Gateway reports: {', '.join(trading_types) or 'none'})", + 400, + ) async def quote_swap( self, @@ -485,7 +523,7 @@ async def quote_swap( """ params = { "chainNetwork": chain_network, - "connector": self.normalize_swap_connector(connector), + "connector": await self.normalize_swap_connector(connector), "baseToken": base_asset, "quoteToken": quote_asset, "amount": str(amount), @@ -520,7 +558,7 @@ async def execute_swap( """ payload = { "chainNetwork": chain_network, - "connector": self.normalize_swap_connector(connector), + "connector": await self.normalize_swap_connector(connector), "walletAddress": wallet_address, "baseToken": base_asset, "quoteToken": quote_asset, diff --git a/test/test_gateway_client_contract.py b/test/test_gateway_client_contract.py index 30b860a3..0cf8b39d 100644 --- a/test/test_gateway_client_contract.py +++ b/test/test_gateway_client_contract.py @@ -18,6 +18,22 @@ from services.gateway_client import GatewayClient, GatewayError, check_gateway_error +# Gateway's own config/connectors listing decides an untyped connector's swap +# type; these trading_types mirror what Gateway reports today. +_CONNECTOR_LISTING = {"connectors": [ + {"name": "jupiter", "trading_types": ["router"]}, + {"name": "0x", "trading_types": ["router"]}, + {"name": "uniswap", "trading_types": ["router", "amm", "clmm"]}, + {"name": "pancakeswap", "trading_types": ["router", "amm", "clmm"]}, + {"name": "dflow", "trading_types": ["router"]}, + {"name": "okx", "trading_types": ["router"]}, + {"name": "titan", "trading_types": ["router"]}, + {"name": "meteora", "trading_types": ["clmm", "amm"]}, + {"name": "orca", "trading_types": ["clmm"]}, + {"name": "raydium", "trading_types": ["clmm", "amm"]}, + {"name": "pancakeswap-sol", "trading_types": ["clmm"]}, +]} + @pytest.fixture def client_and_calls(monkeypatch): @@ -30,6 +46,11 @@ async def fake_request(method, path, params=None, json=None): return {} monkeypatch.setattr(client, "_request", fake_request) + # Pre-seed Gateway's connector listing so swap payload assertions see only + # the swap call itself, not the one-off discovery request behind it. + client._connector_trading_types = { + entry["name"]: entry["trading_types"] for entry in _CONNECTOR_LISTING["connectors"] + } return client, calls @@ -37,13 +58,13 @@ async def fake_request(method, path, params=None, json=None): # Connector normalization # ============================================ + +@pytest.mark.asyncio @pytest.mark.parametrize("connector,expected", [ ("jupiter", "jupiter/router"), ("0x", "0x/router"), ("uniswap", "uniswap/router"), ("pancakeswap", "pancakeswap/router"), - # The full Solana router roster Gateway routes as first-class providers — - # a bare name missing here would misroute to /clmm and 404. ("dflow", "dflow/router"), ("okx", "okx/router"), ("titan", "titan/router"), @@ -56,8 +77,21 @@ async def fake_request(method, path, params=None, json=None): ("meteora/clmm", "meteora/clmm"), ("raydium/amm", "raydium/amm"), ]) -def test_normalize_swap_connector(connector, expected): - assert GatewayClient.normalize_swap_connector(connector) == expected +async def test_normalize_swap_connector(connector, expected): + client = GatewayClient() + client._connector_trading_types = { + entry["name"]: entry["trading_types"] for entry in _CONNECTOR_LISTING["connectors"] + } + assert await client.normalize_swap_connector(connector) == expected + + +@pytest.mark.asyncio +async def test_normalize_swap_connector_rejects_unknown_name(): + client = GatewayClient() + client._connector_trading_types = {"jupiter": ["router"]} + with pytest.raises(GatewayError) as exc: + await client.normalize_swap_connector("nosuchdex") + assert "nosuchdex" in str(exc.value) # ============================================ From 620d20ee127bb8c1d29c8dc3bf46f9005c1a9ba9 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 18 Aug 2026 22:57:39 -0700 Subject: [PATCH 17/54] chore: ignore .DS_Store and the local patched-hummingbot Dockerfile Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 64ad9ca2..dcfc78e0 100644 --- a/.gitignore +++ b/.gitignore @@ -181,3 +181,5 @@ bots/conf/ .idea/ improvements bots/gateway-files/ +.DS_Store +Dockerfile.patched-hummingbot From 5eceb4b2483c115a4d8bae1f8276e19b6577a342 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Tue, 18 Aug 2026 23:36:30 -0700 Subject: [PATCH 18/54] test(gateway): pin the amm quote-liquidity contract The one client verb with no path/payload test; also pins that an omitted slippage stays omitted and an explicit 0 is sent. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- test/test_gateway_client_contract.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/test/test_gateway_client_contract.py b/test/test_gateway_client_contract.py index 0cf8b39d..70e97de0 100644 --- a/test/test_gateway_client_contract.py +++ b/test/test_gateway_client_contract.py @@ -407,6 +407,27 @@ async def test_amm_positions_owned_path(client_and_calls): assert c["params"] == {"connector": "meteora", "chainNetwork": NET, "walletAddress": WALLET} +@pytest.mark.asyncio +async def test_amm_quote_liquidity_path_and_slippage_omitted(client_and_calls): + client, calls = client_and_calls + await client.amm_quote_liquidity(connector="meteora", chain_network=NET, pool_address=POOL, + base_token_amount=1.0, quote_token_amount=100.0) + c = calls[0] + assert (c["method"], c["path"]) == ("GET", "trading/amm/quote-liquidity") + assert c["params"] == {"connector": "meteora", "chainNetwork": NET, "poolAddress": POOL, + "baseTokenAmount": 1.0, "quoteTokenAmount": 100.0} + # Omitted slippage means "use the connector's configured slippagePct" + assert "slippagePct" not in c["params"] + + +@pytest.mark.asyncio +async def test_amm_quote_liquidity_sends_zero_slippage(client_and_calls): + client, calls = client_and_calls + await client.amm_quote_liquidity(connector="meteora", chain_network=NET, pool_address=POOL, + base_token_amount=1.0, quote_token_amount=100.0, slippage_pct=0) + assert calls[0]["params"]["slippagePct"] == 0 + + @pytest.mark.asyncio async def test_amm_add_liquidity_omits_position_when_unset(client_and_calls): client, calls = client_and_calls From 853174873961d5cbabd820050d6a95b8f7def8a4 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Wed, 19 Aug 2026 15:04:19 -0700 Subject: [PATCH 19/54] fix(gateway): follow Gateway's route refactor; record what live trading proved missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two threads, both from testing this stack against Solana mainnet. Routes. Gateway moved the trading type into the path and constrained `connector` to a bare name. Four paths here were stale and would have 404'd: clmm/quote-position -> quote-liquidity, amm/{add,remove}-liquidity -> {add,remove}, and per-connector fetch-pools -> trading/clmm/fetch-pools, which also moved connector from a path segment to a query parameter. normalize_swap_connector became resolve_swap_route, returning (name, type) so the type can select the route. test_gateway_paths_exist.py asserts every path literal against Gateway's OpenAPI spec, vendored so the check runs in CI and so adopting a Gateway change is a reviewable diff; it found all four. Recording. Each of these was found by executing a real transaction and reconciling the stored row against the chain: - CLMM positions labelled tokens with the last 8 characters of the mint and called it a symbol, so a position read "11111112-ZwyTDt1v" and no trading_pair filter could match "SOL-USDC". Now resolved against Gateway's token list, falling back to the full address, which at least identifies the token. - Swap execute never recorded gas: the field existed and the poller could fill it, but the poller only revisits *pending* swaps, so a swap confirmed inline — the normal case — had its gas recorded by nobody. - slippage_pct stored the requested value, so omitting it (the connector default) recorded null while Gateway reported what it actually applied. - position_rent_refunded was parsed at close, logged, returned to the caller, and discarded; there was no column. Adding it lets a close be reconciled against the rent locked at open. - add_liquidity raised the PnL baseline without raising the held amounts, and remove_liquidity booked neither. Either way the row reported a gain or loss of exactly the amount transacted, for up to the 5 minutes until the position poller corrected it. Both now move together, and an add re-weights entry_price so capital added later is valued at the price it entered at. - AMM writes persisted nothing at all: no event, no position, no gas. They now record to gateway_amm_events for every connector, with the pool price, so a fungible-LP position has a cost basis. Meteora DAMM v2 positions are NFTs and additionally get rows in gateway_amm_positions; the address is reconciled from positions-owned until Gateway returns it on open, and that workaround is marked for deletion. - AMM responses returned Gateway's raw TransactionStatus enum where the swap and CLMM surfaces return CONFIRMED/SUBMITTED/FAILED, so callers saw "1" instead of a status. --- database/models.py | 85 + database/repositories/__init__.py | 2 + .../repositories/gateway_amm_repository.py | 226 + .../repositories/gateway_clmm_repository.py | 75 +- gateway-openapi.json | 6948 +++++++++++++++++ models/gateway_trading.py | 14 +- routers/gateway_amm.py | 348 +- routers/gateway_clmm.py | 83 +- routers/gateway_swap.py | 38 +- services/gateway_client.py | 111 +- services/gateway_transaction_poller.py | 23 +- test/test_gateway_client_contract.py | 74 +- test/test_gateway_paths_exist.py | 100 + 13 files changed, 8003 insertions(+), 124 deletions(-) create mode 100644 database/repositories/gateway_amm_repository.py create mode 100644 gateway-openapi.json create mode 100644 test/test_gateway_paths_exist.py diff --git a/database/models.py b/database/models.py index 84698835..154a25b6 100644 --- a/database/models.py +++ b/database/models.py @@ -287,6 +287,10 @@ class GatewayCLMMPosition(Base): # Position rent (SOL locked for position NFT, returned on close) position_rent = Column(Numeric(precision=30, scale=18), nullable=True) + # What the chain actually refunded when the position account was closed. Kept + # alongside position_rent rather than replacing it so the two can be compared — + # a close refunding less than was locked means an account was left behind. + position_rent_refunded = Column(Numeric(precision=30, scale=18), nullable=True) # Current liquidity amounts base_token_amount = Column(Numeric(precision=30, scale=18), nullable=False, default=0) @@ -348,6 +352,87 @@ class GatewayCLMMEvent(Base): position = relationship("GatewayCLMMPosition", back_populates="events") +class GatewayAMMPosition(Base): + """A Meteora DAMM v2 position — an NFT with its own identity, tracked like a CLMM one. + + Only connectors whose positions are NFTs get rows here. Fungible-LP AMMs (Raydium CPMM, + Uniswap/PancakeSwap V2) have no per-position identity to key on: their holdings are the + LP token balance, read live, and their history is gateway_amm_events alone. + """ + __tablename__ = "gateway_amm_positions" + + id = Column(Integer, primary_key=True, index=True) + + position_address = Column(String, nullable=False, unique=True, index=True) # position NFT + pool_address = Column(String, nullable=False, index=True) + + network = Column(String, nullable=False, index=True) # chain-network format + connector = Column(String, nullable=False, index=True) + wallet_address = Column(String, nullable=False, index=True) + + base_token = Column(String, nullable=False, index=True) + quote_token = Column(String, nullable=False, index=True) + trading_pair = Column(String, nullable=False, index=True) + + created_at = Column(TIMESTAMP(timezone=True), server_default=func.now(), nullable=False, index=True) + closed_at = Column(TIMESTAMP(timezone=True), nullable=True, index=True) + status = Column(String, nullable=False, default="OPEN", index=True) # OPEN, CLOSED + + # Deposited capital (the PnL baseline) and what the position currently holds. These + # move together on every add and remove — see the CLMM repository for why letting + # them drift reports a loss or gain of exactly the amount transacted. + initial_base_token_amount = Column(Numeric(precision=30, scale=18), nullable=True) + initial_quote_token_amount = Column(Numeric(precision=30, scale=18), nullable=True) + base_token_amount = Column(Numeric(precision=30, scale=18), nullable=False, default=0) + quote_token_amount = Column(Numeric(precision=30, scale=18), nullable=False, default=0) + lp_token_amount = Column(Numeric(precision=30, scale=18), nullable=True) + + entry_price = Column(Numeric(precision=30, scale=18), nullable=True) # base-weighted across adds + current_price = Column(Numeric(precision=30, scale=18), nullable=True) + + last_updated = Column(TIMESTAMP(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) + + +class GatewayAMMEvent(Base): + """One AMM liquidity write — the AMM history, for every connector. + + Fungible-LP AMMs have only this: no position identity to key a row on, so holdings + come from Gateway live and cost basis reconstructs from the log. Meteora DAMM v2 + positions are NFTs and additionally get a GatewayAMMPosition row, which these events + reference by position_address. + """ + __tablename__ = "gateway_amm_events" + + id = Column(Integer, primary_key=True, index=True) + + transaction_hash = Column(String, nullable=False, index=True) + timestamp = Column(TIMESTAMP(timezone=True), server_default=func.now(), nullable=False, index=True) + + # Venue. No foreign key: there is no AMM positions table to point at. + connector = Column(String, nullable=False, index=True) + network = Column(String, nullable=False, index=True) # chain-network format + wallet_address = Column(String, nullable=False, index=True) + pool_address = Column(String, nullable=False, index=True) + # Meteora DAMM v2 positions are NFTs; fungible-LP AMMs leave this null. + position_address = Column(String, nullable=True, index=True) + + event_type = Column(String, nullable=False, index=True) # ADD_LIQUIDITY, REMOVE_LIQUIDITY, CREATE_POOL + + base_token_amount = Column(Numeric(precision=30, scale=18), nullable=True) + quote_token_amount = Column(Numeric(precision=30, scale=18), nullable=True) + + # Pool price when the write landed (quote per base). Without it a fungible-LP AMM has + # no cost basis anywhere: those connectors get no position row, so this column is the + # only record of what price the capital went in or out at. + price = Column(Numeric(precision=30, scale=18), nullable=True) + + gas_fee = Column(Numeric(precision=30, scale=18), nullable=True) + gas_token = Column(String, nullable=True) + + status = Column(String, nullable=False, default="SUBMITTED", index=True) # SUBMITTED, CONFIRMED, FAILED + error_message = Column(Text, nullable=True) + + class ControllerPerformanceSnapshot(Base): """Periodic snapshot of controller performance and custom_info from bots.""" __tablename__ = "controller_performance_snapshots" diff --git a/database/repositories/__init__.py b/database/repositories/__init__.py index ab9be398..11d19f88 100644 --- a/database/repositories/__init__.py +++ b/database/repositories/__init__.py @@ -3,6 +3,7 @@ from .controller_performance_repository import ControllerPerformanceRepository from .executor_repository import ExecutorRepository from .funding_repository import FundingRepository +from .gateway_amm_repository import GatewayAMMRepository from .gateway_clmm_repository import GatewayCLMMRepository from .gateway_swap_repository import GatewaySwapRepository from .order_repository import OrderRepository @@ -18,4 +19,5 @@ "TradeRepository", "GatewaySwapRepository", "GatewayCLMMRepository", + "GatewayAMMRepository", ] diff --git a/database/repositories/gateway_amm_repository.py b/database/repositories/gateway_amm_repository.py new file mode 100644 index 00000000..c3ca0941 --- /dev/null +++ b/database/repositories/gateway_amm_repository.py @@ -0,0 +1,226 @@ +"""Persistence for AMM liquidity writes and Meteora DAMM v2 positions.""" +import logging +from datetime import datetime, timezone +from decimal import Decimal +from typing import Dict, List, Optional + +from sqlalchemy import desc, select +from sqlalchemy.ext.asyncio import AsyncSession + +from database.models import GatewayAMMEvent, GatewayAMMPosition + +logger = logging.getLogger(__name__) + +# Connectors whose AMM positions are NFTs with their own address. Everything else is +# fungible LP: no position identity, so events only. +NFT_POSITION_CONNECTORS = {"meteora"} + + +def has_nft_positions(connector: str) -> bool: + """Whether this AMM connector's positions are individually addressable.""" + return connector.split("/")[0].lower() in NFT_POSITION_CONNECTORS + + +class GatewayAMMRepository: + """Read/write access to the AMM event log and DAMM v2 position rows.""" + + def __init__(self, session: AsyncSession): + self.session = session + + # ---------------------------- Positions ---------------------------- + + async def get_position_by_address(self, position_address: str) -> Optional[GatewayAMMPosition]: + result = await self.session.execute( + select(GatewayAMMPosition).where(GatewayAMMPosition.position_address == position_address) + ) + return result.scalar_one_or_none() + + async def get_open_position_addresses(self, wallet_address: str, pool_address: str) -> set: + """Addresses this API already tracks for a wallet/pool, used to spot a new one.""" + result = await self.session.execute( + select(GatewayAMMPosition.position_address).where( + GatewayAMMPosition.wallet_address == wallet_address, + GatewayAMMPosition.pool_address == pool_address, + ) + ) + return set(result.scalars().all()) + + async def create_position(self, position_data: Dict) -> GatewayAMMPosition: + position = GatewayAMMPosition(**position_data) + self.session.add(position) + await self.session.flush() + return position + + async def add_to_position_amounts( + self, + position_address: str, + base_delta: Decimal, + quote_delta: Decimal, + entry_price: Optional[Decimal] = None, + ) -> Optional[GatewayAMMPosition]: + """Book added liquidity: raise the PnL baseline and the held amounts together. + + Mirrors the CLMM repository, for the same reason — raising one without the other + makes the row report a gain or loss of exactly the amount deposited. Given a + price, entry becomes the base-weighted average so capital added later is valued + at the price it actually entered at. + """ + position = await self.get_position_by_address(position_address) + if position: + old_base = float(position.initial_base_token_amount or 0) + new_base = old_base + float(base_delta) + + if entry_price is not None and float(base_delta) > 0 and new_base > 0: + old_entry = float(position.entry_price) if position.entry_price is not None else None + position.entry_price = ( + (old_entry * old_base + float(entry_price) * float(base_delta)) / new_base + if old_entry is not None else float(entry_price) + ) + + position.initial_base_token_amount = new_base + position.initial_quote_token_amount = float(position.initial_quote_token_amount or 0) + float(quote_delta) + position.base_token_amount = float(position.base_token_amount or 0) + float(base_delta) + position.quote_token_amount = float(position.quote_token_amount or 0) + float(quote_delta) + await self.session.flush() + return position + + async def subtract_from_position_amounts( + self, + position_address: str, + base_delta: Decimal, + quote_delta: Decimal, + ) -> Optional[GatewayAMMPosition]: + """Book removed liquidity: lower the baseline and the held amounts together. + + entry_price is untouched — a pro-rata removal changes how much remains, not the + average price it was entered at. Floors at 0 so a remove larger than the recorded + holding cannot drive amounts negative. + """ + position = await self.get_position_by_address(position_address) + if position: + position.initial_base_token_amount = max( + 0.0, float(position.initial_base_token_amount or 0) - float(base_delta)) + position.initial_quote_token_amount = max( + 0.0, float(position.initial_quote_token_amount or 0) - float(quote_delta)) + position.base_token_amount = max(0.0, float(position.base_token_amount or 0) - float(base_delta)) + position.quote_token_amount = max(0.0, float(position.quote_token_amount or 0) - float(quote_delta)) + await self.session.flush() + return position + + async def close_position(self, position_address: str) -> Optional[GatewayAMMPosition]: + """Mark a position closed. DAMM v2 closes when its liquidity is fully removed.""" + position = await self.get_position_by_address(position_address) + if position: + position.status = "CLOSED" + position.closed_at = datetime.now(timezone.utc) + await self.session.flush() + return position + + async def search_positions( + self, + connector: Optional[str] = None, + network: Optional[str] = None, + wallet_address: Optional[str] = None, + pool_address: Optional[str] = None, + status: Optional[str] = None, + limit: int = 50, + offset: int = 0, + ) -> List[GatewayAMMPosition]: + query = select(GatewayAMMPosition) + for column, value in ( + (GatewayAMMPosition.connector, connector), + (GatewayAMMPosition.network, network), + (GatewayAMMPosition.wallet_address, wallet_address), + (GatewayAMMPosition.pool_address, pool_address), + (GatewayAMMPosition.status, status), + ): + if value is not None: + query = query.where(column == value) + query = query.order_by(desc(GatewayAMMPosition.created_at)).limit(limit).offset(offset) + result = await self.session.execute(query) + return list(result.scalars().all()) + + @staticmethod + def position_to_dict(position: GatewayAMMPosition) -> Dict: + def num(value): + return float(value) if value is not None else None + + return { + "position_address": position.position_address, + "pool_address": position.pool_address, + "connector": position.connector, + "network": position.network, + "wallet_address": position.wallet_address, + "trading_pair": position.trading_pair, + "base_token": position.base_token, + "quote_token": position.quote_token, + "status": position.status, + "created_at": position.created_at.isoformat() if position.created_at else None, + "closed_at": position.closed_at.isoformat() if position.closed_at else None, + "initial_base_token_amount": num(position.initial_base_token_amount), + "initial_quote_token_amount": num(position.initial_quote_token_amount), + "base_token_amount": num(position.base_token_amount), + "quote_token_amount": num(position.quote_token_amount), + "lp_token_amount": num(position.lp_token_amount), + "entry_price": num(position.entry_price), + "current_price": num(position.current_price), + } + + # ---------------------------- Events ---------------------------- + + async def create_event(self, event_data: Dict) -> GatewayAMMEvent: + """Record one AMM write.""" + event = GatewayAMMEvent(**event_data) + self.session.add(event) + await self.session.flush() + return event + + async def search_events( + self, + connector: Optional[str] = None, + network: Optional[str] = None, + wallet_address: Optional[str] = None, + pool_address: Optional[str] = None, + event_type: Optional[str] = None, + status: Optional[str] = None, + limit: int = 50, + offset: int = 0, + ) -> List[GatewayAMMEvent]: + """Most recent events first, narrowed by whichever filters are given.""" + query = select(GatewayAMMEvent) + for column, value in ( + (GatewayAMMEvent.connector, connector), + (GatewayAMMEvent.network, network), + (GatewayAMMEvent.wallet_address, wallet_address), + (GatewayAMMEvent.pool_address, pool_address), + (GatewayAMMEvent.event_type, event_type), + (GatewayAMMEvent.status, status), + ): + if value is not None: + query = query.where(column == value) + + query = query.order_by(desc(GatewayAMMEvent.timestamp)).limit(limit).offset(offset) + result = await self.session.execute(query) + return list(result.scalars().all()) + + @staticmethod + def event_to_dict(event: GatewayAMMEvent) -> Dict: + return { + "transaction_hash": event.transaction_hash, + "timestamp": event.timestamp.isoformat() if event.timestamp else None, + "connector": event.connector, + "network": event.network, + "wallet_address": event.wallet_address, + "pool_address": event.pool_address, + "position_address": event.position_address, + "event_type": event.event_type, + "base_token_amount": (float(event.base_token_amount) + if event.base_token_amount is not None else None), + "quote_token_amount": (float(event.quote_token_amount) + if event.quote_token_amount is not None else None), + "price": float(event.price) if event.price is not None else None, + "gas_fee": float(event.gas_fee) if event.gas_fee is not None else None, + "gas_token": event.gas_token, + "status": event.status, + "error_message": event.error_message, + } diff --git a/database/repositories/gateway_clmm_repository.py b/database/repositories/gateway_clmm_repository.py index c5281767..9003cfb2 100644 --- a/database/repositories/gateway_clmm_repository.py +++ b/database/repositories/gateway_clmm_repository.py @@ -85,8 +85,12 @@ async def update_position_fees( await self.session.flush() return position - async def close_position(self, position_address: str) -> Optional[GatewayCLMMPosition]: - """Mark position as closed.""" + async def close_position( + self, + position_address: str, + position_rent_refunded: Optional[Decimal] = None + ) -> Optional[GatewayCLMMPosition]: + """Mark position as closed, recording the rent the chain gave back.""" result = await self.session.execute( select(GatewayCLMMPosition).where(GatewayCLMMPosition.position_address == position_address) ) @@ -94,6 +98,8 @@ async def close_position(self, position_address: str) -> Optional[GatewayCLMMPos if position: position.status = "CLOSED" position.closed_at = datetime.now(timezone.utc) + if position_rent_refunded is not None: + position.position_rent_refunded = position_rent_refunded await self.session.flush() return position @@ -114,24 +120,77 @@ async def reopen_position(self, position_address: str) -> Optional[GatewayCLMMPo await self.session.flush() return position - async def add_to_initial_amounts( + async def subtract_from_position_amounts( self, position_address: str, base_delta: Decimal, quote_delta: Decimal ) -> Optional[GatewayCLMMPosition]: - """Raise the PnL baseline when liquidity is ADDED to an existing position. + """Book removed liquidity: lower both the PnL baseline and the held amounts. - Without this, pnl_summary compares post-add value against the original - deposit only and overstates PnL by exactly the added capital. + The mirror of add_to_position_amounts, and the same invariant. Lowering only + the held amounts leaves withdrawn capital counted as a loss of exactly the + amount withdrawn. entry_price is deliberately untouched: a pro-rata removal + changes how much remains, not the average price it was entered at. """ result = await self.session.execute( select(GatewayCLMMPosition).where(GatewayCLMMPosition.position_address == position_address) ) position = result.scalar_one_or_none() if position: - position.initial_base_token_amount = float(position.initial_base_token_amount or 0) + float(base_delta) + # Floor at 0: a remove reported larger than the recorded holding (stale + # cache, or liquidity added outside this API) must not drive it negative. + position.initial_base_token_amount = max( + 0.0, float(position.initial_base_token_amount or 0) - float(base_delta)) + position.initial_quote_token_amount = max( + 0.0, float(position.initial_quote_token_amount or 0) - float(quote_delta)) + position.base_token_amount = max(0.0, float(position.base_token_amount or 0) - float(base_delta)) + position.quote_token_amount = max(0.0, float(position.quote_token_amount or 0) - float(quote_delta)) + await self.session.flush() + return position + + async def add_to_position_amounts( + self, + position_address: str, + base_delta: Decimal, + quote_delta: Decimal, + entry_price: Optional[Decimal] = None + ) -> Optional[GatewayCLMMPosition]: + """Book added liquidity: raise both the PnL baseline and the held amounts. + + Both move together or the row contradicts itself. Raising only the baseline + leaves a position recorded as holding less than it was given, with no removal + or fee collection to explain the gap — pnl_summary then reports a loss of + exactly the added capital. Raising only the held amounts overstates PnL by + the same figure. Nothing else refreshes these: _refresh_position_data runs + only when a caller passes search_positions(refresh=True). + + `entry_price` is the pool price at the moment of the add. Given it, the stored + entry price becomes the base-weighted average of the old basis and the new + capital — without it, capital deposited today is valued at the price the + position opened at, and the cost basis is wrong by the drift between them. + """ + result = await self.session.execute( + select(GatewayCLMMPosition).where(GatewayCLMMPosition.position_address == position_address) + ) + position = result.scalar_one_or_none() + if position: + old_base = float(position.initial_base_token_amount or 0) + new_base = old_base + float(base_delta) + + if entry_price is not None and float(base_delta) > 0 and new_base > 0: + old_entry = float(position.entry_price) if position.entry_price is not None else None + if old_entry is not None: + position.entry_price = (old_entry * old_base + float(entry_price) * float(base_delta)) / new_base + else: + # No basis to weight against (e.g. a discovered position): the + # add's own price is the best available. + position.entry_price = float(entry_price) + + position.initial_base_token_amount = new_base position.initial_quote_token_amount = float(position.initial_quote_token_amount or 0) + float(quote_delta) + position.base_token_amount = float(position.base_token_amount or 0) + float(base_delta) + position.quote_token_amount = float(position.quote_token_amount or 0) + float(quote_delta) await self.session.flush() return position @@ -415,6 +474,8 @@ def position_to_dict(self, position: GatewayCLMMPosition) -> Dict: "initial_quote_token_amount": (float(position.initial_quote_token_amount) if position.initial_quote_token_amount is not None else None), "position_rent": float(position.position_rent) if position.position_rent is not None else None, + "position_rent_refunded": (float(position.position_rent_refunded) + if position.position_rent_refunded is not None else None), "base_token_amount": float(position.base_token_amount), "quote_token_amount": float(position.quote_token_amount), "in_range": position.in_range, diff --git a/gateway-openapi.json b/gateway-openapi.json new file mode 100644 index 00000000..57f2ae65 --- /dev/null +++ b/gateway-openapi.json @@ -0,0 +1,6948 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Hummingbot Gateway", + "description": "API endpoints for interacting with DEXs and blockchains", + "version": "dev-2.17.0" + }, + "components": { + "parameters": { + "queryExample": { + "in": "query", + "name": "example", + "schema": { + "type": "object" + } + } + }, + "schemas": {} + }, + "paths": { + "/config/": { + "get": { + "tags": [ + "/config" + ], + "description": "Get configuration settings. Returns all configurations if no parameters are specified. Use namespace to get a specific config (e.g., server, ethereum-mainnet, solana-mainnet-beta, uniswap).", + "parameters": [ + { + "schema": { + "type": "string" + }, + "examples": { + "server": { + "value": "server" + }, + "ethereum-mainnet": { + "value": "ethereum-mainnet" + }, + "solana-mainnet-beta": { + "value": "solana-mainnet-beta" + }, + "uniswap": { + "value": "uniswap" + } + }, + "in": "query", + "name": "namespace", + "required": false, + "description": "Optional configuration namespace (e.g., \"server\", \"ethereum-mainnet\", \"solana-mainnet-beta\", \"uniswap\")" + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + } + }, + "/config/update": { + "post": { + "tags": [ + "/config" + ], + "description": "Update a specific configuration value", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "namespace": { + "description": "Configuration namespace (e.g., \"server\", \"ethereum-mainnet\", \"solana-mainnet-beta\", \"uniswap\")", + "type": "string", + "example": "server" + }, + "path": { + "description": "Configuration path within the namespace (e.g., \"nodeURL\", \"manualGasPrice\")", + "type": "string", + "example": "nodeURL" + }, + "value": { + "description": "Configuration value", + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "object", + "properties": {} + }, + { + "type": "array", + "items": {} + } + ] + } + }, + "required": [ + "namespace", + "path", + "value" + ] + }, + "examples": { + "example1": { + "value": { + "namespace": "solana-mainnet-beta", + "path": "maxFee", + "value": 0.01 + } + }, + "example2": { + "value": { + "namespace": "ethereum-mainnet", + "path": "nodeURL", + "value": "https://eth-mainnet.g.alchemy.com/v2/your-api-key" + } + }, + "example3": { + "value": { + "namespace": "ethereum-mainnet", + "path": "gasLimitTransaction", + "value": 3000000 + } + }, + "example4": { + "value": { + "namespace": "solana-devnet", + "path": "retryCount", + "value": 5 + } + }, + "example5": { + "value": { + "namespace": "server", + "path": "port", + "value": 15888 + } + } + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "description": "Status message", + "type": "string" + } + }, + "required": [ + "message" + ] + } + } + } + } + } + } + }, + "/config/chains": { + "get": { + "tags": [ + "/config" + ], + "description": "Returns a list of available blockchain networks supported by Gateway.", + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "chains": { + "type": "array", + "items": { + "type": "object", + "properties": { + "chain": { + "type": "string" + }, + "networks": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "chain", + "networks" + ] + } + } + }, + "required": [ + "chains" + ] + } + } + } + } + } + } + }, + "/config/connectors": { + "get": { + "tags": [ + "/config" + ], + "description": "Returns a list of available DEX connectors and their supported blockchain networks.", + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "connectors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "trading_types": { + "type": "array", + "items": { + "type": "string" + } + }, + "chain": { + "type": "string" + }, + "networks": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "name", + "trading_types", + "chain", + "networks" + ] + } + } + }, + "required": [ + "connectors" + ] + } + } + } + } + } + } + }, + "/config/namespaces": { + "get": { + "tags": [ + "/config" + ], + "description": "Returns a list of all configuration namespaces available in Gateway.", + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "namespaces": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "namespaces" + ] + } + } + } + } + } + } + }, + "/wallet/": { + "get": { + "tags": [ + "/wallet" + ], + "description": "Get all wallets across different chains", + "parameters": [ + { + "schema": { + "default": true, + "type": "boolean" + }, + "in": "query", + "name": "showHardware", + "required": false + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "chain": { + "description": "Blockchain name", + "type": "string", + "example": "solana" + }, + "walletAddresses": { + "description": "List of regular wallet addresses with private keys", + "type": "array", + "items": { + "description": "Wallet address (Ethereum format: 0x... or Solana format: base58)", + "type": "string" + } + }, + "hardwareWalletAddresses": { + "description": "List of hardware wallet addresses (Ledger)", + "type": "array", + "items": { + "description": "Wallet address (Ethereum format: 0x... or Solana format: base58)", + "type": "string" + } + } + }, + "required": [ + "chain", + "walletAddresses" + ] + } + } + } + } + } + } + } + }, + "/wallet/add": { + "post": { + "tags": [ + "/wallet" + ], + "description": "Add a new wallet using a private key", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "chain": { + "description": "Blockchain to add wallet to", + "enum": [ + "ethereum", + "solana" + ], + "type": "string", + "example": "solana" + }, + "privateKey": { + "description": "Private key for the wallet", + "type": "string", + "example": "" + }, + "setDefault": { + "description": "Set this wallet as the default for the chain", + "default": false, + "type": "boolean" + } + }, + "required": [ + "chain", + "privateKey" + ] + }, + "example": { + "chain": "solana", + "privateKey": "", + "setDefault": true + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "address": { + "description": "The wallet address that was added", + "type": "string" + } + }, + "required": [ + "address" + ] + } + } + } + } + } + } + }, + "/wallet/add-hardware": { + "post": { + "tags": [ + "/wallet" + ], + "description": "Add a hardware wallet", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "chain": { + "description": "Blockchain for hardware wallet", + "enum": [ + "ethereum", + "solana" + ], + "default": "solana", + "type": "string", + "example": "solana" + }, + "address": { + "description": "Hardware wallet address to add (must exist on connected Ledger device)", + "type": "string" + }, + "setDefault": { + "description": "Set this wallet as the default for the chain", + "default": false, + "type": "boolean" + } + }, + "required": [ + "chain", + "address" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "address": { + "description": "The hardware wallet address that was added", + "type": "string" + }, + "publicKey": { + "description": "Public key of the hardware wallet", + "type": "string" + }, + "derivationPath": { + "description": "BIP32/BIP44 derivation path used", + "type": "string" + }, + "message": { + "description": "Success message", + "type": "string" + } + }, + "required": [ + "address", + "publicKey", + "derivationPath", + "message" + ] + } + } + } + } + } + } + }, + "/wallet/remove": { + "delete": { + "tags": [ + "/wallet" + ], + "description": "Remove a wallet by its address (automatically detects wallet type)", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "chain": { + "description": "Blockchain to remove wallet from", + "enum": [ + "ethereum", + "solana" + ], + "type": "string", + "example": "solana" + }, + "address": { + "description": "Wallet address to remove", + "type": "string" + } + }, + "required": [ + "chain", + "address" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "description": "Success message indicating wallet type removed", + "type": "string" + } + }, + "required": [ + "message" + ] + } + } + } + } + } + } + }, + "/wallet/setDefault": { + "post": { + "tags": [ + "/wallet" + ], + "description": "Set a wallet as default for a specific chain", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "chain": { + "description": "Blockchain to set default wallet for", + "enum": [ + "ethereum", + "solana" + ], + "type": "string", + "example": "solana" + }, + "address": { + "description": "Wallet address to set as default", + "type": "string" + } + }, + "required": [ + "chain", + "address" + ] + }, + "examples": { + "example1": { + "value": { + "chain": "ethereum", + "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f2BDf8" + } + }, + "example2": { + "value": { + "chain": "solana", + "address": "7UX2i7SucgLMQcfZ75s3VXmZZY4YRUyJN9X1RgfMoDUi" + } + } + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "description": "Success message", + "type": "string" + }, + "chain": { + "description": "Chain name", + "type": "string" + }, + "address": { + "description": "Default wallet address", + "type": "string" + } + }, + "required": [ + "message", + "chain", + "address" + ] + } + } + } + } + } + } + }, + "/tokens/{symbolOrAddress}": { + "get": { + "tags": [ + "/tokens" + ], + "description": "Get a specific token by symbol or address", + "parameters": [ + { + "schema": { + "type": "string" + }, + "examples": { + "ethereum": { + "value": "ethereum" + }, + "solana": { + "value": "solana" + } + }, + "in": "query", + "name": "chain", + "required": true, + "description": "Blockchain network (e.g., ethereum, solana)" + }, + { + "schema": { + "type": "string" + }, + "examples": { + "mainnet": { + "value": "mainnet" + }, + "mainnet-beta": { + "value": "mainnet-beta" + }, + "devnet": { + "value": "devnet" + } + }, + "in": "query", + "name": "network", + "required": true, + "description": "Network name (e.g., mainnet, mainnet-beta)" + }, + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "symbolOrAddress", + "required": true, + "description": "Token symbol or address" + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "token": { + "type": "object", + "properties": { + "chainId": { + "description": "The chain ID", + "type": "number", + "example": 1 + }, + "name": { + "description": "The full name of the token", + "type": "string", + "example": "USD Coin" + }, + "symbol": { + "description": "The token symbol", + "type": "string", + "example": "USDC" + }, + "address": { + "description": "The token contract address", + "type": "string", + "example": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + }, + "decimals": { + "description": "The number of decimals the token uses", + "minimum": 0, + "maximum": 255, + "type": "number", + "example": 6 + } + }, + "required": [ + "name", + "symbol", + "address", + "decimals" + ] + }, + "chain": { + "type": "string" + }, + "network": { + "type": "string" + } + }, + "required": [ + "token", + "chain", + "network" + ] + } + } + } + } + } + } + }, + "/tokens/find/{address}": { + "get": { + "tags": [ + "/tokens" + ], + "description": "Get token information with market data from GeckoTerminal by address", + "parameters": [ + { + "schema": { + "type": "string" + }, + "examples": { + "solana-mainnet-beta": { + "value": "solana-mainnet-beta" + }, + "ethereum-mainnet": { + "value": "ethereum-mainnet" + }, + "ethereum-base": { + "value": "ethereum-base" + }, + "ethereum-polygon": { + "value": "ethereum-polygon" + } + }, + "in": "query", + "name": "chainNetwork", + "required": true, + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" + }, + { + "schema": { + "type": "string" + }, + "examples": { + "So11111111111111111111111111111111111111112": { + "value": "So11111111111111111111111111111111111111112" + }, + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48": { + "value": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + } + }, + "in": "path", + "name": "address", + "required": true, + "description": "Token contract address" + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "chainId": { + "description": "The chain ID", + "type": "number", + "example": 1 + }, + "name": { + "description": "The full name of the token", + "type": "string", + "example": "USD Coin" + }, + "symbol": { + "description": "The token symbol", + "type": "string", + "example": "USDC" + }, + "address": { + "description": "The token contract address", + "type": "string", + "example": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + }, + "decimals": { + "description": "The number of decimals the token uses", + "minimum": 0, + "maximum": 255, + "type": "number", + "example": 6 + } + }, + "required": [ + "name", + "symbol", + "address", + "decimals" + ] + } + } + } + } + } + } + }, + "/tokens/": { + "get": { + "tags": [ + "/tokens" + ], + "description": "List tokens from token lists with optional filtering", + "parameters": [ + { + "schema": { + "type": "string" + }, + "examples": { + "ethereum": { + "value": "ethereum" + }, + "solana": { + "value": "solana" + } + }, + "in": "query", + "name": "chain", + "required": false, + "description": "Blockchain network (e.g., ethereum, solana)" + }, + { + "schema": { + "type": "string" + }, + "examples": { + "mainnet": { + "value": "mainnet" + }, + "mainnet-beta": { + "value": "mainnet-beta" + }, + "devnet": { + "value": "devnet" + } + }, + "in": "query", + "name": "network", + "required": false, + "description": "Network name (e.g., mainnet, mainnet-beta)" + }, + { + "schema": { + "type": "string" + }, + "examples": { + "USDC": { + "value": "USDC" + }, + "USD": { + "value": "USD" + } + }, + "in": "query", + "name": "search", + "required": false, + "description": "Search term for filtering tokens by symbol or name" + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tokens": { + "type": "array", + "items": { + "type": "object", + "properties": { + "chainId": { + "description": "The chain ID", + "type": "number", + "example": 1 + }, + "name": { + "description": "The full name of the token", + "type": "string", + "example": "USD Coin" + }, + "symbol": { + "description": "The token symbol", + "type": "string", + "example": "USDC" + }, + "address": { + "description": "The token contract address", + "type": "string", + "example": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + }, + "decimals": { + "description": "The number of decimals the token uses", + "minimum": 0, + "maximum": 255, + "type": "number", + "example": 6 + } + }, + "required": [ + "name", + "symbol", + "address", + "decimals" + ] + } + } + }, + "required": [ + "tokens" + ] + } + } + } + } + } + }, + "post": { + "tags": [ + "/tokens" + ], + "description": "Add a new token to a token list", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "chain": { + "description": "Blockchain network (e.g., ethereum, solana)", + "type": "string", + "example": "ethereum" + }, + "network": { + "description": "Network name (e.g., mainnet, mainnet-beta)", + "type": "string", + "example": "mainnet" + }, + "token": { + "type": "object", + "properties": { + "chainId": { + "description": "The chain ID", + "type": "number", + "example": 1 + }, + "name": { + "description": "The full name of the token", + "type": "string", + "example": "USD Coin" + }, + "symbol": { + "description": "The token symbol", + "type": "string", + "example": "USDC" + }, + "address": { + "description": "The token contract address", + "type": "string", + "example": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + }, + "decimals": { + "description": "The number of decimals the token uses", + "minimum": 0, + "maximum": 255, + "type": "number", + "example": 6 + } + }, + "required": [ + "name", + "symbol", + "address", + "decimals" + ] + } + }, + "required": [ + "chain", + "network", + "token" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "description": "Success message", + "type": "string" + } + }, + "required": [ + "message" + ] + } + } + } + } + } + } + }, + "/tokens/save/{address}": { + "post": { + "tags": [ + "/tokens" + ], + "description": "Find token from GeckoTerminal and save it to the token list", + "parameters": [ + { + "schema": { + "type": "string" + }, + "examples": { + "solana-mainnet-beta": { + "value": "solana-mainnet-beta" + }, + "ethereum-mainnet": { + "value": "ethereum-mainnet" + }, + "ethereum-base": { + "value": "ethereum-base" + }, + "ethereum-polygon": { + "value": "ethereum-polygon" + } + }, + "in": "query", + "name": "chainNetwork", + "required": true, + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" + }, + { + "schema": { + "type": "string" + }, + "examples": { + "So11111111111111111111111111111111111111112": { + "value": "So11111111111111111111111111111111111111112" + }, + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48": { + "value": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + } + }, + "in": "path", + "name": "address", + "required": true, + "description": "Token contract address" + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "token": { + "type": "object", + "properties": { + "chainId": { + "description": "The chain ID", + "type": "number", + "example": 1 + }, + "name": { + "description": "The full name of the token", + "type": "string", + "example": "USD Coin" + }, + "symbol": { + "description": "The token symbol", + "type": "string", + "example": "USDC" + }, + "address": { + "description": "The token contract address", + "type": "string", + "example": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + }, + "decimals": { + "description": "The number of decimals the token uses", + "minimum": 0, + "maximum": 255, + "type": "number", + "example": 6 + } + }, + "required": [ + "name", + "symbol", + "address", + "decimals" + ] + } + }, + "required": [ + "message", + "token" + ] + } + } + } + } + } + } + }, + "/tokens/{address}": { + "delete": { + "tags": [ + "/tokens" + ], + "description": "Remove a token from a token list by address", + "parameters": [ + { + "schema": { + "type": "string" + }, + "examples": { + "ethereum": { + "value": "ethereum" + }, + "solana": { + "value": "solana" + } + }, + "in": "query", + "name": "chain", + "required": true, + "description": "Blockchain network (e.g., ethereum, solana)" + }, + { + "schema": { + "type": "string" + }, + "examples": { + "mainnet": { + "value": "mainnet" + }, + "mainnet-beta": { + "value": "mainnet-beta" + }, + "devnet": { + "value": "devnet" + } + }, + "in": "query", + "name": "network", + "required": true, + "description": "Network name (e.g., mainnet, mainnet-beta)" + }, + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "address", + "required": true, + "description": "Token address to remove" + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "description": "Success message", + "type": "string" + } + }, + "required": [ + "message" + ] + } + } + } + } + } + } + }, + "/pools/{tradingPair}": { + "get": { + "tags": [ + "/pools" + ], + "description": "Get a specific pool by trading pair", + "parameters": [ + { + "schema": { + "type": "string" + }, + "examples": { + "solana": { + "value": "solana" + }, + "ethereum": { + "value": "ethereum" + } + }, + "in": "query", + "name": "chain", + "required": true, + "description": "Blockchain chain (solana, ethereum)" + }, + { + "schema": { + "default": "mainnet-beta", + "type": "string" + }, + "examples": { + "mainnet-beta": { + "value": "mainnet-beta" + }, + "mainnet": { + "value": "mainnet" + } + }, + "in": "query", + "name": "network", + "required": true, + "description": "Network name (mainnet, mainnet-beta, etc)" + }, + { + "schema": { + "enum": [ + "amm", + "clmm" + ], + "type": "string" + }, + "examples": { + "amm": { + "value": "amm" + }, + "clmm": { + "value": "clmm" + } + }, + "in": "query", + "name": "type", + "required": true, + "description": "Pool type" + }, + { + "schema": { + "type": "string" + }, + "examples": { + "raydium": { + "value": "raydium" + }, + "meteora": { + "value": "meteora" + }, + "uniswap": { + "value": "uniswap" + }, + "orca": { + "value": "orca" + } + }, + "in": "query", + "name": "connector", + "required": false, + "description": "Optional: filter by connector (raydium, meteora, uniswap, orca)" + }, + { + "schema": { + "type": "string" + }, + "examples": { + "SOL-USDC": { + "value": "SOL-USDC" + }, + "ETH-USDC": { + "value": "ETH-USDC" + } + }, + "in": "path", + "name": "tradingPair", + "required": true, + "description": "Trading pair (e.g., SOL-USDC, ETH-USDC)" + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "connector": { + "description": "Connector name (raydium, uniswap, orca, etc)", + "type": "string", + "example": "raydium" + }, + "type": { + "description": "Pool type", + "enum": [ + "clmm", + "amm" + ], + "type": "string", + "example": "clmm" + }, + "network": { + "type": "string" + }, + "baseSymbol": { + "type": "string" + }, + "quoteSymbol": { + "type": "string" + }, + "baseTokenAddress": { + "type": "string" + }, + "quoteTokenAddress": { + "type": "string" + }, + "feePct": { + "type": "number" + }, + "address": { + "type": "string" + } + }, + "required": [ + "connector", + "type", + "network", + "baseSymbol", + "quoteSymbol", + "baseTokenAddress", + "quoteTokenAddress", + "feePct", + "address" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/pools/find/{address}": { + "get": { + "tags": [ + "/pools" + ], + "description": "Get detailed pool information by address from GeckoTerminal", + "parameters": [ + { + "schema": { + "type": "string" + }, + "examples": { + "solana-mainnet-beta": { + "value": "solana-mainnet-beta" + }, + "ethereum-mainnet": { + "value": "ethereum-mainnet" + }, + "ethereum-base": { + "value": "ethereum-base" + }, + "ethereum-polygon": { + "value": "ethereum-polygon" + } + }, + "in": "query", + "name": "chainNetwork", + "required": true, + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" + }, + { + "schema": { + "type": "string" + }, + "examples": { + "58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2": { + "value": "58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2" + }, + "0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640": { + "value": "0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640" + } + }, + "in": "path", + "name": "address", + "required": true, + "description": "Pool contract address" + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "connector": { + "description": "Connector name (raydium, uniswap, orca, etc)", + "type": "string", + "example": "raydium" + }, + "type": { + "description": "Pool type", + "enum": [ + "clmm", + "amm" + ], + "type": "string", + "example": "clmm" + }, + "network": { + "type": "string" + }, + "baseSymbol": { + "type": "string" + }, + "quoteSymbol": { + "type": "string" + }, + "baseTokenAddress": { + "type": "string" + }, + "quoteTokenAddress": { + "type": "string" + }, + "feePct": { + "type": "number" + }, + "address": { + "type": "string" + } + }, + "required": [ + "connector", + "type", + "network", + "baseSymbol", + "quoteSymbol", + "baseTokenAddress", + "quoteTokenAddress", + "feePct", + "address" + ] + } + } + } + } + } + } + }, + "/pools/find": { + "get": { + "tags": [ + "/pools" + ], + "description": "Find pools for a token pair from GeckoTerminal", + "parameters": [ + { + "schema": { + "type": "string" + }, + "examples": { + "solana-mainnet-beta": { + "value": "solana-mainnet-beta" + }, + "ethereum-mainnet": { + "value": "ethereum-mainnet" + }, + "ethereum-base": { + "value": "ethereum-base" + }, + "ethereum-polygon": { + "value": "ethereum-polygon" + } + }, + "in": "query", + "name": "chainNetwork", + "required": true, + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" + }, + { + "schema": { + "type": "string" + }, + "examples": { + "raydium": { + "value": "raydium" + }, + "meteora": { + "value": "meteora" + }, + "uniswap": { + "value": "uniswap" + }, + "pancakeswap": { + "value": "pancakeswap" + }, + "pancakeswap-sol": { + "value": "pancakeswap-sol" + }, + "orca": { + "value": "orca" + } + }, + "in": "query", + "name": "connector", + "required": false, + "description": "Filter by connector name (e.g., raydium, meteora, uniswap, pancakeswap, pancakeswap-sol)" + }, + { + "schema": { + "enum": [ + "clmm", + "amm" + ], + "default": "clmm", + "type": "string" + }, + "examples": { + "clmm": { + "value": "clmm" + }, + "amm": { + "value": "amm" + } + }, + "in": "query", + "name": "type", + "required": false, + "description": "Filter by pool type: clmm (v3-style concentrated liquidity) or amm (v2-style)" + }, + { + "schema": { + "type": "string" + }, + "examples": { + "SOL": { + "value": "SOL" + }, + "So11111111111111111111111111111111111111112": { + "value": "So11111111111111111111111111111111111111112" + }, + "USDC": { + "value": "USDC" + } + }, + "in": "query", + "name": "tokenA", + "required": false, + "description": "First token symbol or contract address (optional - for filtering by token pair)" + }, + { + "schema": { + "type": "string" + }, + "examples": { + "USDC": { + "value": "USDC" + }, + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v": { + "value": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" + }, + "SOL": { + "value": "SOL" + } + }, + "in": "query", + "name": "tokenB", + "required": false, + "description": "Second token symbol or contract address (optional - for filtering by token pair)" + }, + { + "schema": { + "minimum": 1, + "maximum": 10, + "default": 10, + "type": "number" + }, + "in": "query", + "name": "pages", + "required": false, + "description": "Number of pages to fetch from GeckoTerminal (1-10, default: 10)" + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "connector": { + "description": "Connector name (raydium, uniswap, orca, etc)", + "type": "string", + "example": "raydium" + }, + "type": { + "description": "Pool type", + "enum": [ + "clmm", + "amm" + ], + "type": "string", + "example": "clmm" + }, + "network": { + "type": "string" + }, + "baseSymbol": { + "type": "string" + }, + "quoteSymbol": { + "type": "string" + }, + "baseTokenAddress": { + "type": "string" + }, + "quoteTokenAddress": { + "type": "string" + }, + "feePct": { + "type": "number" + }, + "address": { + "type": "string" + } + }, + "required": [ + "connector", + "type", + "network", + "baseSymbol", + "quoteSymbol", + "baseTokenAddress", + "quoteTokenAddress", + "feePct", + "address" + ] + } + } + } + } + } + } + } + }, + "/pools/": { + "get": { + "tags": [ + "/pools" + ], + "description": "List all pools for a chain/network, optionally filtered by connector, type, or search term", + "parameters": [ + { + "schema": { + "type": "string" + }, + "examples": { + "solana": { + "value": "solana" + }, + "ethereum": { + "value": "ethereum" + } + }, + "in": "query", + "name": "chain", + "required": true, + "description": "Blockchain chain (solana, ethereum)" + }, + { + "schema": { + "type": "string" + }, + "examples": { + "mainnet-beta": { + "value": "mainnet-beta" + }, + "mainnet": { + "value": "mainnet" + }, + "base": { + "value": "base" + }, + "arbitrum": { + "value": "arbitrum" + } + }, + "in": "query", + "name": "network", + "required": true, + "description": "Network name (mainnet-beta, mainnet, base, etc)" + }, + { + "schema": { + "type": "string" + }, + "examples": { + "raydium": { + "value": "raydium" + }, + "meteora": { + "value": "meteora" + }, + "uniswap": { + "value": "uniswap" + }, + "orca": { + "value": "orca" + } + }, + "in": "query", + "name": "connector", + "required": false, + "description": "Optional: filter by connector (raydium, meteora, uniswap, orca)" + }, + { + "schema": { + "enum": [ + "clmm", + "amm" + ], + "type": "string" + }, + "examples": { + "clmm": { + "value": "clmm" + }, + "amm": { + "value": "amm" + } + }, + "in": "query", + "name": "type", + "required": false, + "description": "Optional: filter by pool type" + }, + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "search", + "required": false, + "description": "Optional: search by token symbol or address" + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "connector": { + "description": "Connector name (raydium, uniswap, orca, etc)", + "type": "string", + "example": "raydium" + }, + "type": { + "description": "Pool type", + "enum": [ + "clmm", + "amm" + ], + "type": "string", + "example": "clmm" + }, + "network": { + "type": "string" + }, + "baseSymbol": { + "type": "string" + }, + "quoteSymbol": { + "type": "string" + }, + "baseTokenAddress": { + "type": "string" + }, + "quoteTokenAddress": { + "type": "string" + }, + "feePct": { + "type": "number" + }, + "address": { + "type": "string" + } + }, + "required": [ + "connector", + "type", + "network", + "baseSymbol", + "quoteSymbol", + "baseTokenAddress", + "quoteTokenAddress", + "feePct", + "address" + ] + } + } + } + } + } + } + }, + "post": { + "tags": [ + "/pools" + ], + "description": "Add a new pool", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "chain": { + "description": "Blockchain chain (solana, ethereum)", + "type": "string", + "example": "solana" + }, + "connector": { + "description": "Connector (raydium, meteora, uniswap, orca)", + "type": "string", + "example": "raydium" + }, + "type": { + "description": "Pool type", + "enum": [ + "clmm", + "amm" + ], + "type": "string", + "example": "clmm" + }, + "network": { + "description": "Network name (mainnet, mainnet-beta, etc)", + "default": "mainnet-beta", + "type": "string", + "example": "mainnet-beta" + }, + "address": { + "description": "Pool contract address", + "type": "string" + }, + "baseSymbol": { + "description": "Base token symbol (optional - fetched automatically if not provided)", + "type": "string", + "example": "SOL" + }, + "quoteSymbol": { + "description": "Quote token symbol (optional - fetched automatically if not provided)", + "type": "string", + "example": "USDC" + }, + "baseTokenAddress": { + "description": "Base token contract address", + "type": "string", + "example": "So11111111111111111111111111111111111111112" + }, + "quoteTokenAddress": { + "description": "Quote token contract address", + "type": "string", + "example": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" + }, + "feePct": { + "description": "Pool fee percentage (optional - fetched from pool-info if not provided)", + "minimum": 0, + "maximum": 100, + "type": "number", + "example": 0.25 + } + }, + "required": [ + "chain", + "connector", + "type", + "network", + "address", + "baseTokenAddress", + "quoteTokenAddress" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ] + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/pools/save/{address}": { + "post": { + "tags": [ + "/pools" + ], + "description": "Find pool from GeckoTerminal and save it to the pool list. Auto-adds missing tokens.", + "parameters": [ + { + "schema": { + "type": "string" + }, + "examples": { + "solana-mainnet-beta": { + "value": "solana-mainnet-beta" + }, + "ethereum-mainnet": { + "value": "ethereum-mainnet" + }, + "ethereum-base": { + "value": "ethereum-base" + }, + "ethereum-polygon": { + "value": "ethereum-polygon" + } + }, + "in": "query", + "name": "chainNetwork", + "required": true, + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" + }, + { + "schema": { + "type": "string" + }, + "examples": { + "58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2": { + "value": "58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2" + }, + "0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640": { + "value": "0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640" + } + }, + "in": "path", + "name": "address", + "required": true, + "description": "Pool contract address" + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "pool": { + "type": "object", + "properties": { + "connector": { + "description": "Connector name (raydium, uniswap, orca, etc)", + "type": "string", + "example": "raydium" + }, + "type": { + "description": "Pool type", + "enum": [ + "clmm", + "amm" + ], + "type": "string", + "example": "clmm" + }, + "network": { + "type": "string" + }, + "baseSymbol": { + "type": "string" + }, + "quoteSymbol": { + "type": "string" + }, + "baseTokenAddress": { + "type": "string" + }, + "quoteTokenAddress": { + "type": "string" + }, + "feePct": { + "type": "number" + }, + "address": { + "type": "string" + } + }, + "required": [ + "connector", + "type", + "network", + "baseSymbol", + "quoteSymbol", + "baseTokenAddress", + "quoteTokenAddress", + "feePct", + "address" + ] + }, + "tokensAdded": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "message", + "pool" + ] + } + } + } + } + } + } + }, + "/pools/{address}": { + "delete": { + "tags": [ + "/pools" + ], + "description": "Remove a pool by address", + "parameters": [ + { + "schema": { + "type": "string" + }, + "examples": { + "solana": { + "value": "solana" + }, + "ethereum": { + "value": "ethereum" + } + }, + "in": "query", + "name": "chain", + "required": true, + "description": "Blockchain chain (solana, ethereum)" + }, + { + "schema": { + "type": "string" + }, + "examples": { + "mainnet": { + "value": "mainnet" + }, + "mainnet-beta": { + "value": "mainnet-beta" + } + }, + "in": "query", + "name": "network", + "required": true, + "description": "Network name (mainnet, mainnet-beta, etc)" + }, + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "address", + "required": true, + "description": "Pool contract address to remove" + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ] + } + } + } + }, + "404": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/trading/router/quote-swap": { + "get": { + "tags": [ + "/trading/router" + ], + "description": "Get a swap quote from a router connector on any supported chain", + "parameters": [ + { + "schema": { + "default": "solana-mainnet-beta", + "type": "string" + }, + "example": "solana-mainnet-beta", + "in": "query", + "name": "chainNetwork", + "required": true, + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" + }, + { + "schema": { + "enum": [ + "jupiter", + "dflow", + "okx", + "titan", + "uniswap", + "pancakeswap", + "0x" + ], + "default": "jupiter", + "type": "string" + }, + "example": "jupiter", + "in": "query", + "name": "connector", + "required": false, + "description": "Router connector. Defaults to the network's swapProvider" + }, + { + "schema": { + "default": "SOL", + "type": "string" + }, + "in": "query", + "name": "baseToken", + "required": true, + "description": "Symbol or address of the base token" + }, + { + "schema": { + "default": "USDC", + "type": "string" + }, + "in": "query", + "name": "quoteToken", + "required": true, + "description": "Symbol or address of the quote token" + }, + { + "schema": { + "format": "decimal", + "default": 1, + "type": "number" + }, + "in": "query", + "name": "amount", + "required": true, + "description": "Amount of base token to trade" + }, + { + "schema": { + "enum": [ + "BUY", + "SELL" + ], + "default": "SELL", + "type": "string" + }, + "in": "query", + "name": "side", + "required": true, + "description": "BUY means buying base token with quote token, SELL means selling base token for quote token" + }, + { + "schema": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "type": "number" + }, + "example": 1, + "in": "query", + "name": "slippagePct", + "required": false, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct." + }, + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "walletAddress", + "required": false, + "description": "Taker the quote is priced for. Required by routers that quote per-wallet or return wallet-specific calldata." + }, + { + "schema": { + "default": true, + "x-connectors": [ + "jupiter", + "dflow", + "okx", + "titan" + ], + "type": "boolean" + }, + "in": "query", + "name": "approximateIfNoExactOut", + "required": false, + "description": "For BUY orders when the router has no ExactOut route: approximate via a sell-leg ExactIn quote instead of failing." + }, + { + "schema": { + "x-connectors": [ + "0x" + ], + "type": "boolean" + }, + "in": "query", + "name": "indicativePrice", + "required": false, + "description": "Return an indicative price instead of a firm, executable quote. An indicative quote cannot be executed with /trading/router/execute-quote." + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tokenIn": { + "description": "Address of the token being swapped from", + "type": "string" + }, + "tokenOut": { + "description": "Address of the token being swapped to", + "type": "string" + }, + "amountIn": { + "format": "decimal", + "description": "Amount of tokenIn to be swapped", + "type": "number" + }, + "amountOut": { + "format": "decimal", + "description": "Expected amount of tokenOut to receive", + "type": "number" + }, + "price": { + "format": "decimal", + "description": "Exchange rate between tokenIn and tokenOut", + "type": "number" + }, + "priceImpactPct": { + "format": "decimal", + "description": "Estimated price impact percentage (0-100)", + "type": "number" + }, + "minAmountOut": { + "format": "decimal", + "description": "Minimum amount of tokenOut that will be accepted", + "type": "number" + }, + "maxAmountIn": { + "format": "decimal", + "description": "Maximum amount of tokenIn that will be spent", + "type": "number" + }, + "poolAddress": { + "description": "Pool address for AMM/CLMM swaps", + "type": "string" + }, + "routePath": { + "description": "Route path for router-based swaps", + "type": "string" + }, + "slippagePct": { + "format": "decimal", + "description": "Slippage tolerance percentage", + "type": "number" + }, + "quoteId": { + "description": "Identifier to pass to /trading/router/execute-quote", + "type": "string" + }, + "approximation": { + "description": "True when a BUY was approximated via a sell-leg ExactIn quote because the router has no ExactOut route; amountOut is an estimate rather than exact", + "type": "boolean" + } + }, + "required": [ + "tokenIn", + "tokenOut", + "amountIn", + "amountOut", + "price", + "priceImpactPct", + "minAmountOut", + "maxAmountIn", + "quoteId" + ] + } + } + } + } + } + } + }, + "/trading/router/execute-quote": { + "post": { + "tags": [ + "/trading/router" + ], + "description": "Execute a previously fetched router quote by its quote id", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "connector": { + "description": "Router connector. Defaults to the network's swapProvider", + "enum": [ + "jupiter", + "dflow", + "okx", + "titan", + "uniswap", + "pancakeswap", + "0x" + ], + "default": "jupiter", + "type": "string", + "example": "jupiter" + }, + "walletAddress": { + "description": "Wallet address that will execute the quote", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "quoteId": { + "description": "ID of a quote returned by /trading/router/quote-swap", + "type": "string" + } + }, + "required": [ + "chainNetwork", + "walletAddress", + "quoteId" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "signature": { + "description": "Transaction signature/hash", + "type": "string" + }, + "status": { + "description": "Transaction status: 0 = PENDING, 1 = CONFIRMED, -1 = FAILED", + "type": "number" + }, + "data": { + "type": "object", + "properties": { + "tokenIn": { + "description": "Address of the token swapped from", + "type": "string" + }, + "tokenOut": { + "description": "Address of the token swapped to", + "type": "string" + }, + "amountIn": { + "format": "decimal", + "description": "Actual amount of tokenIn swapped", + "type": "number" + }, + "amountOut": { + "format": "decimal", + "description": "Actual amount of tokenOut received", + "type": "number" + }, + "fee": { + "format": "decimal", + "description": "Transaction fee paid", + "type": "number" + }, + "baseTokenBalanceChange": { + "format": "decimal", + "description": "Change in base token balance (negative for decrease)", + "type": "number" + }, + "quoteTokenBalanceChange": { + "format": "decimal", + "description": "Change in quote token balance (negative for decrease)", + "type": "number" + }, + "slippagePct": { + "format": "decimal", + "description": "Slippage tolerance percentage actually applied to the swap", + "type": "number" + }, + "poolAddress": { + "description": "Pool the swap executed against. Set by the pool-scoped routes (/trading/clmm, /trading/amm), which resolve exactly one pool; a router picks its own path across pools and leaves this unset. Without it a settled fill cannot be reconciled to a venue without refetching the transaction.", + "type": "string" + } + }, + "required": [ + "tokenIn", + "tokenOut", + "amountIn", + "amountOut", + "fee", + "baseTokenBalanceChange", + "quoteTokenBalanceChange" + ] + } + }, + "required": [ + "signature", + "status" + ] + } + } + } + } + } + } + }, + "/trading/router/execute-swap": { + "post": { + "tags": [ + "/trading/router" + ], + "description": "Quote and execute a swap through a router connector on any supported chain", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "connector": { + "description": "Router connector. Defaults to the network's swapProvider", + "enum": [ + "jupiter", + "dflow", + "okx", + "titan", + "uniswap", + "pancakeswap", + "0x" + ], + "default": "jupiter", + "type": "string", + "example": "jupiter" + }, + "walletAddress": { + "description": "Wallet address that will execute the swap", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "baseToken": { + "description": "Symbol or address of the base token", + "default": "SOL", + "type": "string" + }, + "quoteToken": { + "description": "Symbol or address of the quote token", + "default": "USDC", + "type": "string" + }, + "amount": { + "format": "decimal", + "description": "Amount of base token to trade", + "default": 0.01, + "type": "number" + }, + "side": { + "description": "BUY means buying base token with quote token, SELL means selling base token for quote token", + "enum": [ + "BUY", + "SELL" + ], + "default": "SELL", + "type": "string" + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 + }, + "approximateIfNoExactOut": { + "description": "For BUY orders when the router has no ExactOut route: approximate via a sell-leg ExactIn swap instead of failing.", + "default": true, + "x-connectors": [ + "jupiter", + "dflow", + "okx", + "titan" + ], + "type": "boolean" + } + }, + "required": [ + "chainNetwork", + "walletAddress", + "baseToken", + "quoteToken", + "amount", + "side" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "signature": { + "description": "Transaction signature/hash", + "type": "string" + }, + "status": { + "description": "Transaction status: 0 = PENDING, 1 = CONFIRMED, -1 = FAILED", + "type": "number" + }, + "data": { + "type": "object", + "properties": { + "tokenIn": { + "description": "Address of the token swapped from", + "type": "string" + }, + "tokenOut": { + "description": "Address of the token swapped to", + "type": "string" + }, + "amountIn": { + "format": "decimal", + "description": "Actual amount of tokenIn swapped", + "type": "number" + }, + "amountOut": { + "format": "decimal", + "description": "Actual amount of tokenOut received", + "type": "number" + }, + "fee": { + "format": "decimal", + "description": "Transaction fee paid", + "type": "number" + }, + "baseTokenBalanceChange": { + "format": "decimal", + "description": "Change in base token balance (negative for decrease)", + "type": "number" + }, + "quoteTokenBalanceChange": { + "format": "decimal", + "description": "Change in quote token balance (negative for decrease)", + "type": "number" + }, + "slippagePct": { + "format": "decimal", + "description": "Slippage tolerance percentage actually applied to the swap", + "type": "number" + }, + "poolAddress": { + "description": "Pool the swap executed against. Set by the pool-scoped routes (/trading/clmm, /trading/amm), which resolve exactly one pool; a router picks its own path across pools and leaves this unset. Without it a settled fill cannot be reconciled to a venue without refetching the transaction.", + "type": "string" + } + }, + "required": [ + "tokenIn", + "tokenOut", + "amountIn", + "amountOut", + "fee", + "baseTokenBalanceChange", + "quoteTokenBalanceChange" + ] + } + }, + "required": [ + "signature", + "status" + ] + } + } + } + } + } + } + }, + "/trading/clmm/pool-info": { + "get": { + "tags": [ + "/trading/clmm" + ], + "description": "Get CLMM pool information from any supported connector", + "parameters": [ + { + "schema": { + "enum": [ + "meteora", + "raydium", + "pancakeswap-sol", + "orca", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string" + }, + "example": "meteora", + "in": "query", + "name": "connector", + "required": true, + "description": "CLMM connector" + }, + { + "schema": { + "default": "solana-mainnet-beta", + "type": "string" + }, + "example": "solana-mainnet-beta", + "in": "query", + "name": "chainNetwork", + "required": true, + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" + }, + { + "schema": { + "type": "string" + }, + "example": "2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3", + "in": "query", + "name": "poolAddress", + "required": true, + "description": "Pool contract address" + }, + { + "schema": { + "default": 0, + "minimum": 0, + "maximum": 401, + "type": "integer" + }, + "in": "query", + "name": "binCount", + "required": false, + "description": "If > 0, include a `bins` array of per-tick liquidity around the active tick. Supported by every connector except Meteora, which always returns its bins and ignores this. Default 0 = skip the bin fetch." + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "baseTokenAddress": { + "type": "string" + }, + "quoteTokenAddress": { + "type": "string" + }, + "binStep": { + "type": "number" + }, + "feePct": { + "type": "number" + }, + "price": { + "type": "number" + }, + "baseTokenAmount": { + "type": "number" + }, + "quoteTokenAmount": { + "type": "number" + }, + "activeBinId": { + "type": "number" + }, + "bins": { + "type": "array", + "items": { + "type": "object", + "properties": { + "binId": { + "type": "number" + }, + "price": { + "type": "number" + }, + "baseTokenAmount": { + "type": "number" + }, + "quoteTokenAmount": { + "type": "number" + } + }, + "required": [ + "binId", + "price", + "baseTokenAmount", + "quoteTokenAmount" + ], + "title": "BinLiquidity" + } + } + }, + "required": [ + "address", + "baseTokenAddress", + "quoteTokenAddress", + "feePct", + "price", + "baseTokenAmount", + "quoteTokenAmount", + "activeBinId" + ] + } + } + } + } + } + } + }, + "/trading/clmm/position-info": { + "get": { + "tags": [ + "/trading/clmm" + ], + "description": "Get CLMM position information from any supported connector", + "parameters": [ + { + "schema": { + "enum": [ + "meteora", + "raydium", + "pancakeswap-sol", + "orca", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string" + }, + "example": "meteora", + "in": "query", + "name": "connector", + "required": true, + "description": "CLMM connector" + }, + { + "schema": { + "default": "solana-mainnet-beta", + "type": "string" + }, + "example": "solana-mainnet-beta", + "in": "query", + "name": "chainNetwork", + "required": true, + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" + }, + { + "schema": { + "type": "string" + }, + "example": "", + "in": "query", + "name": "positionAddress", + "required": true, + "description": "Position address or NFT token ID" + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "poolAddress": { + "type": "string" + }, + "baseTokenAddress": { + "type": "string" + }, + "quoteTokenAddress": { + "type": "string" + }, + "baseTokenAmount": { + "type": "number" + }, + "quoteTokenAmount": { + "type": "number" + }, + "baseFeeAmount": { + "type": "number" + }, + "quoteFeeAmount": { + "type": "number" + }, + "lowerBinId": { + "type": "number" + }, + "upperBinId": { + "type": "number" + }, + "lowerPrice": { + "type": "number" + }, + "upperPrice": { + "type": "number" + }, + "price": { + "type": "number" + } + }, + "required": [ + "address", + "poolAddress", + "baseTokenAddress", + "quoteTokenAddress", + "baseTokenAmount", + "quoteTokenAmount", + "baseFeeAmount", + "quoteFeeAmount", + "lowerBinId", + "upperBinId", + "lowerPrice", + "upperPrice", + "price" + ] + } + } + } + } + } + } + }, + "/trading/clmm/positions-owned": { + "get": { + "tags": [ + "/trading/clmm" + ], + "description": "Get all CLMM positions owned by a wallet from any supported connector", + "parameters": [ + { + "schema": { + "enum": [ + "meteora", + "raydium", + "pancakeswap-sol", + "orca", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string" + }, + "example": "meteora", + "in": "query", + "name": "connector", + "required": true, + "description": "CLMM connector" + }, + { + "schema": { + "default": "solana-mainnet-beta", + "type": "string" + }, + "example": "solana-mainnet-beta", + "in": "query", + "name": "chainNetwork", + "required": true, + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" + }, + { + "schema": { + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "in": "query", + "name": "walletAddress", + "required": true, + "description": "Wallet address" + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "poolAddress": { + "type": "string" + }, + "baseTokenAddress": { + "type": "string" + }, + "quoteTokenAddress": { + "type": "string" + }, + "baseTokenAmount": { + "type": "number" + }, + "quoteTokenAmount": { + "type": "number" + }, + "baseFeeAmount": { + "type": "number" + }, + "quoteFeeAmount": { + "type": "number" + }, + "lowerBinId": { + "type": "number" + }, + "upperBinId": { + "type": "number" + }, + "lowerPrice": { + "type": "number" + }, + "upperPrice": { + "type": "number" + }, + "price": { + "type": "number" + } + }, + "required": [ + "address", + "poolAddress", + "baseTokenAddress", + "quoteTokenAddress", + "baseTokenAmount", + "quoteTokenAmount", + "baseFeeAmount", + "quoteFeeAmount", + "lowerBinId", + "upperBinId", + "lowerPrice", + "upperPrice", + "price" + ], + "title": "PositionInfo" + } + } + } + } + } + } + } + }, + "/trading/clmm/quote-liquidity": { + "get": { + "tags": [ + "/trading/clmm" + ], + "description": "Quote amounts for a new CLMM position from any supported connector", + "parameters": [ + { + "schema": { + "enum": [ + "meteora", + "raydium", + "pancakeswap-sol", + "orca", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string" + }, + "example": "meteora", + "in": "query", + "name": "connector", + "required": true, + "description": "CLMM connector" + }, + { + "schema": { + "default": "solana-mainnet-beta", + "type": "string" + }, + "example": "solana-mainnet-beta", + "in": "query", + "name": "chainNetwork", + "required": true, + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" + }, + { + "schema": { + "format": "decimal", + "type": "number" + }, + "example": 150, + "in": "query", + "name": "lowerPrice", + "required": true, + "description": "Lower price bound for the position" + }, + { + "schema": { + "format": "decimal", + "type": "number" + }, + "example": 250, + "in": "query", + "name": "upperPrice", + "required": true, + "description": "Upper price bound for the position" + }, + { + "schema": { + "type": "string" + }, + "example": "2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3", + "in": "query", + "name": "poolAddress", + "required": true, + "description": "Pool contract address" + }, + { + "schema": { + "format": "decimal", + "type": "number" + }, + "example": 0.01, + "in": "query", + "name": "baseTokenAmount", + "required": false, + "description": "Amount of base token to deposit" + }, + { + "schema": { + "format": "decimal", + "type": "number" + }, + "example": 2, + "in": "query", + "name": "quoteTokenAmount", + "required": false, + "description": "Amount of quote token to deposit" + }, + { + "schema": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "type": "number" + }, + "example": 1, + "in": "query", + "name": "slippagePct", + "required": false, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct." + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "baseLimited": { + "type": "boolean" + }, + "baseTokenAmount": { + "type": "number" + }, + "quoteTokenAmount": { + "type": "number" + }, + "baseTokenAmountMax": { + "type": "number" + }, + "quoteTokenAmountMax": { + "type": "number" + }, + "liquidity": {} + }, + "required": [ + "baseLimited", + "baseTokenAmount", + "quoteTokenAmount", + "baseTokenAmountMax", + "quoteTokenAmountMax" + ] + } + } + } + } + } + } + }, + "/trading/clmm/fetch-pools": { + "get": { + "tags": [ + "/trading/clmm" + ], + "description": "Discover pools from a CLMM connector's own pool-listing API", + "parameters": [ + { + "schema": { + "default": "solana-mainnet-beta", + "type": "string" + }, + "example": "solana-mainnet-beta", + "in": "query", + "name": "chainNetwork", + "required": true, + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" + }, + { + "schema": { + "enum": [ + "meteora", + "orca" + ], + "default": "meteora", + "type": "string" + }, + "example": "meteora", + "in": "query", + "name": "connector", + "required": true, + "description": "CLMM connector whose pool-discovery API to query" + }, + { + "schema": { + "minimum": 1, + "maximum": 1000, + "default": 50, + "type": "number" + }, + "in": "query", + "name": "limit", + "required": false, + "description": "Maximum number of pools to return" + }, + { + "schema": { + "type": "string" + }, + "examples": { + "SOL": { + "value": "SOL" + }, + "SOL-USDC": { + "value": "SOL-USDC" + } + }, + "in": "query", + "name": "query", + "required": false, + "description": "Search pools by name, token, or address" + }, + { + "schema": { + "type": "string" + }, + "examples": { + "tvl": { + "value": "tvl" + }, + "tvl:desc": { + "value": "tvl:desc" + } + }, + "in": "query", + "name": "sortBy", + "required": false, + "description": "Sort field. Meteora takes a \"field:direction\" pair; Orca takes the field alone with sortDirection." + }, + { + "schema": { + "minimum": 0, + "x-connectors": [ + "meteora" + ], + "type": "number" + }, + "in": "query", + "name": "page", + "required": false, + "description": "0-based page index. Only connectors whose API paginates honor this." + }, + { + "schema": { + "x-connectors": [ + "meteora" + ], + "type": "boolean" + }, + "in": "query", + "name": "includeUnverified", + "required": false, + "description": "Include unverified pools" + }, + { + "schema": { + "enum": [ + "asc", + "desc" + ], + "x-connectors": [ + "orca" + ], + "type": "string" + }, + "in": "query", + "name": "sortDirection", + "required": false, + "description": "Sort direction" + }, + { + "schema": { + "x-connectors": [ + "orca" + ], + "type": "boolean" + }, + "in": "query", + "name": "verifiedOnly", + "required": false, + "description": "Return only verified pools" + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "pools": { + "type": "array", + "items": { + "type": "object", + "properties": { + "address": { + "description": "Pool address", + "type": "string" + }, + "name": { + "description": "Pool name (e.g., SOL-USDC)", + "type": "string" + }, + "baseTokenAddress": { + "description": "Base token address", + "type": "string" + }, + "baseTokenSymbol": { + "description": "Base token symbol", + "type": "string" + }, + "quoteTokenAddress": { + "description": "Quote token address", + "type": "string" + }, + "quoteTokenSymbol": { + "description": "Quote token symbol", + "type": "string" + }, + "binStep": { + "description": "Bin step / tick spacing", + "type": "number" + }, + "baseFee": { + "format": "decimal", + "description": "Base fee percentage", + "type": "number" + }, + "price": { + "format": "decimal", + "description": "Current price", + "type": "number" + }, + "tvl": { + "format": "decimal", + "description": "Total value locked in USD", + "type": "number" + }, + "apr": { + "format": "decimal", + "description": "Annual percentage rate", + "type": "number" + }, + "apy": { + "format": "decimal", + "description": "Annual percentage yield", + "type": "number" + }, + "volume24h": { + "format": "decimal", + "description": "24-hour trading volume", + "type": "number" + }, + "fees24h": { + "format": "decimal", + "description": "24-hour fees collected", + "type": "number" + } + }, + "required": [ + "address", + "name", + "baseTokenAddress", + "baseTokenSymbol", + "quoteTokenAddress", + "quoteTokenSymbol", + "binStep", + "baseFee", + "price", + "tvl" + ], + "title": "PoolListItem" + } + }, + "total": { + "description": "Total number of matching pools", + "type": "number" + }, + "page": { + "description": "Current page number", + "type": "number" + }, + "pageSize": { + "description": "Number of pools per page", + "type": "number" + } + }, + "required": [ + "pools", + "total", + "page", + "pageSize" + ] + } + } + } + } + } + } + }, + "/trading/clmm/quote-swap": { + "get": { + "tags": [ + "/trading/clmm" + ], + "description": "Get a swap quote from a single CLMM pool", + "parameters": [ + { + "schema": { + "default": "solana-mainnet-beta", + "type": "string" + }, + "example": "solana-mainnet-beta", + "in": "query", + "name": "chainNetwork", + "required": true, + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" + }, + { + "schema": { + "enum": [ + "meteora", + "raydium", + "orca", + "pancakeswap-sol", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string" + }, + "example": "meteora", + "in": "query", + "name": "connector", + "required": false, + "description": "CLMM connector to price the swap against" + }, + { + "schema": { + "default": "SOL", + "type": "string" + }, + "in": "query", + "name": "baseToken", + "required": true, + "description": "Symbol or address of the base token" + }, + { + "schema": { + "default": "USDC", + "type": "string" + }, + "in": "query", + "name": "quoteToken", + "required": true, + "description": "Symbol or address of the quote token" + }, + { + "schema": { + "format": "decimal", + "default": 1, + "type": "number" + }, + "in": "query", + "name": "amount", + "required": true, + "description": "Amount of base token to trade" + }, + { + "schema": { + "enum": [ + "BUY", + "SELL" + ], + "default": "SELL", + "type": "string" + }, + "in": "query", + "name": "side", + "required": true, + "description": "BUY means buying base token with quote token, SELL means selling base token for quote token" + }, + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "poolAddress", + "required": false, + "description": "Pool to trade against. Omit to resolve it from Gateway's configured pool list by token pair; pass an address to pin a pool that is not in that list." + }, + { + "schema": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "type": "number" + }, + "example": 1, + "in": "query", + "name": "slippagePct", + "required": false, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct." + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tokenIn": { + "description": "Address of the token being swapped from", + "type": "string" + }, + "tokenOut": { + "description": "Address of the token being swapped to", + "type": "string" + }, + "amountIn": { + "format": "decimal", + "description": "Amount of tokenIn to be swapped", + "type": "number" + }, + "amountOut": { + "format": "decimal", + "description": "Expected amount of tokenOut to receive", + "type": "number" + }, + "price": { + "format": "decimal", + "description": "Exchange rate between tokenIn and tokenOut", + "type": "number" + }, + "priceImpactPct": { + "format": "decimal", + "description": "Estimated price impact percentage (0-100)", + "type": "number" + }, + "minAmountOut": { + "format": "decimal", + "description": "Minimum amount of tokenOut that will be accepted", + "type": "number" + }, + "maxAmountIn": { + "format": "decimal", + "description": "Maximum amount of tokenIn that will be spent", + "type": "number" + }, + "poolAddress": { + "description": "Pool address for AMM/CLMM swaps", + "type": "string" + }, + "routePath": { + "description": "Route path for router-based swaps", + "type": "string" + }, + "slippagePct": { + "format": "decimal", + "description": "Slippage tolerance percentage", + "type": "number" + } + }, + "required": [ + "tokenIn", + "tokenOut", + "amountIn", + "amountOut", + "price", + "priceImpactPct", + "minAmountOut", + "maxAmountIn" + ] + } + } + } + } + } + } + }, + "/trading/clmm/execute-swap": { + "post": { + "tags": [ + "/trading/clmm" + ], + "description": "Execute a swap against a single CLMM pool", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "connector": { + "description": "CLMM connector to execute the swap against", + "enum": [ + "meteora", + "raydium", + "orca", + "pancakeswap-sol", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" + }, + "walletAddress": { + "description": "Wallet address that will execute the swap", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "baseToken": { + "description": "Symbol or address of the base token", + "default": "SOL", + "type": "string" + }, + "quoteToken": { + "description": "Symbol or address of the quote token", + "default": "USDC", + "type": "string" + }, + "amount": { + "format": "decimal", + "description": "Amount of base token to trade", + "default": 0.01, + "type": "number" + }, + "side": { + "description": "BUY means buying base token with quote token, SELL means selling base token for quote token", + "enum": [ + "BUY", + "SELL" + ], + "default": "SELL", + "type": "string" + }, + "poolAddress": { + "description": "Pool to trade against. Omit to resolve it from Gateway's configured pool list by token pair; pass an address to pin a pool that is not in that list.", + "type": "string" + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 + } + }, + "required": [ + "chainNetwork", + "walletAddress", + "baseToken", + "quoteToken", + "amount", + "side" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "signature": { + "description": "Transaction signature/hash", + "type": "string" + }, + "status": { + "description": "Transaction status: 0 = PENDING, 1 = CONFIRMED, -1 = FAILED", + "type": "number" + }, + "data": { + "type": "object", + "properties": { + "tokenIn": { + "description": "Address of the token swapped from", + "type": "string" + }, + "tokenOut": { + "description": "Address of the token swapped to", + "type": "string" + }, + "amountIn": { + "format": "decimal", + "description": "Actual amount of tokenIn swapped", + "type": "number" + }, + "amountOut": { + "format": "decimal", + "description": "Actual amount of tokenOut received", + "type": "number" + }, + "fee": { + "format": "decimal", + "description": "Transaction fee paid", + "type": "number" + }, + "baseTokenBalanceChange": { + "format": "decimal", + "description": "Change in base token balance (negative for decrease)", + "type": "number" + }, + "quoteTokenBalanceChange": { + "format": "decimal", + "description": "Change in quote token balance (negative for decrease)", + "type": "number" + }, + "slippagePct": { + "format": "decimal", + "description": "Slippage tolerance percentage actually applied to the swap", + "type": "number" + }, + "poolAddress": { + "description": "Pool the swap executed against. Set by the pool-scoped routes (/trading/clmm, /trading/amm), which resolve exactly one pool; a router picks its own path across pools and leaves this unset. Without it a settled fill cannot be reconciled to a venue without refetching the transaction.", + "type": "string" + } + }, + "required": [ + "tokenIn", + "tokenOut", + "amountIn", + "amountOut", + "fee", + "baseTokenBalanceChange", + "quoteTokenBalanceChange" + ] + } + }, + "required": [ + "signature", + "status" + ] + } + } + } + } + } + } + }, + "/trading/clmm/open": { + "post": { + "tags": [ + "/trading/clmm" + ], + "description": "Open a new CLMM position across supported connectors", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "connector": { + "description": "CLMM connector", + "enum": [ + "meteora", + "raydium", + "pancakeswap-sol", + "orca", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" + }, + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "walletAddress": { + "description": "Wallet address", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "lowerPrice": { + "format": "decimal", + "description": "Lower price bound for the position", + "type": "number", + "example": 150 + }, + "upperPrice": { + "format": "decimal", + "description": "Upper price bound for the position", + "type": "number", + "example": 250 + }, + "poolAddress": { + "description": "Pool address", + "type": "string", + "example": "2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3" + }, + "baseTokenAmount": { + "format": "decimal", + "description": "Amount of base token to deposit", + "type": "number", + "example": 0.01 + }, + "quoteTokenAmount": { + "format": "decimal", + "description": "Amount of quote token to deposit", + "type": "number", + "example": 2 + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 + }, + "strategyType": { + "x-connectors": [ + "meteora" + ], + "description": "Strategy type for Meteora positions (0=Spot, 1=Curve). Only applies to Meteora connector.", + "type": "number", + "example": 0 + } + }, + "required": [ + "connector", + "chainNetwork", + "walletAddress", + "lowerPrice", + "upperPrice", + "poolAddress" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "signature": { + "type": "string" + }, + "status": { + "description": "TransactionStatus enum value", + "type": "number" + }, + "data": { + "type": "object", + "properties": { + "fee": { + "type": "number" + }, + "positionAddress": { + "type": "string" + }, + "positionRent": { + "type": "number" + }, + "baseTokenAmountAdded": { + "type": "number" + }, + "quoteTokenAmountAdded": { + "type": "number" + } + }, + "required": [ + "fee", + "positionAddress", + "positionRent", + "baseTokenAmountAdded", + "quoteTokenAmountAdded" + ] + } + }, + "required": [ + "signature", + "status" + ] + } + } + } + } + } + } + }, + "/trading/clmm/add": { + "post": { + "tags": [ + "/trading/clmm" + ], + "description": "Add liquidity to an existing CLMM position across supported connectors", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "connector": { + "description": "CLMM connector", + "enum": [ + "meteora", + "raydium", + "pancakeswap-sol", + "orca", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" + }, + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "walletAddress": { + "description": "Wallet address", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "positionAddress": { + "description": "Position address", + "type": "string", + "example": "" + }, + "baseTokenAmount": { + "format": "decimal", + "description": "Amount of base token to deposit (omit for single-sided quote deposit)", + "type": "number", + "example": 0.01 + }, + "quoteTokenAmount": { + "format": "decimal", + "description": "Amount of quote token to deposit (omit for single-sided base deposit)", + "type": "number", + "example": 2 + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 + }, + "strategyType": { + "x-connectors": [ + "meteora" + ], + "description": "Strategy type for Meteora positions (0=Spot, 1=Curve). Only applies to Meteora connector.", + "type": "number", + "example": 0 + } + }, + "required": [ + "connector", + "chainNetwork", + "walletAddress", + "positionAddress" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "signature": { + "type": "string" + }, + "status": { + "description": "TransactionStatus enum value", + "type": "number" + }, + "data": { + "type": "object", + "properties": { + "fee": { + "type": "number" + }, + "baseTokenAmountAdded": { + "type": "number" + }, + "quoteTokenAmountAdded": { + "type": "number" + } + }, + "required": [ + "fee", + "baseTokenAmountAdded", + "quoteTokenAmountAdded" + ] + } + }, + "required": [ + "signature", + "status" + ] + } + } + } + } + } + } + }, + "/trading/clmm/remove": { + "post": { + "tags": [ + "/trading/clmm" + ], + "description": "Remove liquidity from a CLMM position across supported connectors", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "connector": { + "description": "CLMM connector", + "enum": [ + "meteora", + "raydium", + "pancakeswap-sol", + "orca", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" + }, + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "walletAddress": { + "description": "Wallet address", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "positionAddress": { + "description": "Position address", + "type": "string", + "example": "" + }, + "percentageToRemove": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Percentage of liquidity to remove", + "default": 100, + "type": "number", + "example": 100 + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage. Only applies to the Orca connector; defaults to Orca's configured slippagePct.", + "type": "number", + "example": 1 + } + }, + "required": [ + "connector", + "chainNetwork", + "walletAddress", + "positionAddress", + "percentageToRemove" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "signature": { + "type": "string" + }, + "status": { + "description": "TransactionStatus enum value", + "type": "number" + }, + "data": { + "type": "object", + "properties": { + "fee": { + "type": "number" + }, + "baseTokenAmountRemoved": { + "type": "number" + }, + "quoteTokenAmountRemoved": { + "type": "number" + } + }, + "required": [ + "fee", + "baseTokenAmountRemoved", + "quoteTokenAmountRemoved" + ] + } + }, + "required": [ + "signature", + "status" + ] + } + } + } + } + } + } + }, + "/trading/clmm/collect-fees": { + "post": { + "tags": [ + "/trading/clmm" + ], + "description": "Collect fees from a CLMM position across supported connectors", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "connector": { + "description": "CLMM connector", + "enum": [ + "meteora", + "raydium", + "pancakeswap-sol", + "orca", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" + }, + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "walletAddress": { + "description": "Wallet address", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "positionAddress": { + "description": "Position address", + "type": "string", + "example": "" + } + }, + "required": [ + "connector", + "chainNetwork", + "walletAddress", + "positionAddress" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "signature": { + "type": "string" + }, + "status": { + "description": "TransactionStatus enum value", + "type": "number" + }, + "data": { + "type": "object", + "properties": { + "fee": { + "type": "number" + }, + "baseFeeAmountCollected": { + "type": "number" + }, + "quoteFeeAmountCollected": { + "type": "number" + } + }, + "required": [ + "fee", + "baseFeeAmountCollected", + "quoteFeeAmountCollected" + ] + } + }, + "required": [ + "signature", + "status" + ] + } + } + } + } + } + } + }, + "/trading/clmm/close": { + "post": { + "tags": [ + "/trading/clmm" + ], + "description": "Close a CLMM position across supported connectors", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "connector": { + "description": "CLMM connector", + "enum": [ + "meteora", + "raydium", + "pancakeswap-sol", + "orca", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" + }, + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "walletAddress": { + "description": "Wallet address", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "positionAddress": { + "description": "Position address", + "type": "string", + "example": "" + } + }, + "required": [ + "connector", + "chainNetwork", + "walletAddress", + "positionAddress" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "signature": { + "type": "string" + }, + "status": { + "description": "TransactionStatus enum value", + "type": "number" + }, + "data": { + "type": "object", + "properties": { + "fee": { + "type": "number" + }, + "positionRentRefunded": { + "type": "number" + }, + "baseTokenAmountRemoved": { + "type": "number" + }, + "quoteTokenAmountRemoved": { + "type": "number" + }, + "baseFeeAmountCollected": { + "type": "number" + }, + "quoteFeeAmountCollected": { + "type": "number" + } + }, + "required": [ + "fee", + "positionRentRefunded", + "baseTokenAmountRemoved", + "quoteTokenAmountRemoved", + "baseFeeAmountCollected", + "quoteFeeAmountCollected" + ] + } + }, + "required": [ + "signature", + "status" + ] + } + } + } + } + } + } + }, + "/trading/clmm/create-pool": { + "post": { + "tags": [ + "/trading/clmm" + ], + "description": "Create and initialize a new CLMM pool across supported connectors (Meteora DLMM, Raydium CLMM, PancakeSwap Solana CLMM, Orca Whirlpool, Uniswap V3, PancakeSwap V3)", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "connector": { + "description": "CLMM connector", + "enum": [ + "meteora", + "raydium", + "pancakeswap-sol", + "orca", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" + }, + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "walletAddress": { + "description": "Wallet address (pool creator + payer)", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "baseToken": { + "type": "string" + }, + "quoteToken": { + "type": "string" + }, + "initialPrice": { + "format": "decimal", + "description": "Initial pool price as quote per base. If omitted, the current market price is fetched from the unified swap router so the pool opens on-market.", + "type": "number" + }, + "binStep": { + "x-connectors": [ + "meteora", + "orca" + ], + "description": "Bin/tick granularity: Meteora DLMM bin step (bps); Orca Whirlpool tick spacing.", + "type": "number" + }, + "feeBps": { + "x-connectors": [ + "meteora", + "uniswap", + "pancakeswap" + ], + "description": "Base fee in basis points: Meteora DLMM base fee; Uniswap/PancakeSwap V3 fee tier (1, 5, 30 or 100 bps; PancakeSwap also 25).", + "type": "number" + }, + "ammConfigIndex": { + "x-connectors": [ + "raydium", + "pancakeswap-sol" + ], + "description": "Fee-config index for the Raydium CLMM family: Raydium API config list index; pancakeswap-sol amm_config PDA index. Default 0.", + "type": "number" + } + }, + "required": [ + "connector", + "chainNetwork", + "walletAddress", + "baseToken", + "quoteToken" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "signature": { + "type": "string" + }, + "status": { + "description": "TransactionStatus enum value", + "type": "number" + }, + "poolAddress": { + "description": "Address of the newly created pool", + "type": "string" + }, + "price": { + "format": "decimal", + "description": "Initial price the pool was initialized at (quote per base)", + "type": "number" + }, + "data": { + "type": "object", + "properties": { + "fee": { + "type": "number" + } + }, + "required": [ + "fee" + ] + } + }, + "required": [ + "signature", + "status", + "poolAddress" + ] + } + } + } + } + } + } + }, + "/trading/amm/pool-info": { + "get": { + "tags": [ + "/trading/amm" + ], + "description": "Get AMM pool information from any supported connector", + "parameters": [ + { + "schema": { + "enum": [ + "meteora", + "raydium", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string" + }, + "example": "meteora", + "in": "query", + "name": "connector", + "required": true, + "description": "AMM connector" + }, + { + "schema": { + "default": "solana-mainnet-beta", + "type": "string" + }, + "example": "solana-mainnet-beta", + "in": "query", + "name": "chainNetwork", + "required": true, + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" + }, + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "poolAddress", + "required": true, + "description": "Pool contract address" + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "baseTokenAddress": { + "type": "string" + }, + "quoteTokenAddress": { + "type": "string" + }, + "feePct": { + "type": "number" + }, + "price": { + "type": "number" + }, + "baseTokenAmount": { + "type": "number" + }, + "quoteTokenAmount": { + "type": "number" + } + }, + "required": [ + "address", + "baseTokenAddress", + "quoteTokenAddress", + "feePct", + "price", + "baseTokenAmount", + "quoteTokenAmount" + ] + } + } + } + } + } + } + }, + "/trading/amm/position-info": { + "get": { + "tags": [ + "/trading/amm" + ], + "description": "Get a wallet's aggregated AMM liquidity in a pool from any supported connector", + "parameters": [ + { + "schema": { + "enum": [ + "meteora", + "raydium", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string" + }, + "example": "meteora", + "in": "query", + "name": "connector", + "required": true, + "description": "AMM connector" + }, + { + "schema": { + "default": "solana-mainnet-beta", + "type": "string" + }, + "example": "solana-mainnet-beta", + "in": "query", + "name": "chainNetwork", + "required": true, + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" + }, + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "poolAddress", + "required": true, + "description": "Pool contract address" + }, + { + "schema": { + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "in": "query", + "name": "walletAddress", + "required": true, + "description": "Wallet address" + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "poolAddress": { + "type": "string" + }, + "walletAddress": { + "type": "string" + }, + "baseTokenAddress": { + "type": "string" + }, + "quoteTokenAddress": { + "type": "string" + }, + "lpTokenAmount": { + "type": "number" + }, + "baseTokenAmount": { + "type": "number" + }, + "quoteTokenAmount": { + "type": "number" + }, + "price": { + "type": "number" + }, + "positions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "positionAddress": { + "description": "Address of the individual position (NFT position account)", + "type": "string" + }, + "lpTokenAmount": { + "format": "decimal", + "description": "Liquidity held by this position (LP units)", + "type": "number" + }, + "baseTokenAmount": { + "type": "number" + }, + "quoteTokenAmount": { + "type": "number" + } + }, + "required": [ + "positionAddress", + "lpTokenAmount", + "baseTokenAmount", + "quoteTokenAmount" + ], + "title": "PositionDetail" + } + } + }, + "required": [ + "poolAddress", + "walletAddress", + "baseTokenAddress", + "quoteTokenAddress", + "lpTokenAmount", + "baseTokenAmount", + "quoteTokenAmount", + "price" + ] + } + } + } + } + } + } + }, + "/trading/amm/positions-owned": { + "get": { + "tags": [ + "/trading/amm" + ], + "description": "List all AMM positions a wallet owns across pools. Supported only for non-fungible-LP AMMs (meteora DAMM v2). Fungible-LP AMMs (raydium, uniswap, pancakeswap) have no enumerable positions — use position-info with a specific pool address instead.", + "parameters": [ + { + "schema": { + "enum": [ + "meteora", + "raydium", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string" + }, + "example": "meteora", + "in": "query", + "name": "connector", + "required": true, + "description": "AMM connector (only non-fungible-LP AMMs supported: meteora)" + }, + { + "schema": { + "default": "solana-mainnet-beta", + "type": "string" + }, + "example": "solana-mainnet-beta", + "in": "query", + "name": "chainNetwork", + "required": true, + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" + }, + { + "schema": { + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "in": "query", + "name": "walletAddress", + "required": true, + "description": "Wallet address to list positions for" + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "poolAddress": { + "type": "string" + }, + "walletAddress": { + "type": "string" + }, + "baseTokenAddress": { + "type": "string" + }, + "quoteTokenAddress": { + "type": "string" + }, + "lpTokenAmount": { + "type": "number" + }, + "baseTokenAmount": { + "type": "number" + }, + "quoteTokenAmount": { + "type": "number" + }, + "price": { + "type": "number" + }, + "positions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "positionAddress": { + "description": "Address of the individual position (NFT position account)", + "type": "string" + }, + "lpTokenAmount": { + "format": "decimal", + "description": "Liquidity held by this position (LP units)", + "type": "number" + }, + "baseTokenAmount": { + "type": "number" + }, + "quoteTokenAmount": { + "type": "number" + } + }, + "required": [ + "positionAddress", + "lpTokenAmount", + "baseTokenAmount", + "quoteTokenAmount" + ] + } + } + }, + "required": [ + "poolAddress", + "walletAddress", + "baseTokenAddress", + "quoteTokenAddress", + "lpTokenAmount", + "baseTokenAmount", + "quoteTokenAmount", + "price" + ] + } + } + } + } + } + } + } + }, + "/trading/amm/quote-liquidity": { + "get": { + "tags": [ + "/trading/amm" + ], + "description": "Quote amounts for adding liquidity to an AMM pool from any supported connector", + "parameters": [ + { + "schema": { + "enum": [ + "meteora", + "raydium", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string" + }, + "example": "meteora", + "in": "query", + "name": "connector", + "required": true, + "description": "AMM connector" + }, + { + "schema": { + "default": "solana-mainnet-beta", + "type": "string" + }, + "example": "solana-mainnet-beta", + "in": "query", + "name": "chainNetwork", + "required": true, + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" + }, + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "poolAddress", + "required": true, + "description": "Pool contract address" + }, + { + "schema": { + "format": "decimal", + "type": "number" + }, + "in": "query", + "name": "baseTokenAmount", + "required": true, + "description": "Amount of base token to deposit" + }, + { + "schema": { + "format": "decimal", + "type": "number" + }, + "in": "query", + "name": "quoteTokenAmount", + "required": true, + "description": "Amount of quote token to deposit" + }, + { + "schema": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "type": "number" + }, + "example": 1, + "in": "query", + "name": "slippagePct", + "required": false, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct." + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "baseLimited": { + "type": "boolean" + }, + "baseTokenAmount": { + "type": "number" + }, + "quoteTokenAmount": { + "type": "number" + }, + "baseTokenAmountMax": { + "type": "number" + }, + "quoteTokenAmountMax": { + "type": "number" + } + }, + "required": [ + "baseLimited", + "baseTokenAmount", + "quoteTokenAmount", + "baseTokenAmountMax", + "quoteTokenAmountMax" + ] + } + } + } + } + } + } + }, + "/trading/amm/quote-swap": { + "get": { + "tags": [ + "/trading/amm" + ], + "description": "Get a swap quote from a single AMM pool", + "parameters": [ + { + "schema": { + "default": "solana-mainnet-beta", + "type": "string" + }, + "example": "solana-mainnet-beta", + "in": "query", + "name": "chainNetwork", + "required": true, + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" + }, + { + "schema": { + "enum": [ + "meteora", + "raydium", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string" + }, + "example": "meteora", + "in": "query", + "name": "connector", + "required": false, + "description": "AMM connector to price the swap against" + }, + { + "schema": { + "default": "SOL", + "type": "string" + }, + "in": "query", + "name": "baseToken", + "required": true, + "description": "Symbol or address of the base token" + }, + { + "schema": { + "default": "USDC", + "type": "string" + }, + "in": "query", + "name": "quoteToken", + "required": true, + "description": "Symbol or address of the quote token" + }, + { + "schema": { + "format": "decimal", + "default": 1, + "type": "number" + }, + "in": "query", + "name": "amount", + "required": true, + "description": "Amount of base token to trade" + }, + { + "schema": { + "enum": [ + "BUY", + "SELL" + ], + "default": "SELL", + "type": "string" + }, + "in": "query", + "name": "side", + "required": true, + "description": "BUY means buying base token with quote token, SELL means selling base token for quote token" + }, + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "poolAddress", + "required": false, + "description": "Pool to trade against. Omit to resolve it from Gateway's configured pool list by token pair; pass an address to pin a pool that is not in that list." + }, + { + "schema": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "type": "number" + }, + "example": 1, + "in": "query", + "name": "slippagePct", + "required": false, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct." + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tokenIn": { + "description": "Address of the token being swapped from", + "type": "string" + }, + "tokenOut": { + "description": "Address of the token being swapped to", + "type": "string" + }, + "amountIn": { + "format": "decimal", + "description": "Amount of tokenIn to be swapped", + "type": "number" + }, + "amountOut": { + "format": "decimal", + "description": "Expected amount of tokenOut to receive", + "type": "number" + }, + "price": { + "format": "decimal", + "description": "Exchange rate between tokenIn and tokenOut", + "type": "number" + }, + "priceImpactPct": { + "format": "decimal", + "description": "Estimated price impact percentage (0-100)", + "type": "number" + }, + "minAmountOut": { + "format": "decimal", + "description": "Minimum amount of tokenOut that will be accepted", + "type": "number" + }, + "maxAmountIn": { + "format": "decimal", + "description": "Maximum amount of tokenIn that will be spent", + "type": "number" + }, + "poolAddress": { + "description": "Pool address for AMM/CLMM swaps", + "type": "string" + }, + "routePath": { + "description": "Route path for router-based swaps", + "type": "string" + }, + "slippagePct": { + "format": "decimal", + "description": "Slippage tolerance percentage", + "type": "number" + } + }, + "required": [ + "tokenIn", + "tokenOut", + "amountIn", + "amountOut", + "price", + "priceImpactPct", + "minAmountOut", + "maxAmountIn" + ] + } + } + } + } + } + } + }, + "/trading/amm/execute-swap": { + "post": { + "tags": [ + "/trading/amm" + ], + "description": "Execute a swap against a single AMM pool", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "connector": { + "description": "AMM connector to execute the swap against", + "enum": [ + "meteora", + "raydium", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" + }, + "walletAddress": { + "description": "Wallet address that will execute the swap", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "baseToken": { + "description": "Symbol or address of the base token", + "default": "SOL", + "type": "string" + }, + "quoteToken": { + "description": "Symbol or address of the quote token", + "default": "USDC", + "type": "string" + }, + "amount": { + "format": "decimal", + "description": "Amount of base token to trade", + "default": 0.01, + "type": "number" + }, + "side": { + "description": "BUY means buying base token with quote token, SELL means selling base token for quote token", + "enum": [ + "BUY", + "SELL" + ], + "default": "SELL", + "type": "string" + }, + "poolAddress": { + "description": "Pool to trade against. Omit to resolve it from Gateway's configured pool list by token pair; pass an address to pin a pool that is not in that list.", + "type": "string" + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 + } + }, + "required": [ + "chainNetwork", + "walletAddress", + "baseToken", + "quoteToken", + "amount", + "side" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "signature": { + "description": "Transaction signature/hash", + "type": "string" + }, + "status": { + "description": "Transaction status: 0 = PENDING, 1 = CONFIRMED, -1 = FAILED", + "type": "number" + }, + "data": { + "type": "object", + "properties": { + "tokenIn": { + "description": "Address of the token swapped from", + "type": "string" + }, + "tokenOut": { + "description": "Address of the token swapped to", + "type": "string" + }, + "amountIn": { + "format": "decimal", + "description": "Actual amount of tokenIn swapped", + "type": "number" + }, + "amountOut": { + "format": "decimal", + "description": "Actual amount of tokenOut received", + "type": "number" + }, + "fee": { + "format": "decimal", + "description": "Transaction fee paid", + "type": "number" + }, + "baseTokenBalanceChange": { + "format": "decimal", + "description": "Change in base token balance (negative for decrease)", + "type": "number" + }, + "quoteTokenBalanceChange": { + "format": "decimal", + "description": "Change in quote token balance (negative for decrease)", + "type": "number" + }, + "slippagePct": { + "format": "decimal", + "description": "Slippage tolerance percentage actually applied to the swap", + "type": "number" + }, + "poolAddress": { + "description": "Pool the swap executed against. Set by the pool-scoped routes (/trading/clmm, /trading/amm), which resolve exactly one pool; a router picks its own path across pools and leaves this unset. Without it a settled fill cannot be reconciled to a venue without refetching the transaction.", + "type": "string" + } + }, + "required": [ + "tokenIn", + "tokenOut", + "amountIn", + "amountOut", + "fee", + "baseTokenBalanceChange", + "quoteTokenBalanceChange" + ] + } + }, + "required": [ + "signature", + "status" + ] + } + } + } + } + } + } + }, + "/trading/amm/open": { + "post": { + "tags": [ + "/trading/amm" + ], + "description": "Open a position with initial liquidity. On AMMs whose positions are discrete accounts (meteora DAMM v2) this mints the position and returns its address and rent; on fungible-LP AMMs (raydium, uniswap, pancakeswap) it performs the equivalent deposit and returns neither.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "connector": { + "description": "AMM connector", + "enum": [ + "meteora", + "raydium", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" + }, + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "walletAddress": { + "description": "Wallet that will own the position", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "poolAddress": { + "description": "Pool to open the position in", + "type": "string" + }, + "baseTokenAmount": { + "format": "decimal", + "description": "Amount of base token to deposit", + "type": "number" + }, + "quoteTokenAmount": { + "format": "decimal", + "description": "Amount of quote token to deposit", + "type": "number" + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 + } + }, + "required": [ + "connector", + "chainNetwork", + "walletAddress", + "poolAddress", + "baseTokenAmount", + "quoteTokenAmount" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "signature": { + "type": "string" + }, + "status": { + "description": "TransactionStatus enum value", + "type": "number" + }, + "data": { + "type": "object", + "properties": { + "fee": { + "format": "decimal", + "type": "number" + }, + "positionAddress": { + "description": "Address of the newly opened position. Absent on fungible-LP AMMs, which hold liquidity as LP tokens rather than a position account.", + "type": "string" + }, + "positionRent": { + "format": "decimal", + "description": "Native token locked as rent for the position account, refunded on close. 0 on fungible-LP AMMs, which lock no rent.", + "type": "number" + }, + "baseTokenAmountAdded": { + "format": "decimal", + "type": "number" + }, + "quoteTokenAmountAdded": { + "format": "decimal", + "type": "number" + } + }, + "required": [ + "fee", + "positionRent", + "baseTokenAmountAdded", + "quoteTokenAmountAdded" + ] + } + }, + "required": [ + "signature", + "status" + ] + } + } + } + } + } + } + }, + "/trading/amm/add": { + "post": { + "tags": [ + "/trading/amm" + ], + "description": "Add liquidity to an AMM pool from any supported connector", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "connector": { + "description": "AMM connector", + "enum": [ + "meteora", + "raydium", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" + }, + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "walletAddress": { + "description": "Wallet address", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "poolAddress": { + "description": "Pool contract address", + "type": "string" + }, + "baseTokenAmount": { + "format": "decimal", + "description": "Amount of base token to add", + "type": "number" + }, + "quoteTokenAmount": { + "format": "decimal", + "description": "Amount of quote token to add", + "type": "number" + }, + "positionAddress": { + "x-connectors": [ + "meteora" + ], + "description": "meteora only (DAMM v2 positions are NFTs): add to this specific position. Omit to open a new position. Ignored by fungible-LP AMMs.", + "type": "string" + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 + } + }, + "required": [ + "connector", + "chainNetwork", + "walletAddress", + "poolAddress", + "baseTokenAmount", + "quoteTokenAmount" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "signature": { + "type": "string" + }, + "status": { + "description": "TransactionStatus enum value", + "type": "number" + }, + "data": { + "type": "object", + "properties": { + "fee": { + "format": "decimal", + "type": "number" + }, + "positionAddress": { + "description": "Position the liquidity went into. Absent on fungible-LP AMMs, which hold liquidity as LP tokens rather than a position account.", + "x-connectors": [ + "meteora" + ], + "type": "string" + }, + "positionRent": { + "format": "decimal", + "description": "Native token locked as rent when this call opened the position. Absent when adding to a position that already existed, and on fungible-LP AMMs.", + "x-connectors": [ + "meteora" + ], + "type": "number" + }, + "baseTokenAmountAdded": { + "format": "decimal", + "type": "number" + }, + "quoteTokenAmountAdded": { + "format": "decimal", + "type": "number" + } + }, + "required": [ + "fee", + "baseTokenAmountAdded", + "quoteTokenAmountAdded" + ] + } + }, + "required": [ + "signature", + "status" + ] + } + } + } + } + } + } + }, + "/trading/amm/remove": { + "post": { + "tags": [ + "/trading/amm" + ], + "description": "Remove liquidity from an AMM pool from any supported connector", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "connector": { + "description": "AMM connector", + "enum": [ + "meteora", + "raydium", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" + }, + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "walletAddress": { + "description": "Wallet address", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "poolAddress": { + "description": "Pool contract address", + "type": "string" + }, + "positionAddress": { + "x-connectors": [ + "meteora" + ], + "description": "Required for meteora (DAMM v2 positions are NFTs): the specific position to remove from. List positions with position-info or positions-owned. Ignored by fungible-LP AMMs.", + "type": "string" + }, + "percentageToRemove": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Percentage of liquidity to remove", + "default": 100, + "type": "number", + "example": 100 + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 + } + }, + "required": [ + "connector", + "chainNetwork", + "walletAddress", + "poolAddress", + "percentageToRemove" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "signature": { + "type": "string" + }, + "status": { + "description": "TransactionStatus enum value", + "type": "number" + }, + "data": { + "type": "object", + "properties": { + "fee": { + "type": "number" + }, + "baseTokenAmountRemoved": { + "type": "number" + }, + "quoteTokenAmountRemoved": { + "type": "number" + } + }, + "required": [ + "fee", + "baseTokenAmountRemoved", + "quoteTokenAmountRemoved" + ] + } + }, + "required": [ + "signature", + "status" + ] + } + } + } + } + } + } + }, + "/trading/amm/close": { + "post": { + "tags": [ + "/trading/amm" + ], + "description": "Withdraw all of a position's liquidity. On AMMs whose positions are discrete accounts (meteora DAMM v2) this also closes the position account and refunds its rent; on fungible-LP AMMs (raydium, uniswap, pancakeswap) it withdraws the full LP balance and refunds no rent.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "connector": { + "description": "AMM connector", + "enum": [ + "meteora", + "raydium", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" + }, + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "walletAddress": { + "description": "Wallet that owns the position", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "poolAddress": { + "description": "Pool the position belongs to", + "type": "string" + }, + "positionAddress": { + "description": "Position to close. Required on AMMs whose positions are discrete accounts (meteora DAMM v2), where a wallet may hold several per pool. Ignored by fungible-LP AMMs, which hold one LP balance per pool.", + "x-connectors": [ + "meteora" + ], + "type": "string" + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage on the withdrawn amounts.", + "type": "number", + "example": 1 + } + }, + "required": [ + "connector", + "chainNetwork", + "walletAddress", + "poolAddress" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "signature": { + "type": "string" + }, + "status": { + "description": "TransactionStatus enum value", + "type": "number" + }, + "data": { + "type": "object", + "properties": { + "fee": { + "format": "decimal", + "type": "number" + }, + "positionRentRefunded": { + "format": "decimal", + "description": "Native token rent returned when the position account closed. 0 on fungible-LP AMMs, which have no position account to close.", + "type": "number" + }, + "baseTokenAmountRemoved": { + "format": "decimal", + "type": "number" + }, + "quoteTokenAmountRemoved": { + "format": "decimal", + "type": "number" + } + }, + "required": [ + "fee", + "positionRentRefunded", + "baseTokenAmountRemoved", + "quoteTokenAmountRemoved" + ] + } + }, + "required": [ + "signature", + "status" + ] + } + } + } + } + } + } + }, + "/trading/amm/create-pool": { + "post": { + "tags": [ + "/trading/amm" + ], + "description": "Create and seed a new AMM pool across supported connectors (Meteora DAMM v2, Raydium CPMM, Uniswap V2, PancakeSwap V2)", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "connector": { + "description": "AMM connector", + "enum": [ + "meteora", + "raydium", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" + }, + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "walletAddress": { + "description": "Wallet address (pool creator + payer)", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "baseToken": { + "description": "Base token symbol or address (becomes the pool base)", + "type": "string" + }, + "quoteToken": { + "description": "Quote token symbol or address (becomes the pool quote)", + "type": "string" + }, + "baseTokenAmount": { + "format": "decimal", + "description": "Amount of base token to seed the pool with", + "type": "number" + }, + "quoteTokenAmount": { + "format": "decimal", + "description": "Amount of quote token to seed with. If provided, the base:quote ratio sets the initial price. If omitted (and no initialPrice), the price is fetched from the market.", + "type": "number" + }, + "initialPrice": { + "format": "decimal", + "description": "Initial price as quote per base. Overrides quoteTokenAmount. If both are omitted, the current market price is fetched from the unified swap router so the pool opens on-market.", + "type": "number" + }, + "configAddress": { + "x-connectors": [ + "meteora" + ], + "description": "Meteora DAMM v2 config account address (required for the meteora connector — configs are permissionless accounts with no index derivation, so the address must be explicit).", + "type": "string" + }, + "ammConfigIndex": { + "x-connectors": [ + "raydium" + ], + "description": "Raydium CPMM fee-config index (optional; defaults to the first available config).", + "type": "number" + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Uniswap/PancakeSwap seeding slippage percentage. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 + } + }, + "required": [ + "connector", + "chainNetwork", + "walletAddress", + "baseToken", + "quoteToken", + "baseTokenAmount" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "signature": { + "type": "string" + }, + "status": { + "description": "TransactionStatus enum value", + "type": "number" + }, + "poolAddress": { + "description": "Address of the newly created pool", + "type": "string" + }, + "price": { + "format": "decimal", + "description": "Initial price the pool was seeded at (quote per base)", + "type": "number" + }, + "data": { + "type": "object", + "properties": { + "fee": { + "type": "number" + }, + "baseTokenAmountAdded": { + "type": "number" + }, + "quoteTokenAmountAdded": { + "type": "number" + } + }, + "required": [ + "fee", + "baseTokenAmountAdded", + "quoteTokenAmountAdded" + ] + } + }, + "required": [ + "signature", + "status", + "poolAddress" + ] + } + } + } + } + } + } + }, + "/chains/{chain}/status": { + "get": { + "tags": [ + "/chains" + ], + "description": "Get the status of a chain and network", + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "network", + "required": false + }, + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "chain", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "chain": { + "type": "string" + }, + "network": { + "type": "string" + }, + "rpcUrl": { + "type": "string" + }, + "rpcProvider": { + "type": "string" + }, + "currentBlockNumber": { + "type": "number" + }, + "nativeCurrency": { + "type": "string" + }, + "swapProvider": { + "type": "string" + } + }, + "required": [ + "chain", + "network", + "rpcUrl", + "rpcProvider", + "currentBlockNumber", + "nativeCurrency", + "swapProvider" + ] + } + } + } + } + } + } + }, + "/chains/{chain}/estimate-gas": { + "get": { + "tags": [ + "/chains" + ], + "description": "Estimate the current transaction fee on a chain", + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "network", + "required": false + }, + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "chain", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "feePerComputeUnit": { + "type": "number" + }, + "denomination": { + "type": "string" + }, + "computeUnits": { + "type": "number" + }, + "feeAsset": { + "type": "string" + }, + "fee": { + "type": "number" + }, + "timestamp": { + "type": "number" + }, + "gasType": { + "type": "string" + }, + "maxFeePerGas": { + "type": "number" + }, + "maxPriorityFeePerGas": { + "type": "number" + }, + "priorityFeeLevel": { + "type": "string" + }, + "priorityFeePerCUEstimate": { + "type": "number" + } + }, + "required": [ + "feePerComputeUnit", + "denomination", + "computeUnits", + "feeAsset", + "fee", + "timestamp" + ] + } + } + } + } + } + } + }, + "/chains/{chain}/balances": { + "post": { + "tags": [ + "/chains" + ], + "description": "Get token balances for a wallet", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "network": { + "type": "string" + }, + "address": { + "type": "string" + }, + "tokens": { + "description": "a list of token symbols or addresses", + "type": "array", + "items": { + "type": "string" + } + }, + "fetchAll": { + "description": "fetch all tokens in wallet, not just those in token list (default: false)", + "type": "boolean" + } + } + } + } + } + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "chain", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "balances": { + "type": "object", + "additionalProperties": { + "type": "number" + } + } + }, + "required": [ + "balances" + ] + } + } + } + } + } + } + }, + "/chains/{chain}/poll": { + "post": { + "tags": [ + "/chains" + ], + "description": "Poll a transaction by signature/hash", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "network": { + "type": "string" + }, + "signature": { + "description": "Transaction signature/hash", + "type": "string" + } + }, + "required": [ + "signature" + ] + } + } + }, + "required": true + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "chain", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "currentBlock": { + "type": "number" + }, + "signature": { + "type": "string" + }, + "txBlock": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "txStatus": { + "description": "Transaction status: 1 = confirmed, 0 = pending, -1 = failed, -2 = not found (unknown to the chain: never received or dropped; on Solana this is terminal once the transaction blockhash expires)", + "type": "number" + }, + "fee": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "error": { + "anyOf": [ + { + "description": "Error info if failed: \"TYPE (code): message\"", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "txData": { + "anyOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "currentBlock", + "signature", + "txBlock", + "txStatus", + "fee", + "error", + "txData" + ] + } + } + } + } + } + } + }, + "/chains/{chain}/wrap": { + "post": { + "tags": [ + "/chains" + ], + "description": "Wrap native token into its wrapped form (SOL to WSOL, ETH to WETH, ...)", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "network": { + "type": "string" + }, + "address": { + "description": "Wallet address holding the native token", + "type": "string" + }, + "amount": { + "description": "Amount of the native token to wrap, in whole units (not lamports/wei)", + "type": "string", + "example": "1.0" + } + }, + "required": [ + "address", + "amount" + ] + } + } + }, + "required": true + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "chain", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "signature": { + "type": "string" + }, + "status": { + "description": "TransactionStatus enum value", + "type": "number" + }, + "data": { + "type": "object", + "properties": { + "nonce": { + "description": "EVM transaction nonce; absent on non-EVM chains", + "type": "number" + }, + "fee": { + "type": "string" + }, + "amount": { + "type": "string" + }, + "wrappedAddress": { + "type": "string" + }, + "nativeToken": { + "type": "string" + }, + "wrappedToken": { + "type": "string" + } + }, + "required": [ + "fee", + "amount", + "wrappedAddress", + "nativeToken", + "wrappedToken" + ] + } + }, + "required": [ + "signature", + "status" + ] + } + } + } + } + } + } + }, + "/chains/{chain}/unwrap": { + "post": { + "tags": [ + "/chains" + ], + "description": "Unwrap a wrapped native token back into the native token", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "network": { + "type": "string" + }, + "address": { + "description": "Wallet address holding the wrapped token", + "type": "string" + }, + "amount": { + "description": "Amount of the wrapped token to unwrap, in whole units. Solana unwraps the full balance when omitted; EVM chains require it.", + "type": "string", + "example": "1.0" + } + }, + "required": [ + "address" + ] + } + } + }, + "required": true + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "chain", + "required": true + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "signature": { + "type": "string" + }, + "status": { + "description": "TransactionStatus enum value", + "type": "number" + }, + "data": { + "type": "object", + "properties": { + "nonce": { + "description": "EVM transaction nonce; absent on non-EVM chains", + "type": "number" + }, + "fee": { + "type": "string" + }, + "amount": { + "type": "string" + }, + "wrappedAddress": { + "type": "string" + }, + "nativeToken": { + "type": "string" + }, + "wrappedToken": { + "type": "string" + } + }, + "required": [ + "fee", + "amount", + "wrappedAddress", + "nativeToken", + "wrappedToken" + ] + } + }, + "required": [ + "signature", + "status" + ] + } + } + } + } + } + } + }, + "/chains/ethereum/allowances": { + "post": { + "tags": [ + "/chain/ethereum" + ], + "description": "Get token allowances", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "network": { + "description": "The Ethereum network to use", + "default": "mainnet", + "enum": [ + "arbitrum", + "avalanche", + "base", + "bsc", + "celo", + "mainnet", + "optimism", + "polygon", + "robinhoodchain-testnet", + "robinhoodchain", + "sepolia", + "unichain" + ], + "type": "string" + }, + "address": { + "description": "Ethereum wallet address", + "default": "0xDA50C69342216b538Daf06FfECDa7363E0B96684", + "type": "string" + }, + "spender": { + "description": "Connector name (e.g., uniswap/clmm, uniswap/amm, 0x/router) or contract address", + "type": "string", + "example": "uniswap/router" + }, + "tokens": { + "description": "Array of token symbols or addresses", + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "USDC", + "WETH" + ] + } + }, + "required": [ + "spender", + "tokens" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "spender": { + "type": "string" + }, + "approvals": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "spender", + "approvals" + ] + } + } + } + } + } + } + }, + "/chains/ethereum/approve": { + "post": { + "tags": [ + "/chain/ethereum" + ], + "description": "Approve token spending", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "network": { + "description": "The Ethereum network to use", + "default": "mainnet", + "enum": [ + "arbitrum", + "avalanche", + "base", + "bsc", + "celo", + "mainnet", + "optimism", + "polygon", + "robinhoodchain-testnet", + "robinhoodchain", + "sepolia", + "unichain" + ], + "type": "string" + }, + "address": { + "description": "Ethereum wallet address", + "default": "0xDA50C69342216b538Daf06FfECDa7363E0B96684", + "type": "string" + }, + "spender": { + "description": "Connector name (e.g., uniswap/clmm, uniswap/amm, 0x/router) contract address", + "type": "string", + "example": "uniswap/router" + }, + "token": { + "description": "Token symbol or address", + "type": "string", + "example": "USDC" + }, + "amount": { + "description": "The amount to approve. If not provided, defaults to maximum amount (unlimited approval).", + "default": "", + "type": "string" + } + }, + "required": [ + "spender", + "token" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "signature": { + "type": "string" + }, + "status": { + "description": "TransactionStatus enum value", + "type": "number" + }, + "data": { + "type": "object", + "properties": { + "tokenAddress": { + "type": "string" + }, + "spender": { + "type": "string" + }, + "amount": { + "type": "string" + }, + "nonce": { + "type": "number" + }, + "fee": { + "type": "string" + } + }, + "required": [ + "tokenAddress", + "spender", + "amount", + "nonce", + "fee" + ] + } + }, + "required": [ + "signature", + "status" + ] + } + } + } + } + } + } + } + }, + "servers": [ + { + "url": "http://localhost:15889" + } + ], + "tags": [ + { + "name": "/config", + "description": "System configuration endpoints" + }, + { + "name": "/wallet", + "description": "Wallet management endpoints" + }, + { + "name": "/tokens", + "description": "Token management endpoints" + }, + { + "name": "/pools", + "description": "Pool management endpoints" + }, + { + "name": "/chains", + "description": "Chain endpoints, parameterized by chain" + }, + { + "name": "/trading/router", + "description": "Swaps routed across pools by a router connector" + }, + { + "name": "/trading/clmm", + "description": "Concentrated-liquidity pools: swaps, positions, and pool management" + }, + { + "name": "/trading/amm", + "description": "Constant-product pools: swaps, liquidity, and pool management" + } + ] +} diff --git a/models/gateway_trading.py b/models/gateway_trading.py index 93a8ef66..7670dbb1 100644 --- a/models/gateway_trading.py +++ b/models/gateway_trading.py @@ -34,7 +34,7 @@ class SwapQuoteRequest(BaseModel): class SwapQuoteResponse(BaseModel): """Swap quote, re-framed from Gateway's token-flow response into trading-pair terms. - Gateway's /trading/swap/quote speaks tokenIn/tokenOut; this keeps the base/quote + + Gateway's quote-swap routes speak tokenIn/tokenOut; this keeps the base/quote + side framing bots use and passes Gateway's execution-safety fields through in snake_case. No gas estimate: Gateway's quote does not return one. """ @@ -405,7 +405,11 @@ class AMMPositionInfoResponse(BaseModel): class AMMTransactionResponse(BaseModel): """Chain-neutral write response. `signature` holds the tx signature (Solana) or tx hash (EVM).""" signature: str = Field(description="Transaction signature (Solana) or transaction hash (EVM)") - status: int = Field(description="TransactionStatus enum value from Gateway") + status: str = Field( + description="Transaction status: SUBMITTED, CONFIRMED or FAILED. Mapped from " + "Gateway's TransactionStatus enum by the same helper the swap and " + "CLMM surfaces use, so one vocabulary spans all three." + ) data: Optional[Dict[str, Any]] = Field(default=None, description="Connector-specific confirmed-tx details") model_config = {"populate_by_name": True} @@ -482,7 +486,11 @@ class AMMCreatePoolRequest(BaseModel): class AMMCreatePoolResponse(BaseModel): """Response after creating an AMM pool.""" signature: str = Field(description="Transaction signature (Solana) or transaction hash (EVM)") - status: int = Field(description="TransactionStatus enum value from Gateway") + status: str = Field( + description="Transaction status: SUBMITTED, CONFIRMED or FAILED. Mapped from " + "Gateway's TransactionStatus enum by the same helper the swap and " + "CLMM surfaces use, so one vocabulary spans all three." + ) pool_address: str = Field(alias="poolAddress", description="Address of the newly created pool") price: Optional[Decimal] = Field(default=None, description="Initial price the pool was seeded at (quote per base)") data: Optional[Dict[str, Any]] = Field(default=None, description="Connector-specific confirmed-tx details") diff --git a/routers/gateway_amm.py b/routers/gateway_amm.py index 607c1a4f..a8768448 100644 --- a/routers/gateway_amm.py +++ b/routers/gateway_amm.py @@ -5,18 +5,30 @@ deliberately separate surface from CLMM: AMM was previously removed from hummingbot-api and is re-added here to expose Gateway's standardized /trading/amm/* routes. -Stateless by design (no position persistence) — the caller/agent holds position state. Meteora -DAMM v2 positions are NFTs, so the routes are position-addressed: remove requires position_address, -add 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 ignore position_address and -Gateway rejects positions-owned for them with a 400, surfaced here as-is. +Every liquidity WRITE is persisted to gateway_amm_events — the AMM history for all connectors. +Without it a deposit existed only on-chain and in Gateway's live view, with no record here that +it happened and no gas accounting at all. + +Meteora DAMM v2 positions are NFTs with their own identity, so they additionally get tracked +rows in gateway_amm_positions, carrying deposited capital, held amounts and a base-weighted +entry price — the same treatment CLMM positions get. The routes are position-addressed for +them: remove requires position_address, add takes it optionally (omit = new position), +position-info returns a positions[] breakdown, and positions-owned lists a wallet's positions. + +Fungible-LP AMMs (Raydium CPMM, Uniswap/PancakeSwap V2) have no position identity, so they get +events only; their holdings are the LP token balance, read live from Gateway. They ignore +position_address, and Gateway rejects positions-owned for them with a 400, surfaced as-is. """ import logging -from typing import List, Optional +from decimal import Decimal +from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, HTTPException -from deps import get_accounts_service +from database import AsyncDatabaseManager +from database.repositories import GatewayAMMRepository +from database.repositories.gateway_amm_repository import has_nft_positions +from deps import get_accounts_service, get_database_manager from models import ( AMMAddLiquidityRequest, AMMCreatePoolRequest, @@ -29,9 +41,9 @@ AMMRemoveLiquidityRequest, AMMTransactionResponse, ) -from routers.gateway_extras import ExtraParamsSpec, validate_extra_params +from routers.gateway_extras import ExtraParamsSpec, get_transaction_status_from_response, validate_extra_params from services.accounts_service import AccountsService -from services.gateway_client import GatewayError, check_gateway_error +from services.gateway_client import GatewayError, check_gateway_error, get_native_gas_token logger = logging.getLogger(__name__) @@ -58,6 +70,170 @@ async def _resolve_wallet(accounts_service: AccountsService, network: str, walle ) +async def _resolve_new_position_address( + accounts_service: AccountsService, + db_manager: AsyncDatabaseManager, + connector: str, + network: str, + wallet_address: str, + pool_address: str, +) -> Optional[str]: + """Find the position address an add just created, by diffing against what we track. + + Workaround for a Gateway gap (GW-6): the AMM add-liquidity response carries no + positionAddress, even though Gateway generates the NFT keypair itself and logs the + address. Delete this the moment that field exists — a diff cannot attribute an + address to a transaction and loses to a concurrent write on the same pool. + """ + try: + live = check_gateway_error(await accounts_service.gateway_client.amm_position_info( + connector=connector, chain_network=network, + pool_address=pool_address, wallet_address=wallet_address, + )) + on_chain = {p.get("positionAddress") for p in (live.get("positions") or []) if p.get("positionAddress")} + if not on_chain: + return None + async with db_manager.get_session_context() as session: + known = await GatewayAMMRepository(session).get_open_position_addresses( + wallet_address, pool_address) + new = on_chain - known + if len(new) == 1: + return new.pop() + if len(new) > 1: + logger.warning(f"{len(new)} untracked DAMM v2 positions in pool {pool_address}; " + "cannot attribute the add to one of them") + except Exception as e: + logger.warning(f"Could not resolve the new DAMM v2 position in pool {pool_address}: {e}") + return None + + +async def _read_pool( + accounts_service: AccountsService, + connector: str, + network: str, + pool_address: str, +) -> Dict[str, Any]: + """Pool state at the moment of a write — its price is the cost basis for the event. + + Read for every connector, not just the ones with position rows: a fungible-LP AMM has + nowhere else to record the price its capital went in or out at. A failure costs the + price, never the write — the liquidity has already moved by the time this is called. + """ + try: + return check_gateway_error(await accounts_service.gateway_client.amm_pool_info( + connector=connector, chain_network=network, pool_address=pool_address, + )) or {} + except Exception as e: + logger.warning(f"Could not read AMM pool {pool_address}; the write will be " + f"recorded without a price: {e}") + return {} + + +async def _book_position_add( + accounts_service: AccountsService, + db_manager: AsyncDatabaseManager, + request: AMMAddLiquidityRequest, + wallet_address: str, + position_address: str, + data: Dict[str, Any], + pool_info: Dict[str, Any], + price: Optional[float], +) -> None: + """Create or top up the DAMM v2 position row for a confirmed add.""" + base_added = data.get("baseTokenAmountAdded") or 0 + quote_added = data.get("quoteTokenAmountAdded") or 0 + + try: + async with db_manager.get_session_context() as session: + repo = GatewayAMMRepository(session) + existing = await repo.get_position_by_address(position_address) + if existing: + await repo.add_to_position_amounts( + position_address=position_address, + base_delta=Decimal(str(base_added)), + quote_delta=Decimal(str(quote_added)), + entry_price=Decimal(str(price)) if price else None, + ) + if existing.status == "CLOSED": + existing.status = "OPEN" + existing.closed_at = None + else: + chain, network_name = request.network.split("-", 1) + base_symbol = await accounts_service.gateway_client.resolve_token_symbol( + chain, network_name, pool_info.get("baseTokenAddress", "")) + quote_symbol = await accounts_service.gateway_client.resolve_token_symbol( + chain, network_name, pool_info.get("quoteTokenAddress", "")) + await repo.create_position({ + "position_address": position_address, + "pool_address": request.pool_address, + "connector": request.connector.split("/")[0], + "network": request.network, + "wallet_address": wallet_address, + "base_token": base_symbol, + "quote_token": quote_symbol, + "trading_pair": f"{base_symbol}-{quote_symbol}", + "initial_base_token_amount": base_added, + "initial_quote_token_amount": quote_added, + "base_token_amount": base_added, + "quote_token_amount": quote_added, + "entry_price": price, + "current_price": price, + }) + logger.info(f"Booked AMM position {position_address}: +{base_added} base, +{quote_added} quote") + except Exception as db_error: + logger.error(f"Error booking AMM position {position_address}: {db_error}", exc_info=True) + + +async def _record_event( + db_manager: AsyncDatabaseManager, + result: Dict[str, Any], + *, + event_type: str, + connector: str, + network: str, + wallet_address: str, + pool_address: str, + position_address: Optional[str], + base_amount_key: str, + quote_amount_key: str, + price: Optional[float] = None, +) -> str: + """Persist one AMM write and return its status in hapi's vocabulary. + + Amounts come from Gateway's `data`, present only once it confirmed the tx; a + submitted-not-confirmed write records the status with null amounts rather than + inventing figures. Recording never fails the operation — the liquidity has already + moved by the time we get here, so a database problem must not surface as a failed + write to the caller. + """ + tx_status = get_transaction_status_from_response(result) + data = result.get("data") or {} + chain, _ = network.split("-", 1) if "-" in network else (network, "") + + try: + async with db_manager.get_session_context() as session: + await GatewayAMMRepository(session).create_event({ + "transaction_hash": result.get("signature") or result.get("txHash") or "", + "connector": connector, + "network": network, + "wallet_address": wallet_address, + "pool_address": pool_address, + "position_address": position_address, + "event_type": event_type, + "base_token_amount": data.get(base_amount_key), + "quote_token_amount": data.get(quote_amount_key), + "price": price, + "gas_fee": data.get("fee"), + "gas_token": get_native_gas_token(chain) if data.get("fee") is not None else None, + "status": tx_status, + }) + logger.info(f"Recorded AMM {event_type}: {result.get('signature')} (status: {tx_status})") + except Exception as db_error: + logger.error(f"Error recording AMM {event_type} event: {db_error}", exc_info=True) + + return tx_status + + # ----------------------------- Reads ----------------------------- @router.get("/amm/pool-info", response_model=AMMPoolInfoResponse, response_model_by_alias=False) @@ -174,6 +350,7 @@ async def quote_amm_liquidity( async def add_amm_liquidity( request: AMMAddLiquidityRequest, accounts_service: AccountsService = Depends(get_accounts_service), + db_manager: AsyncDatabaseManager = Depends(get_database_manager), ): """ Add two-sided liquidity to an AMM pool. @@ -191,7 +368,36 @@ async def add_amm_liquidity( slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else None, position_address=request.position_address, )) - return AMMTransactionResponse(**result) + data = result.get("data") or {} + confirmed = get_transaction_status_from_response(result) == "CONFIRMED" + position_address = request.position_address + + pool_info = await _read_pool(accounts_service, request.connector, + request.network, request.pool_address) + price = float(pool_info["price"]) if pool_info.get("price") else None + + # DAMM v2 positions are NFTs and get tracked individually; fungible-LP AMMs have + # no position identity, so for them this block is a no-op and the event log — with + # its price — is the entire record. + if confirmed and has_nft_positions(request.connector): + if position_address is None: + position_address = await _resolve_new_position_address( + accounts_service, db_manager, request.connector, request.network, + wallet_address, request.pool_address) + if position_address: + await _book_position_add( + accounts_service, db_manager, request, wallet_address, position_address, + data, pool_info, price) + + tx_status = await _record_event( + db_manager, result, + event_type="ADD_LIQUIDITY", connector=request.connector, network=request.network, + wallet_address=wallet_address, pool_address=request.pool_address, + position_address=position_address, + base_amount_key="baseTokenAmountAdded", quote_amount_key="quoteTokenAmountAdded", + price=price, + ) + return AMMTransactionResponse(**{**result, "status": tx_status}) except HTTPException: raise except GatewayError as e: @@ -207,6 +413,7 @@ async def add_amm_liquidity( async def remove_amm_liquidity( request: AMMRemoveLiquidityRequest, accounts_service: AccountsService = Depends(get_accounts_service), + db_manager: AsyncDatabaseManager = Depends(get_database_manager), ): """ Remove liquidity from an AMM pool. @@ -223,7 +430,38 @@ async def remove_amm_liquidity( slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else None, position_address=request.position_address, )) - return AMMTransactionResponse(**result) + data = result.get("data") or {} + pool_info = await _read_pool(accounts_service, request.connector, + request.network, request.pool_address) + price = float(pool_info["price"]) if pool_info.get("price") else None + + if (get_transaction_status_from_response(result) == "CONFIRMED" + and has_nft_positions(request.connector) and request.position_address): + try: + async with db_manager.get_session_context() as session: + repo = GatewayAMMRepository(session) + position = await repo.subtract_from_position_amounts( + position_address=request.position_address, + base_delta=Decimal(str(data.get("baseTokenAmountRemoved") or 0)), + quote_delta=Decimal(str(data.get("quoteTokenAmountRemoved") or 0)), + ) + # DAMM v2 burns the position NFT on a full withdrawal, so a 100% + # remove is the close — there is no separate close route. + if position and float(request.percentage_to_remove) >= 100: + await repo.close_position(request.position_address) + except Exception as db_error: + logger.error(f"Error booking AMM removal for {request.position_address}: " + f"{db_error}", exc_info=True) + + tx_status = await _record_event( + db_manager, result, + event_type="REMOVE_LIQUIDITY", connector=request.connector, network=request.network, + wallet_address=wallet_address, pool_address=request.pool_address, + position_address=request.position_address, + base_amount_key="baseTokenAmountRemoved", quote_amount_key="quoteTokenAmountRemoved", + price=price, + ) + return AMMTransactionResponse(**{**result, "status": tx_status}) except HTTPException: raise except GatewayError as e: @@ -239,6 +477,7 @@ async def remove_amm_liquidity( async def create_amm_pool( request: AMMCreatePoolRequest, accounts_service: AccountsService = Depends(get_accounts_service), + db_manager: AsyncDatabaseManager = Depends(get_database_manager), ): """ Create and seed a new AMM pool. @@ -270,7 +509,19 @@ async def create_amm_pool( slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else None, extra_params=request.extra_params, )) - return AMMCreatePoolResponse(**result) + # The pool address only exists in the response, so it is read from there + # rather than the request, which names tokens. + tx_status = await _record_event( + db_manager, result, + event_type="CREATE_POOL", connector=request.connector, network=request.network, + wallet_address=wallet_address, + pool_address=result.get("poolAddress") or result.get("pool_address") or "", + position_address=None, + base_amount_key="baseTokenAmountAdded", quote_amount_key="quoteTokenAmountAdded", + # The seed price is in the create response; no pool exists to read yet. + price=float(result["price"]) if result.get("price") else None, + ) + return AMMCreatePoolResponse(**{**result, "status": tx_status}) except HTTPException: raise except GatewayError as e: @@ -280,3 +531,76 @@ async def create_amm_pool( except Exception as e: logger.error(f"Error creating AMM pool: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"Error creating AMM pool: {str(e)}") + + +@router.post("/amm/events/search") +async def search_amm_events( + connector: Optional[str] = None, + network: Optional[str] = None, + wallet_address: Optional[str] = None, + pool_address: Optional[str] = None, + event_type: Optional[str] = None, + status: Optional[str] = None, + limit: int = 50, + offset: int = 0, + db_manager: AsyncDatabaseManager = Depends(get_database_manager), +): + """ + Search recorded AMM liquidity writes, newest first. + + This is the AMM history: ADD_LIQUIDITY, REMOVE_LIQUIDITY and CREATE_POOL with their + on-chain amounts and gas. Current holdings are not here — read those live from + /gateway/amm/position-info, which is the only authority on them. + """ + try: + async with db_manager.get_session_context() as session: + repo = GatewayAMMRepository(session) + events = await repo.search_events( + connector=connector, network=network, wallet_address=wallet_address, + pool_address=pool_address, event_type=event_type, status=status, + limit=min(limit, 1000), offset=offset, + ) + return { + "data": [repo.event_to_dict(event) for event in events], + "total_count": len(events), + "limit": limit, + "offset": offset, + } + except Exception as e: + logger.error(f"Error searching AMM events: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Error searching AMM events: {str(e)}") + + +@router.post("/amm/positions/search") +async def search_amm_positions( + connector: Optional[str] = None, + network: Optional[str] = None, + wallet_address: Optional[str] = None, + pool_address: Optional[str] = None, + status: Optional[str] = None, + limit: int = 50, + offset: int = 0, + db_manager: AsyncDatabaseManager = Depends(get_database_manager), +): + """ + Search tracked AMM positions (Meteora DAMM v2 NFTs), newest first. + + Fungible-LP AMMs never appear here — they have no position identity. Their holdings + come from /gateway/amm/position-info and their history from /gateway/amm/events/search. + """ + try: + async with db_manager.get_session_context() as session: + repo = GatewayAMMRepository(session) + positions = await repo.search_positions( + connector=connector, network=network, wallet_address=wallet_address, + pool_address=pool_address, status=status, limit=min(limit, 1000), offset=offset, + ) + return { + "data": [repo.position_to_dict(position) for position in positions], + "total_count": len(positions), + "limit": limit, + "offset": offset, + } + except Exception as e: + logger.error(f"Error searching AMM positions: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Error searching AMM positions: {str(e)}") diff --git a/routers/gateway_clmm.py b/routers/gateway_clmm.py index 211ccc5f..039b80c7 100644 --- a/routers/gateway_clmm.py +++ b/routers/gateway_clmm.py @@ -243,6 +243,9 @@ async def get_clmm_pools( List of available pools with trading pairs, addresses, liquidity, volume, APR, etc. """ try: + # Both listing connectors are Solana-only, which is what makes the + # "solana-" prefix below safe: the endpoint takes a bare network name while + # Gateway's unified route keys on chain-network. supported_connectors = ["meteora", "orca"] if connector.lower() not in supported_connectors: raise HTTPException( @@ -260,7 +263,7 @@ async def get_clmm_pools( direction = order_by if order_by else "desc" gateway_data = check_gateway_error(await accounts_service.gateway_client.clmm_fetch_pools( connector="meteora", - network=network, + chain_network=f"solana-{network}", limit=limit, query=search_term, sort_by=f"{sort_key}{time_suffix}:{direction}" if sort_key else None, @@ -276,7 +279,7 @@ async def get_clmm_pools( ) gateway_data = check_gateway_error(await accounts_service.gateway_client.clmm_fetch_pools( connector="orca", - network=network, + chain_network=f"solana-{network}", limit=limit, query=search_term, sort_by=sort_key, @@ -620,6 +623,27 @@ async def add_liquidity_to_clmm_position( if quote_amount_added is None: quote_amount_added = float(request.quote_token_amount) if request.quote_token_amount else None + # Pool price at the moment of the add, used to weight the position's entry + # price. Gateway's add-liquidity response carries no price, so read it from + # the pool the position sits in. A failure here costs the weighting, not the + # add — the capital is already deposited. + add_price = None + try: + position_for_pool = None + async with db_manager.get_session_context() as session: + position_for_pool = await GatewayCLMMRepository(session).get_position_by_address( + request.position_address) + if position_for_pool: + pool_info = check_gateway_error(await accounts_service.gateway_client.clmm_pool_info( + connector=request.connector, + chain_network=request.network, + pool_address=position_for_pool.pool_address + )) + add_price = float(pool_info.get("price")) if pool_info.get("price") else None + except Exception as price_error: + logger.warning(f"Could not read pool price for ADD_LIQUIDITY {transaction_hash}; " + f"entry price will not be re-weighted: {price_error}") + # Store ADD_LIQUIDITY event in database try: async with db_manager.get_session_context() as session: @@ -643,15 +667,16 @@ async def add_liquidity_to_clmm_position( logger.info(f"Recorded CLMM ADD_LIQUIDITY event: {transaction_hash} " f"(status: {tx_status}, gas: {gas_fee} {gas_token})") - # Added capital raises the PnL baseline. Book here only when the - # tx confirmed inline (the event is created CONFIRMED and the - # poller never re-processes it); SUBMITTED events are booked by - # the poller's confirm path. + # Added capital raises both the PnL baseline and the held amounts. + # Book here only when the tx confirmed inline (the event is created + # CONFIRMED and the poller never re-processes it); SUBMITTED events + # are booked by the poller's confirm path. if tx_status == "CONFIRMED": - await clmm_repo.add_to_initial_amounts( + await clmm_repo.add_to_position_amounts( position_address=request.position_address, base_delta=Decimal(str(base_amount_added or 0)), quote_delta=Decimal(str(quote_amount_added or 0)), + entry_price=Decimal(str(add_price)) if add_price else None, ) else: logger.warning(f"ADD_LIQUIDITY {transaction_hash} executed for position " @@ -765,6 +790,17 @@ async def remove_liquidity_from_clmm_position( await clmm_repo.create_event(event_data) logger.info(f"Recorded CLMM REMOVE_LIQUIDITY event: {transaction_hash} " f"(status: {tx_status}, gas: {gas_fee} {gas_token})") + + # Withdrawn capital lowers both the held amounts and the PnL + # baseline. Book only on inline confirmation (the event is created + # CONFIRMED and the poller never re-processes it); SUBMITTED events + # are booked by the poller's confirm path. + if tx_status == "CONFIRMED": + await clmm_repo.subtract_from_position_amounts( + position_address=request.position_address, + base_delta=Decimal(str(base_amount_removed or 0)), + quote_delta=Decimal(str(quote_amount_removed or 0)), + ) else: logger.warning(f"REMOVE_LIQUIDITY {transaction_hash} executed for position " f"{request.position_address} with no database record — " @@ -972,7 +1008,11 @@ async def close_clmm_position( if verify_result and isinstance(verify_result, dict) and "error" in verify_result: status_code = verify_result.get("status") if status_code in (404, 500): - await clmm_repo.close_position(request.position_address) + await clmm_repo.close_position( + request.position_address, + position_rent_refunded=(Decimal(str(position_rent_refunded)) + if position_rent_refunded is not None else None) + ) logger.info(f"Position {request.position_address} verified as closed " f"(Gateway returned {status_code})") else: @@ -1213,7 +1253,7 @@ async def get_clmm_positions_owned( raise HTTPException(status_code=503, detail="Gateway service is not available") # Parse network_id - chain, _ = accounts_service.gateway_client.parse_network_id(request.network) + chain, network = accounts_service.gateway_client.parse_network_id(request.network) # Get wallet address wallet_address = await accounts_service.gateway_client.get_wallet_address_or_default( @@ -1232,13 +1272,12 @@ async def get_clmm_positions_owned( positions = [] for pos in positions_data: - # Extract token addresses (Gateway returns addresses, not symbols) - base_token_address = pos.get("baseTokenAddress", "") - quote_token_address = pos.get("quoteTokenAddress", "") - - # Use short addresses as symbols for now - base_token = base_token_address[-8:] if base_token_address else "" - quote_token = quote_token_address[-8:] if quote_token_address else "" + # Gateway returns token addresses; resolve them against its token list so + # positions carry real symbols ('SOL-USDC') that trading_pair filters match. + base_token = await accounts_service.gateway_client.resolve_token_symbol( + chain, network, pos.get("baseTokenAddress", "")) + quote_token = await accounts_service.gateway_client.resolve_token_symbol( + chain, network, pos.get("quoteTokenAddress", "")) trading_pair = f"{base_token}-{quote_token}" if base_token and quote_token else "" current_price = Decimal(str(pos.get("price", 0))) @@ -1398,10 +1437,11 @@ async def get_clmm_position_info( raise HTTPException(status_code=404, detail=f"Position {position_address} not found or closed") raise HTTPException(status_code=status_code or 502, detail=str(pos.get("error"))) - base_token_address = pos.get("baseTokenAddress", "") - quote_token_address = pos.get("quoteTokenAddress", "") - base_token = base_token_address[-8:] if base_token_address else "" - quote_token = quote_token_address[-8:] if quote_token_address else "" + chain, bare_network = accounts_service.gateway_client.parse_network_id(network) + base_token = await accounts_service.gateway_client.resolve_token_symbol( + chain, bare_network, pos.get("baseTokenAddress", "")) + quote_token = await accounts_service.gateway_client.resolve_token_symbol( + chain, bare_network, pos.get("quoteTokenAddress", "")) current_price = Decimal(str(pos.get("price", 0))) lower_price = Decimal(str(pos.get("lowerPrice", 0))) if pos.get("lowerPrice") else Decimal("0") upper_price = Decimal(str(pos.get("upperPrice", 0))) if pos.get("upperPrice") else Decimal("0") @@ -1495,7 +1535,8 @@ async def search_clmm_positions( network: Filter by network (e.g., 'solana-mainnet-beta') connector: Filter by connector (e.g., 'meteora') wallet_address: Filter by wallet address - trading_pair: Filter by trading pair (address-derived identifiers as stored; symbol pairs like 'SOL-USDC' will not match) + trading_pair: Filter by trading pair (e.g., 'SOL-USDC'; a token outside Gateway's + token list is stored under its full mint address instead of a symbol) status: Filter by status (OPEN, CLOSED) position_addresses: Filter by specific position addresses (list of addresses) limit: Max results (default 50, max 1000) diff --git a/routers/gateway_swap.py b/routers/gateway_swap.py index 17381bd8..45f8315f 100644 --- a/routers/gateway_swap.py +++ b/routers/gateway_swap.py @@ -1,6 +1,7 @@ """ Gateway Swap Router - Handles DEX swap operations via Hummingbot Gateway. -Uses Gateway's unified /trading/swap endpoints, so any swap provider works: +Dispatches to Gateway's /trading/{router,clmm,amm}/*-swap routes by the connector's +trading type, so any swap provider works: router connectors (jupiter, 0x, uniswap, pancakeswap) and amm/clmm connectors (raydium/amm, meteora/clmm, ...). """ @@ -16,14 +17,14 @@ from models import SwapExecuteRequest, SwapExecuteResponse, SwapQuoteRequest, SwapQuoteResponse from routers.gateway_extras import ExtraParamsSpec, get_transaction_status_from_response, validate_extra_params from services.accounts_service import AccountsService -from services.gateway_client import GatewayError, check_gateway_error +from services.gateway_client import GatewayError, check_gateway_error, get_native_gas_token logger = logging.getLogger(__name__) router = APIRouter(tags=["Gateway Swaps"], prefix="/gateway") -# Gateway's unified /trading/swap routes pass approximateIfNoExactOut only to the +# Gateway's swap routes pass approximateIfNoExactOut only to the # Solana router connectors' quote path; every other provider silently ignores it. SWAP_EXTRA_PARAMS_SPEC: ExtraParamsSpec = { "approximateIfNoExactOut": ((bool,), {"jupiter", "dflow", "okx", "titan"}), @@ -52,7 +53,7 @@ async def get_swap_quote( """ try: validate_extra_params(request.extra_params, SWAP_EXTRA_PARAMS_SPEC, - request.connector, "unified /trading/swap/quote") + request.connector, "the quote-swap routes") if not await accounts_service.gateway_client.ping(): raise HTTPException(status_code=503, detail="Gateway service is not available") @@ -129,7 +130,7 @@ async def execute_swap( """ try: validate_extra_params(request.extra_params, SWAP_EXTRA_PARAMS_SPEC, - request.connector, "unified /trading/swap/execute") + request.connector, "the execute-swap routes") if not await accounts_service.gateway_client.ping(): raise HTTPException(status_code=503, detail="Gateway service is not available") @@ -187,6 +188,24 @@ async def execute_swap( output_amount = request.amount if side == "BUY" else Decimal("0") price = Decimal("0") + # Gateway reports the gas it actually paid in the same confirmed `data` block. + # Record it here rather than leaving the columns null for the poller to fill — + # the poller only revisits swaps that were still pending, so a swap confirmed + # on the execute call was never getting its gas recorded at all. + fee_raw = data.get("fee") + gas_fee = Decimal(str(fee_raw)) if fee_raw is not None else None + chain, _ = accounts_service.gateway_client.parse_network_id(request.network) + gas_token = get_native_gas_token(chain) if gas_fee is not None else None + + # Prefer the slippage Gateway reports it actually applied over the one the + # caller asked for: omitting slippage_pct means "use the connector's configured + # value", and recording the request's None there loses what was really enforced. + applied_slippage = data.get("slippagePct") + slippage_pct = ( + Decimal(str(applied_slippage)) if applied_slippage is not None + else request.slippage_pct + ) + # Get transaction status from Gateway response tx_status = get_transaction_status_from_response(result) @@ -209,10 +228,13 @@ async def execute_swap( "input_amount": float(input_amount), "output_amount": float(output_amount), "price": float(price), - "slippage_pct": float(request.slippage_pct) if request.slippage_pct is not None else None, + "slippage_pct": float(slippage_pct) if slippage_pct is not None else None, + "gas_fee": float(gas_fee) if gas_fee is not None else None, + "gas_token": gas_token, "status": tx_status, - # Gateway's execute response schema carries no pool information - "pool_address": None + # Set by the pool-scoped routes, which resolve exactly one pool; a + # router picks its own path across pools and leaves it unset. + "pool_address": data.get("poolAddress") } await swap_repo.create_swap(swap_data) diff --git a/services/gateway_client.py b/services/gateway_client.py index 037e60fb..c96be56e 100644 --- a/services/gateway_client.py +++ b/services/gateway_client.py @@ -92,6 +92,8 @@ def __init__( self._certs_unavailable_warned = False # Gateway's connector -> trading_types listing, fetched on first use. self._connector_trading_types: Optional[Dict[str, List[str]]] = None + # Per-(chain, network) token address -> symbol map, fetched on first use. + self._token_symbols: Dict[tuple[str, str], Dict[str, str]] = {} @staticmethod def parse_network_id(network_id: str) -> tuple[str, str]: @@ -330,6 +332,31 @@ async def get_tokens(self, chain: str, network: str) -> Dict: "network": network }) + async def _get_token_symbols(self, chain: str, network: str) -> Dict[str, str]: + """Gateway's token address -> symbol map for a network, fetched once per client.""" + key = (chain, network) + if key not in self._token_symbols: + listing = check_gateway_error(await self.get_tokens(chain, network)) + self._token_symbols[key] = { + token["address"]: token["symbol"] + for token in listing.get("tokens", []) + if token.get("address") and token.get("symbol") + } + return self._token_symbols[key] + + async def resolve_token_symbol(self, chain: str, network: str, address: str) -> str: + """ + Symbol for a token address, falling back to the address itself. + + Gateway knows only the tokens in its configured list, so a pool on an unlisted + mint has no symbol to report. The full address then stands in as the identifier: + it is at least unambiguous and usable, where the truncated fragment this + replaced ('11111112' for wrapped SOL) named nothing and matched nothing. + """ + if not address: + return "" + return (await self._get_token_symbols(chain, network)).get(address, address) + async def add_token(self, chain: str, network: str, address: str, symbol: str, name: str, decimals: int) -> Dict: """Add a custom token to Gateway's token list""" return await self._request("POST", "tokens", json={ @@ -458,7 +485,7 @@ async def delete_pool(self, chain: str, network: str, address: str) -> Dict: }) # ============================================ - # Swap Operations (unified /trading/swap endpoints) + # Swap Operations (/trading/{router,clmm,amm}/{quote,execute}-swap) # ============================================ async def _get_connector_trading_types(self) -> Dict[str, List[str]]: @@ -472,17 +499,21 @@ async def _get_connector_trading_types(self) -> Dict[str, List[str]]: } return self._connector_trading_types - async def normalize_swap_connector(self, connector: str) -> str: + async def resolve_swap_route(self, connector: str) -> tuple[str, str]: """ - Normalize a connector name to Gateway's 'name/type' swap-provider format. - - An already-typed value passes through untouched ('raydium/amm'). A bare - name takes the connector's most swap-appropriate trading type as Gateway - reports it: router, else clmm, else amm. Unknown names raise rather than - guessing a type that Gateway would reject with an opaque 400. + Split a swap provider into the (bare name, trading type) the routes need. + + Gateway carries the trading type in the path — /trading/router, /trading/clmm, + /trading/amm — and constrains each route's `connector` to bare names, so a typed + value has to be taken apart rather than passed through. A typed input + ('raydium/amm') is split as given; a bare one takes the connector's most + swap-appropriate type as Gateway reports it: router, else clmm, else amm. + Unknown names raise rather than guessing a type Gateway would reject with an + opaque 400. """ if "/" in connector: - return connector + name, trading_type = connector.split("/", 1) + return name, trading_type trading_types = (await self._get_connector_trading_types()).get(connector) if trading_types is None: raise GatewayError( @@ -492,7 +523,7 @@ async def normalize_swap_connector(self, connector: str) -> str: ) for candidate in _SWAP_TYPE_PREFERENCE: if candidate in trading_types: - return f"{connector}/{candidate}" + return connector, candidate raise GatewayError( f"Connector '{connector}' supports no swap trading type " f"(Gateway reports: {', '.join(trading_types) or 'none'})", @@ -508,22 +539,28 @@ async def quote_swap( amount: float, side: str, slippage_pct: Optional[float] = None, - extra_params: Optional[Dict] = None + extra_params: Optional[Dict] = None, + pool_address: Optional[str] = None ) -> Dict: """ - Get a swap quote via Gateway's unified /trading/swap/quote endpoint. + Get a swap quote from the trading surface matching the connector's type. Args: connector: Swap provider, either bare ('jupiter', 'meteora') or typed - ('jupiter/router', 'raydium/amm', 'meteora/clmm'). + ('jupiter/router', 'raydium/amm', 'meteora/clmm'). The type selects the + route — /trading/{router,clmm,amm}/quote-swap — and the bare name is sent + as `connector`. chain_network: 'chain-network' format (e.g. 'solana-mainnet-beta'). - For amm/clmm providers Gateway resolves the pool from its pool list. + pool_address: Pin an amm/clmm quote to one pool. Omitted, Gateway resolves the + pool from its configured list by token pair, which cannot reach a pool that + is not in it. Routers reject this — they choose their own path across pools. extra_params: Connector-specific query params under Gateway's own names (e.g. approximateIfNoExactOut). The router validates keys first. """ + name, trading_type = await self.resolve_swap_route(connector) params = { "chainNetwork": chain_network, - "connector": await self.normalize_swap_connector(connector), + "connector": name, "baseToken": base_asset, "quoteToken": quote_asset, "amount": str(amount), @@ -531,13 +568,15 @@ async def quote_swap( } if slippage_pct is not None: params["slippagePct"] = str(slippage_pct) + if pool_address: + params["poolAddress"] = pool_address if extra_params: # Query params must be strings for aiohttp; Gateway's schema coerces # "true"/"false" back to booleans. for key, value in extra_params.items(): params[key] = str(value).lower() if isinstance(value, bool) else str(value) - return await self._request("GET", "trading/swap/quote", params=params) + return await self._request("GET", f"trading/{trading_type}/quote-swap", params=params) async def execute_swap( self, @@ -549,16 +588,20 @@ async def execute_swap( amount: float, side: str, slippage_pct: Optional[float] = None, - extra_params: Optional[Dict] = None + extra_params: Optional[Dict] = None, + pool_address: Optional[str] = None ) -> Dict: - """Execute a swap via Gateway's unified /trading/swap/execute endpoint. + """Execute a swap on the trading surface matching the connector's type. - extra_params carries connector-specific params under Gateway's own names - (e.g. approximateIfNoExactOut). The router validates keys first. + The type selects the route — /trading/{router,clmm,amm}/execute-swap — and the + bare name is sent as `connector`. pool_address pins an amm/clmm swap to one pool; + routers reject it. extra_params carries connector-specific params under Gateway's + own names (e.g. approximateIfNoExactOut); the router validates keys first. """ + name, trading_type = await self.resolve_swap_route(connector) payload = { "chainNetwork": chain_network, - "connector": await self.normalize_swap_connector(connector), + "connector": name, "walletAddress": wallet_address, "baseToken": base_asset, "quoteToken": quote_asset, @@ -567,10 +610,12 @@ async def execute_swap( } if slippage_pct is not None: payload["slippagePct"] = slippage_pct + if pool_address: + payload["poolAddress"] = pool_address if extra_params: payload.update(extra_params) - return await self._request("POST", "trading/swap/execute", json=payload) + return await self._request("POST", f"trading/{trading_type}/execute-swap", json=payload) # ============================================ # Liquidity Operations - CLMM (unified /trading/clmm endpoints) @@ -769,7 +814,7 @@ async def clmm_quote_position( params["quoteTokenAmount"] = quote_token_amount if slippage_pct is not None: params["slippagePct"] = slippage_pct - return await self._request("GET", "trading/clmm/quote-position", params=params) + return await self._request("GET", "trading/clmm/quote-liquidity", params=params) async def clmm_create_pool( self, @@ -841,7 +886,7 @@ async def clmm_pool_info( async def clmm_fetch_pools( self, connector: str, - network: str, + chain_network: str, limit: int = 50, query: Optional[str] = None, sort_by: Optional[str] = None, @@ -853,15 +898,15 @@ async def clmm_fetch_pools( """ Discover CLMM pools from the connector's own listing API (meteora, orca). - This is a per-connector Gateway route (no unified equivalent): it proxies the - DEX's pool-discovery API rather than Gateway's saved pool list. The schemas - differ per connector — meteora takes page/includeUnverified and a + Proxies the DEX's pool-discovery API rather than Gateway's saved pool list. The + knobs differ per connector — meteora takes page/includeUnverified and a "field:direction" sortBy; orca takes sortDirection/verifiedOnly and does not - paginate. Only keys the caller sets are sent; AJV strips unknown keys - Gateway-side, so sending the wrong connector's knob would be a silent no-op. + paginate — so only keys the caller sets are sent. Gateway drops a knob the chosen + connector ignores, meaning the wrong connector's knob is a silent no-op. """ params = { - "network": network, + "chainNetwork": chain_network, + "connector": connector, "limit": limit, } if query: @@ -877,7 +922,7 @@ async def clmm_fetch_pools( if verified_only is not None: params["verifiedOnly"] = "true" if verified_only else "false" - return await self._request("GET", f"connectors/{connector}/clmm/fetch-pools", params=params) + return await self._request("GET", "trading/clmm/fetch-pools", params=params) # ============================================ # AMM Liquidity (Meteora DAMM v2, Raydium CPMM, Uniswap/Pancakeswap V2) @@ -960,7 +1005,7 @@ async def amm_add_liquidity( payload["slippagePct"] = slippage_pct if position_address is not None: payload["positionAddress"] = position_address - return await self._request("POST", "trading/amm/add-liquidity", json=payload) + return await self._request("POST", "trading/amm/add", json=payload) async def amm_remove_liquidity( self, @@ -984,7 +1029,7 @@ async def amm_remove_liquidity( payload["slippagePct"] = slippage_pct if position_address is not None: payload["positionAddress"] = position_address - return await self._request("POST", "trading/amm/remove-liquidity", json=payload) + return await self._request("POST", "trading/amm/remove", json=payload) async def amm_create_pool( self, diff --git a/services/gateway_transaction_poller.py b/services/gateway_transaction_poller.py index 4589203c..bca5a0c3 100644 --- a/services/gateway_transaction_poller.py +++ b/services/gateway_transaction_poller.py @@ -311,12 +311,25 @@ async def _update_position_from_event(self, event, clmm_repo: GatewayCLMMReposit await clmm_repo.close_position(position.position_address) elif event.event_type == "ADD_LIQUIDITY": - # Added capital raises the PnL baseline. Event amounts may be the - # requested figures (recorded at submit time) rather than on-chain - # actuals — the accepted residual is that pending-tx amounts are not - # backfilled from txData; requested amounts are the best available. + # Added capital raises both the PnL baseline and the held amounts. + # Event amounts may be the requested figures (recorded at submit time) + # rather than on-chain actuals — the accepted residual is that + # pending-tx amounts are not backfilled from txData; requested amounts + # are the best available. if event.base_token_amount or event.quote_token_amount: - await clmm_repo.add_to_initial_amounts( + await clmm_repo.add_to_position_amounts( + position_address=position.position_address, + base_delta=Decimal(str(event.base_token_amount or 0)), + quote_delta=Decimal(str(event.quote_token_amount or 0)), + ) + + elif event.event_type == "REMOVE_LIQUIDITY": + # The mirror of ADD_LIQUIDITY: withdrawn capital lowers both the held + # amounts and the PnL baseline. Endpoints book inline only for txs + # Gateway confirmed at submit time — those events are created CONFIRMED + # and never reach this path, so there is no double count. + if event.base_token_amount or event.quote_token_amount: + await clmm_repo.subtract_from_position_amounts( position_address=position.position_address, base_delta=Decimal(str(event.base_token_amount or 0)), quote_delta=Decimal(str(event.quote_token_amount or 0)), diff --git a/test/test_gateway_client_contract.py b/test/test_gateway_client_contract.py index 70e97de0..463aa8cd 100644 --- a/test/test_gateway_client_contract.py +++ b/test/test_gateway_client_contract.py @@ -3,7 +3,7 @@ These pin the client to the Gateway route table verified live on 2026-07-13 (hummingbot/gateway feat-robinhood-chain): -- swaps go through the unified /trading/swap endpoints (NOT /connectors/{c}/router/..., +- swaps go through /trading/{router,clmm,amm}/*-swap (NOT /connectors/{c}/router/..., which 404s for clmm-only connectors like meteora and doubles the path for connector values like "jupiter/router"), - CLMM ops go through the unified /trading/clmm endpoints with camelCase keys @@ -55,51 +55,52 @@ async def fake_request(method, path, params=None, json=None): # ============================================ -# Connector normalization +# Connector -> (name, trading type) resolution # ============================================ @pytest.mark.asyncio @pytest.mark.parametrize("connector,expected", [ - ("jupiter", "jupiter/router"), - ("0x", "0x/router"), - ("uniswap", "uniswap/router"), - ("pancakeswap", "pancakeswap/router"), - ("dflow", "dflow/router"), - ("okx", "okx/router"), - ("titan", "titan/router"), - ("meteora", "meteora/clmm"), - ("orca", "orca/clmm"), - ("raydium", "raydium/clmm"), - ("pancakeswap-sol", "pancakeswap-sol/clmm"), - # Already-typed providers pass through untouched (no doubled /router/router) - ("jupiter/router", "jupiter/router"), - ("meteora/clmm", "meteora/clmm"), - ("raydium/amm", "raydium/amm"), + ("jupiter", ("jupiter", "router")), + ("0x", ("0x", "router")), + ("uniswap", ("uniswap", "router")), + ("pancakeswap", ("pancakeswap", "router")), + ("dflow", ("dflow", "router")), + ("okx", ("okx", "router")), + ("titan", ("titan", "router")), + ("meteora", ("meteora", "clmm")), + ("orca", ("orca", "clmm")), + ("raydium", ("raydium", "clmm")), + ("pancakeswap-sol", ("pancakeswap-sol", "clmm")), + # A typed provider is split as given, never re-resolved + ("jupiter/router", ("jupiter", "router")), + ("meteora/clmm", ("meteora", "clmm")), + ("raydium/amm", ("raydium", "amm")), ]) -async def test_normalize_swap_connector(connector, expected): +async def test_resolve_swap_route(connector, expected): client = GatewayClient() client._connector_trading_types = { entry["name"]: entry["trading_types"] for entry in _CONNECTOR_LISTING["connectors"] } - assert await client.normalize_swap_connector(connector) == expected + assert await client.resolve_swap_route(connector) == expected @pytest.mark.asyncio -async def test_normalize_swap_connector_rejects_unknown_name(): +async def test_resolve_swap_route_rejects_unknown_name(): client = GatewayClient() client._connector_trading_types = {"jupiter": ["router"]} with pytest.raises(GatewayError) as exc: - await client.normalize_swap_connector("nosuchdex") + await client.resolve_swap_route("nosuchdex") assert "nosuchdex" in str(exc.value) # ============================================ -# Swap paths and payloads (unified /trading/swap) +# Swap paths and payloads (/trading/{type}/*-swap) # ============================================ @pytest.mark.asyncio -async def test_quote_swap_uses_unified_endpoint(client_and_calls): +async def test_quote_swap_routes_by_trading_type(client_and_calls): + """A bare name resolves to its type, which selects the path; `connector` stays bare.""" client, calls = client_and_calls await client.quote_swap( connector="meteora", chain_network="solana-mainnet-beta", @@ -108,10 +109,10 @@ async def test_quote_swap_uses_unified_endpoint(client_and_calls): ) call = calls[0] assert call["method"] == "GET" - assert call["path"] == "trading/swap/quote" + assert call["path"] == "trading/clmm/quote-swap" assert call["params"] == { "chainNetwork": "solana-mainnet-beta", - "connector": "meteora/clmm", + "connector": "meteora", "baseToken": "SOL", "quoteToken": "USDC", "amount": "0.1", @@ -121,7 +122,7 @@ async def test_quote_swap_uses_unified_endpoint(client_and_calls): @pytest.mark.asyncio -async def test_execute_swap_uses_unified_endpoint(client_and_calls): +async def test_execute_swap_routes_by_trading_type(client_and_calls): client, calls = client_and_calls await client.execute_swap( connector="jupiter/router", chain_network="solana-mainnet-beta", @@ -130,9 +131,9 @@ async def test_execute_swap_uses_unified_endpoint(client_and_calls): ) call = calls[0] assert call["method"] == "POST" - assert call["path"] == "trading/swap/execute" + assert call["path"] == "trading/router/execute-swap" assert call["json"]["chainNetwork"] == "solana-mainnet-beta" - assert call["json"]["connector"] == "jupiter/router" + assert call["json"]["connector"] == "jupiter" assert call["json"]["walletAddress"] == "WALLET" assert call["json"]["side"] == "BUY" @@ -311,10 +312,12 @@ async def test_clmm_pool_info_uses_unified_endpoint(client_and_calls): async def test_clmm_fetch_pools_meteora_params(client_and_calls): """Meteora's fetch-pools paginates and filters via page/includeUnverified.""" client, calls = client_and_calls - await client.clmm_fetch_pools(connector="meteora", network="mainnet-beta", limit=10, + await client.clmm_fetch_pools(connector="meteora", chain_network="solana-mainnet-beta", limit=10, sort_by="volume_24h:desc", page=2, include_unverified=False) call = calls[0] - assert (call["method"], call["path"]) == ("GET", "connectors/meteora/clmm/fetch-pools") + assert (call["method"], call["path"]) == ("GET", "trading/clmm/fetch-pools") + assert call["params"]["connector"] == "meteora" + assert call["params"]["chainNetwork"] == "solana-mainnet-beta" assert call["params"]["page"] == 2 assert call["params"]["includeUnverified"] == "false" assert call["params"]["sortBy"] == "volume_24h:desc" @@ -325,12 +328,13 @@ async def test_clmm_fetch_pools_meteora_params(client_and_calls): @pytest.mark.asyncio async def test_clmm_fetch_pools_orca_params(client_and_calls): """Orca's fetch-pools takes sortDirection/verifiedOnly and has no pagination — - sending meteora's knobs would be silently stripped by Gateway's AJV.""" + the unified route drops a knob the chosen connector ignores.""" client, calls = client_and_calls - await client.clmm_fetch_pools(connector="orca", network="mainnet-beta", limit=10, + await client.clmm_fetch_pools(connector="orca", chain_network="solana-mainnet-beta", limit=10, sort_by="volume", sort_direction="desc", verified_only=True) call = calls[0] - assert (call["method"], call["path"]) == ("GET", "connectors/orca/clmm/fetch-pools") + assert (call["method"], call["path"]) == ("GET", "trading/clmm/fetch-pools") + assert call["params"]["connector"] == "orca" assert call["params"]["sortDirection"] == "desc" assert call["params"]["verifiedOnly"] == "true" for meteora_only in ("page", "includeUnverified"): @@ -434,7 +438,7 @@ async def test_amm_add_liquidity_omits_position_when_unset(client_and_calls): await client.amm_add_liquidity(connector="meteora", chain_network=NET, wallet_address=WALLET, pool_address=POOL, base_token_amount=1.0, quote_token_amount=2.0) c = calls[0] - assert (c["method"], c["path"]) == ("POST", "trading/amm/add-liquidity") + assert (c["method"], c["path"]) == ("POST", "trading/amm/add") assert "positionAddress" not in c["json"] # omit => open a new Meteora position @@ -453,7 +457,7 @@ async def test_amm_remove_liquidity_includes_position_when_set(client_and_calls) await client.amm_remove_liquidity(connector="meteora", chain_network=NET, wallet_address=WALLET, pool_address=POOL, percentage_to_remove=100, position_address="POS123") c = calls[0] - assert (c["method"], c["path"]) == ("POST", "trading/amm/remove-liquidity") + assert (c["method"], c["path"]) == ("POST", "trading/amm/remove") assert c["json"]["percentageToRemove"] == 100 assert c["json"]["positionAddress"] == "POS123" diff --git a/test/test_gateway_paths_exist.py b/test/test_gateway_paths_exist.py new file mode 100644 index 00000000..d51522d3 --- /dev/null +++ b/test/test_gateway_paths_exist.py @@ -0,0 +1,100 @@ +"""Every Gateway path this client calls must exist in Gateway's OpenAPI spec. + +The client is hand-written against an API defined elsewhere, so a route rename in +Gateway is invisible here until a call 404s at runtime — in production, mid-trade. +That has happened repeatedly: /trading/swap/* split into +/trading/{router,clmm,amm}/*-swap, clmm quote-position became quote-liquidity, amm +add-liquidity became add, and per-connector fetch-pools moved under /trading/clmm. +Each was found by reading Gateway's source by hand. + +This asserts the paths instead, against a vendored copy of Gateway's OpenAPI spec. + +The copy is vendored rather than read from a sibling checkout so the check runs in CI, +where no gateway repo exists — and so that adopting a Gateway change is a reviewable +diff of this file, showing exactly which routes moved. Refresh it deliberately: + + cd ../gateway && pnpm generate:openapi + cp ../gateway/openapi.json gateway-openapi.json + +A failure here means one of two things: the client is stale and should follow the spec, +or the spec is stale and should be refreshed. Point GATEWAY_OPENAPI at a live spec to +check against an unmerged Gateway branch without touching the vendored copy. +""" +import json +import os +import re +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parent.parent +SPEC_PATH = Path(os.environ.get("GATEWAY_OPENAPI", _REPO_ROOT / "gateway-openapi.json")) +CLIENT_PATH = _REPO_ROOT / "services" / "gateway_client.py" + +# Path literals passed to _request(). Both plain and f-strings, since the trading +# routes interpolate the type ("trading/{trading_type}/quote-swap"). +_REQUEST_CALL = re.compile(r'_request\(\s*"[A-Z]+"\s*,\s*f?"([^"]+)"') + +# Interpolated segments stand for a value chosen at runtime; each is expanded to the +# values it can take, so the check stays exact rather than pattern-matching. +_SEGMENT_VALUES = { + "{trading_type}": ["router", "clmm", "amm"], +} + + +def _spec_paths() -> set: + spec = json.loads(SPEC_PATH.read_text()) + return set(spec.get("paths", {})) + + +def _normalise(path: str) -> list: + """Client path -> the spec paths it can resolve to.""" + candidates = [f"/{path.lstrip('/')}"] + for token, values in _SEGMENT_VALUES.items(): + if any(token in c for c in candidates): + candidates = [c.replace(token, v) for c in candidates for v in values] + # Any remaining {placeholder} is a resource id; the spec names it differently + # (e.g. {address} vs {token_address}), so compare by segment shape. + return candidates + + +def _shape(path: str) -> str: + return re.sub(r"\{[^}]+\}", "{}", path.split("?")[0].rstrip("/")) + + +def _client_paths() -> set: + return set(_REQUEST_CALL.findall(CLIENT_PATH.read_text())) + + +def test_the_vendored_spec_is_present(): + """The spec ships with the repo, so a missing one is a broken checkout, not a skip.""" + assert SPEC_PATH.exists(), ( + f"No Gateway OpenAPI spec at {SPEC_PATH}. It is vendored so this check runs in CI; " + "restore it with `cp ../gateway/openapi.json gateway-openapi.json`." + ) + + +def test_every_called_path_exists_in_the_spec(): + spec_shapes = {_shape(p) for p in _spec_paths()} + missing = [] + for called in sorted(_client_paths()): + for candidate in _normalise(called): + if _shape(candidate) not in spec_shapes: + missing.append(candidate) + assert not missing, ( + "GatewayClient calls paths Gateway does not serve:\n " + + "\n ".join(missing) + + f"\n\nSpec: {SPEC_PATH} ({len(spec_shapes)} paths). " + "Regenerate it with `pnpm generate:openapi` in the gateway repo if it is stale." + ) + + +def test_the_spec_actually_loaded(): + """Guard the guard: a spec that parsed to nothing would pass the check vacuously.""" + paths = _spec_paths() + assert len(paths) > 20, f"Only {len(paths)} paths in {SPEC_PATH} — is it truncated?" + assert any(p.startswith("/trading/") for p in paths), "No /trading routes in the spec" + + +def test_client_paths_were_found(): + """Guard the guard: a regex that matched nothing would also pass vacuously.""" + called = _client_paths() + assert len(called) > 20, f"Only {len(called)} path literals found in {CLIENT_PATH}" From 9375d500947e8ed26e5333c432b2cff81a8474b3 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Wed, 19 Aug 2026 16:14:30 -0700 Subject: [PATCH 20/54] feat(gateway): mirror Gateway's schemas from its spec, and pin the fields we read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gateway's spec now names its schemas as components, so they can be generated rather than transcribed. models/gateway_generated.py is that mirror, vendored so it imports without a build step and adopting a Gateway change reads as a diff. `make gateway-models` regenerates it; a test fails if the committed copy is not what the vendored spec produces. The generated models replace nothing in models/gateway_trading.py, which turns out not to be a transcription: it is this service's own API, deliberately reframed (trading_pair + side over Gateway's token flow, a compound chain-network, Decimal, a string status vocabulary). Only 6 of its 32 models pass Gateway's shape through unchanged. What the mirror is good for is checking those 6 — and it now does, along with every camelCase key the client writes or reads. Both halves failed silently before: a renamed field is a .get() that returns None, not an error. Requests stay hand-written. Gateway declares 24 of 28 request bodies inline rather than as components, and every read is a GET whose fields live in the spec as parameters, so no generated request model would cover them. The wire-key check does. Also fixes clmm/create-pool, which raised a ValidationError on every successful create: it splatted Gateway's numeric status into a model declaring a string, missing the mapping every other write path applies. --- Makefile | 24 +- environment.yml | 3 + gateway-openapi.json | 4758 +++++++++++++++--------- models/gateway_generated.py | 735 ++++ routers/gateway_clmm.py | 5 +- routers/gateway_swap.py | 4 +- test/test_gateway_models_match_spec.py | 170 + 7 files changed, 3860 insertions(+), 1839 deletions(-) create mode 100644 models/gateway_generated.py create mode 100644 test/test_gateway_models_match_spec.py diff --git a/Makefile b/Makefile index 443ff767..64b8dd57 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: setup run deploy stop install uninstall build install-pre-commit tailscale-status reset +.PHONY: setup run deploy stop install uninstall build install-pre-commit tailscale-status reset gateway-models SETUP_SENTINEL := .setup-complete @@ -86,6 +86,28 @@ install-pre-commit: conda run -n hummingbot-api pip install pre-commit conda run -n hummingbot-api pre-commit install +# Header stamped onto the generated models. `#` starts a comment in a Makefile, so it +# has to reach the recipe through a variable. +HASH := \# +define GATEWAY_MODELS_HEADER +$(HASH) Generated from gateway-openapi.json by 'make gateway-models'. Do not edit. +$(HASH) flake8: noqa: E501 +endef +export GATEWAY_MODELS_HEADER + +# Regenerate models/gateway_generated.py from the vendored Gateway spec. +# Adopting a Gateway change is two steps — refresh the spec, then rerun this: +# cd ../gateway && pnpm generate:openapi && cp openapi.json ../hummingbot-api/gateway-openapi.json +# make gateway-models +# test/test_gateway_models_match_spec.py fails if the committed models drift from the spec. +gateway-models: + conda run --no-capture-output -n hummingbot-api python -m datamodel_code_generator \ + --input gateway-openapi.json --input-file-type openapi --openapi-scopes schemas \ + --output models/gateway_generated.py --output-model-type pydantic_v2.BaseModel \ + --snake-case-field --target-python-version 3.12 --disable-timestamp \ + --formatters black --formatters isort \ + --custom-file-header "$$GATEWAY_MODELS_HEADER" + # Build Docker image build: docker build -t hummingbot/hummingbot-api:latest . diff --git a/environment.yml b/environment.yml index 69dd80ff..a1986575 100644 --- a/environment.yml +++ b/environment.yml @@ -35,3 +35,6 @@ dependencies: - psycopg2-binary - greenlet - pydantic-settings + # Regenerates models/gateway_generated.py from gateway-openapi.json + # (`make gateway-models`); test_gateway_models_match_spec reruns it to catch drift. + - datamodel-code-generator diff --git a/gateway-openapi.json b/gateway-openapi.json index 57f2ae65..88d0c2e0 100644 --- a/gateway-openapi.json +++ b/gateway-openapi.json @@ -15,7 +15,2792 @@ } } }, - "schemas": {} + "schemas": { + "AmmPoolInfo": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "baseTokenAddress": { + "type": "string" + }, + "quoteTokenAddress": { + "type": "string" + }, + "feePct": { + "format": "decimal", + "type": "number" + }, + "price": { + "format": "decimal", + "type": "number" + }, + "baseTokenAmount": { + "format": "decimal", + "type": "number" + }, + "quoteTokenAmount": { + "format": "decimal", + "type": "number" + } + }, + "required": [ + "address", + "baseTokenAddress", + "quoteTokenAddress", + "feePct", + "price", + "baseTokenAmount", + "quoteTokenAmount" + ] + }, + "AmmGetPoolInfoRequest": { + "type": "object", + "properties": { + "network": { + "type": "string" + }, + "poolAddress": { + "type": "string" + } + }, + "required": [ + "poolAddress" + ] + }, + "AmmAddLiquidityRequest": { + "type": "object", + "properties": { + "network": { + "type": "string" + }, + "walletAddress": { + "type": "string" + }, + "poolAddress": { + "type": "string" + }, + "baseTokenAmount": { + "format": "decimal", + "type": "number" + }, + "quoteTokenAmount": { + "format": "decimal", + "type": "number" + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "type": "number" + } + }, + "required": [ + "poolAddress", + "baseTokenAmount", + "quoteTokenAmount" + ] + }, + "AmmAddLiquidityResponse": { + "type": "object", + "properties": { + "signature": { + "type": "string" + }, + "status": { + "description": "TransactionStatus enum value", + "type": "number" + }, + "data": { + "$ref": "#/components/schemas/AmmAddLiquidityResponseData" + } + }, + "required": [ + "signature", + "status" + ] + }, + "AmmAddLiquidityResponseData": { + "type": "object", + "properties": { + "fee": { + "format": "decimal", + "type": "number" + }, + "poolAddress": { + "description": "Pool this operation acted on", + "type": "string" + }, + "positionAddress": { + "description": "Position the liquidity went into. Absent on fungible-LP AMMs, which hold liquidity as LP tokens rather than a position account.", + "x-connectors": [ + "meteora" + ], + "type": "string" + }, + "positionRent": { + "format": "decimal", + "description": "Native token locked as rent when this call opened the position. Absent when adding to a position that already existed, and on fungible-LP AMMs.", + "x-connectors": [ + "meteora" + ], + "type": "number" + }, + "baseTokenAmountAdded": { + "format": "decimal", + "type": "number" + }, + "quoteTokenAmountAdded": { + "format": "decimal", + "type": "number" + } + }, + "required": [ + "fee", + "baseTokenAmountAdded", + "quoteTokenAmountAdded" + ] + }, + "AmmOpenPositionResponse": { + "type": "object", + "properties": { + "signature": { + "type": "string" + }, + "status": { + "description": "TransactionStatus enum value", + "type": "number" + }, + "data": { + "$ref": "#/components/schemas/AmmOpenPositionResponseData" + } + }, + "required": [ + "signature", + "status" + ] + }, + "AmmOpenPositionResponseData": { + "type": "object", + "properties": { + "fee": { + "format": "decimal", + "type": "number" + }, + "poolAddress": { + "description": "Pool this operation acted on", + "type": "string" + }, + "positionAddress": { + "description": "Address of the newly opened position. Absent on fungible-LP AMMs, which hold liquidity as LP tokens rather than a position account.", + "type": "string" + }, + "positionRent": { + "format": "decimal", + "description": "Native token locked as rent for the position account, refunded on close. 0 on fungible-LP AMMs, which lock no rent.", + "type": "number" + }, + "baseTokenAmountAdded": { + "format": "decimal", + "type": "number" + }, + "quoteTokenAmountAdded": { + "format": "decimal", + "type": "number" + } + }, + "required": [ + "fee", + "positionRent", + "baseTokenAmountAdded", + "quoteTokenAmountAdded" + ] + }, + "AmmClosePositionResponse": { + "type": "object", + "properties": { + "signature": { + "type": "string" + }, + "status": { + "description": "TransactionStatus enum value", + "type": "number" + }, + "data": { + "$ref": "#/components/schemas/AmmClosePositionResponseData" + } + }, + "required": [ + "signature", + "status" + ] + }, + "AmmClosePositionResponseData": { + "type": "object", + "properties": { + "fee": { + "format": "decimal", + "type": "number" + }, + "poolAddress": { + "description": "Pool this operation acted on", + "type": "string" + }, + "positionAddress": { + "description": "Position this operation acted on", + "x-connectors": [ + "meteora" + ], + "type": "string" + }, + "positionRentRefunded": { + "format": "decimal", + "description": "Native token rent returned when the position account closed. 0 on fungible-LP AMMs, which have no position account to close.", + "type": "number" + }, + "baseTokenAmountRemoved": { + "format": "decimal", + "type": "number" + }, + "quoteTokenAmountRemoved": { + "format": "decimal", + "type": "number" + } + }, + "required": [ + "fee", + "positionRentRefunded", + "baseTokenAmountRemoved", + "quoteTokenAmountRemoved" + ] + }, + "QuoteLiquidityRequest": { + "type": "object", + "properties": { + "network": { + "type": "string" + }, + "poolAddress": { + "type": "string" + }, + "baseTokenAmount": { + "format": "decimal", + "type": "number" + }, + "quoteTokenAmount": { + "format": "decimal", + "type": "number" + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "type": "number" + } + }, + "required": [ + "poolAddress", + "baseTokenAmount", + "quoteTokenAmount" + ] + }, + "QuoteLiquidityResponse": { + "type": "object", + "properties": { + "poolAddress": { + "description": "Pool the quote was computed against", + "type": "string" + }, + "baseLimited": { + "type": "boolean" + }, + "baseTokenAmount": { + "format": "decimal", + "type": "number" + }, + "quoteTokenAmount": { + "format": "decimal", + "type": "number" + }, + "baseTokenAmountMax": { + "format": "decimal", + "type": "number" + }, + "quoteTokenAmountMax": { + "format": "decimal", + "type": "number" + } + }, + "required": [ + "baseLimited", + "baseTokenAmount", + "quoteTokenAmount", + "baseTokenAmountMax", + "quoteTokenAmountMax" + ] + }, + "AmmRemoveLiquidityRequest": { + "type": "object", + "properties": { + "network": { + "type": "string" + }, + "walletAddress": { + "type": "string" + }, + "poolAddress": { + "type": "string" + }, + "percentageToRemove": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "type": "number" + } + }, + "required": [ + "poolAddress", + "percentageToRemove" + ] + }, + "AmmRemoveLiquidityResponse": { + "type": "object", + "properties": { + "signature": { + "type": "string" + }, + "status": { + "description": "TransactionStatus enum value", + "type": "number" + }, + "data": { + "$ref": "#/components/schemas/AmmRemoveLiquidityResponseData" + } + }, + "required": [ + "signature", + "status" + ] + }, + "AmmRemoveLiquidityResponseData": { + "type": "object", + "properties": { + "fee": { + "format": "decimal", + "type": "number" + }, + "poolAddress": { + "description": "Pool this operation acted on", + "type": "string" + }, + "positionAddress": { + "description": "Position this operation acted on", + "x-connectors": [ + "meteora" + ], + "type": "string" + }, + "baseTokenAmountRemoved": { + "format": "decimal", + "type": "number" + }, + "quoteTokenAmountRemoved": { + "format": "decimal", + "type": "number" + } + }, + "required": [ + "fee", + "baseTokenAmountRemoved", + "quoteTokenAmountRemoved" + ] + }, + "CreatePoolRequest": { + "type": "object", + "properties": { + "network": { + "type": "string" + }, + "walletAddress": { + "type": "string" + }, + "baseToken": { + "description": "Base token symbol or address (becomes the pool base)", + "type": "string" + }, + "quoteToken": { + "description": "Quote token symbol or address (becomes the pool quote)", + "type": "string" + }, + "baseTokenAmount": { + "format": "decimal", + "description": "Amount of base token to seed the pool with", + "type": "number" + }, + "quoteTokenAmount": { + "format": "decimal", + "description": "Amount of quote token to seed with. If provided, the base:quote ratio sets the initial price. If omitted (and no initialPrice), the price is fetched from the market.", + "type": "number" + }, + "initialPrice": { + "format": "decimal", + "description": "Initial price as quote per base. Overrides quoteTokenAmount. If both are omitted, the current market price is fetched from the unified swap router so the pool opens on-market.", + "type": "number" + } + }, + "required": [ + "baseToken", + "quoteToken", + "baseTokenAmount" + ] + }, + "CreatePoolResponse": { + "type": "object", + "properties": { + "signature": { + "type": "string" + }, + "status": { + "description": "TransactionStatus enum value", + "type": "number" + }, + "poolAddress": { + "description": "Address of the newly created pool", + "type": "string" + }, + "price": { + "format": "decimal", + "description": "Initial price the pool was seeded at (quote per base)", + "type": "number" + }, + "data": { + "$ref": "#/components/schemas/CreatePoolResponseData" + } + }, + "required": [ + "signature", + "status", + "poolAddress" + ] + }, + "CreatePoolResponseData": { + "type": "object", + "properties": { + "fee": { + "format": "decimal", + "type": "number" + }, + "baseTokenAmountAdded": { + "format": "decimal", + "type": "number" + }, + "quoteTokenAmountAdded": { + "format": "decimal", + "type": "number" + } + }, + "required": [ + "fee", + "baseTokenAmountAdded", + "quoteTokenAmountAdded" + ] + }, + "PositionDetail": { + "type": "object", + "properties": { + "positionAddress": { + "description": "Address of the individual position (NFT position account)", + "type": "string" + }, + "lpTokenAmount": { + "format": "decimal", + "description": "Liquidity held by this position (LP units)", + "type": "number" + }, + "baseTokenAmount": { + "format": "decimal", + "type": "number" + }, + "quoteTokenAmount": { + "format": "decimal", + "type": "number" + } + }, + "required": [ + "positionAddress", + "lpTokenAmount", + "baseTokenAmount", + "quoteTokenAmount" + ] + }, + "AmmPositionInfo": { + "type": "object", + "properties": { + "poolAddress": { + "type": "string" + }, + "walletAddress": { + "type": "string" + }, + "baseTokenAddress": { + "type": "string" + }, + "quoteTokenAddress": { + "type": "string" + }, + "lpTokenAmount": { + "format": "decimal", + "type": "number" + }, + "baseTokenAmount": { + "format": "decimal", + "type": "number" + }, + "quoteTokenAmount": { + "format": "decimal", + "type": "number" + }, + "price": { + "format": "decimal", + "type": "number" + }, + "positions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PositionDetail" + } + } + }, + "required": [ + "poolAddress", + "walletAddress", + "baseTokenAddress", + "quoteTokenAddress", + "lpTokenAmount", + "baseTokenAmount", + "quoteTokenAmount", + "price" + ] + }, + "AmmGetPositionInfoRequest": { + "type": "object", + "properties": { + "network": { + "type": "string" + }, + "poolAddress": { + "type": "string" + }, + "walletAddress": { + "type": "string" + } + }, + "required": [ + "poolAddress" + ] + }, + "AmmQuoteSwapRequest": { + "type": "object", + "properties": { + "network": { + "type": "string" + }, + "poolAddress": { + "description": "Pool address (optional - can be looked up from baseToken and quoteToken)", + "type": "string" + }, + "baseToken": { + "description": "Token to determine swap direction", + "type": "string" + }, + "quoteToken": { + "description": "The other token in the pair (optional - required if poolAddress not provided)", + "type": "string" + }, + "amount": { + "format": "decimal", + "type": "number" + }, + "side": { + "description": "Trade direction", + "enum": [ + "BUY", + "SELL" + ], + "type": "string" + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "type": "number" + } + }, + "required": [ + "baseToken", + "amount", + "side" + ] + }, + "AmmQuoteSwapResponse": { + "type": "object", + "properties": { + "poolAddress": { + "type": "string" + }, + "tokenIn": { + "type": "string" + }, + "tokenOut": { + "type": "string" + }, + "amountIn": { + "format": "decimal", + "type": "number" + }, + "amountOut": { + "format": "decimal", + "type": "number" + }, + "price": { + "format": "decimal", + "type": "number" + }, + "slippagePct": { + "format": "decimal", + "type": "number" + }, + "minAmountOut": { + "format": "decimal", + "type": "number" + }, + "maxAmountIn": { + "format": "decimal", + "type": "number" + }, + "priceImpactPct": { + "format": "decimal", + "type": "number" + } + }, + "required": [ + "poolAddress", + "tokenIn", + "tokenOut", + "amountIn", + "amountOut", + "price", + "minAmountOut", + "maxAmountIn", + "priceImpactPct" + ] + }, + "AmmExecuteSwapRequest": { + "type": "object", + "properties": { + "walletAddress": { + "type": "string" + }, + "network": { + "type": "string" + }, + "poolAddress": { + "description": "Pool address (optional - can be looked up from baseToken and quoteToken)", + "type": "string" + }, + "baseToken": { + "type": "string" + }, + "quoteToken": { + "description": "The other token in the pair (optional - required if poolAddress not provided)", + "type": "string" + }, + "amount": { + "format": "decimal", + "type": "number" + }, + "side": { + "enum": [ + "BUY", + "SELL" + ], + "type": "string" + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "type": "number" + } + }, + "required": [ + "baseToken", + "amount", + "side" + ] + }, + "AmmExecuteSwapResponse": { + "type": "object", + "properties": { + "signature": { + "type": "string" + }, + "status": { + "description": "TransactionStatus enum value", + "type": "number" + }, + "data": { + "$ref": "#/components/schemas/AmmExecuteSwapResponseData" + } + }, + "required": [ + "signature", + "status" + ] + }, + "AmmExecuteSwapResponseData": { + "type": "object", + "properties": { + "tokenIn": { + "type": "string" + }, + "tokenOut": { + "type": "string" + }, + "amountIn": { + "format": "decimal", + "type": "number" + }, + "amountOut": { + "format": "decimal", + "type": "number" + }, + "fee": { + "format": "decimal", + "type": "number" + }, + "baseTokenBalanceChange": { + "format": "decimal", + "type": "number" + }, + "quoteTokenBalanceChange": { + "format": "decimal", + "type": "number" + }, + "slippagePct": { + "format": "decimal", + "description": "Slippage tolerance percentage actually applied to the swap", + "type": "number" + } + }, + "required": [ + "tokenIn", + "tokenOut", + "amountIn", + "amountOut", + "fee", + "baseTokenBalanceChange", + "quoteTokenBalanceChange" + ] + }, + "EstimateGasRequest": { + "type": "object", + "properties": { + "network": { + "type": "string" + } + } + }, + "EstimateGasResponse": { + "type": "object", + "properties": { + "feePerComputeUnit": { + "format": "decimal", + "type": "number" + }, + "denomination": { + "type": "string" + }, + "computeUnits": { + "type": "number" + }, + "feeAsset": { + "type": "string" + }, + "fee": { + "format": "decimal", + "type": "number" + }, + "timestamp": { + "type": "number" + }, + "gasType": { + "type": "string" + }, + "maxFeePerGas": { + "format": "decimal", + "type": "number" + }, + "maxPriorityFeePerGas": { + "format": "decimal", + "type": "number" + }, + "priorityFeeLevel": { + "type": "string" + }, + "priorityFeePerCUEstimate": { + "format": "decimal", + "type": "number" + } + }, + "required": [ + "feePerComputeUnit", + "denomination", + "computeUnits", + "feeAsset", + "fee", + "timestamp" + ] + }, + "BalanceRequest": { + "type": "object", + "properties": { + "network": { + "type": "string" + }, + "address": { + "type": "string" + }, + "tokens": { + "description": "a list of token symbols or addresses", + "type": "array", + "items": { + "type": "string" + } + }, + "fetchAll": { + "description": "fetch all tokens in wallet, not just those in token list (default: false)", + "type": "boolean" + } + } + }, + "BalanceResponse": { + "type": "object", + "properties": { + "balances": { + "type": "object", + "additionalProperties": { + "type": "number" + } + } + }, + "required": [ + "balances" + ] + }, + "TokensRequest": { + "type": "object", + "properties": { + "network": { + "type": "string" + }, + "tokenSymbols": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + } + }, + "TokensResponse": { + "type": "object", + "properties": { + "tokens": { + "type": "array", + "items": { + "type": "object", + "properties": { + "symbol": { + "type": "string" + }, + "address": { + "type": "string" + }, + "decimals": { + "type": "number" + }, + "name": { + "type": "string" + } + }, + "required": [ + "symbol", + "address", + "decimals", + "name" + ] + } + } + }, + "required": [ + "tokens" + ] + }, + "PollRequest": { + "type": "object", + "properties": { + "network": { + "type": "string" + }, + "signature": { + "description": "Transaction signature/hash", + "type": "string" + } + }, + "required": [ + "signature" + ] + }, + "PollResponse": { + "type": "object", + "properties": { + "currentBlock": { + "type": "number" + }, + "signature": { + "type": "string" + }, + "txBlock": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "txStatus": { + "description": "Transaction status: 1 = confirmed, 0 = pending, -1 = failed, -2 = not found (unknown to the chain: never received or dropped; on Solana this is terminal once the transaction blockhash expires)", + "type": "number" + }, + "fee": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "error": { + "anyOf": [ + { + "description": "Error info if failed: \"TYPE (code): message\"", + "type": "string" + }, + { + "type": "null" + } + ] + }, + "txData": { + "anyOf": [ + { + "type": "object", + "additionalProperties": {} + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "currentBlock", + "signature", + "txBlock", + "txStatus", + "fee", + "error", + "txData" + ] + }, + "StatusRequest": { + "type": "object", + "properties": { + "network": { + "type": "string" + } + } + }, + "StatusResponse": { + "type": "object", + "properties": { + "chain": { + "type": "string" + }, + "network": { + "type": "string" + }, + "rpcUrl": { + "type": "string" + }, + "rpcProvider": { + "type": "string" + }, + "currentBlockNumber": { + "type": "number" + }, + "nativeCurrency": { + "type": "string" + }, + "swapProvider": { + "type": "string" + } + }, + "required": [ + "chain", + "network", + "rpcUrl", + "rpcProvider", + "currentBlockNumber", + "nativeCurrency", + "swapProvider" + ] + }, + "ChainQuoteSwapResponse": { + "type": "object", + "properties": { + "tokenIn": { + "description": "Address of the token being swapped from", + "type": "string" + }, + "tokenOut": { + "description": "Address of the token being swapped to", + "type": "string" + }, + "amountIn": { + "format": "decimal", + "description": "Amount of tokenIn to be swapped", + "type": "number" + }, + "amountOut": { + "format": "decimal", + "description": "Expected amount of tokenOut to receive", + "type": "number" + }, + "price": { + "format": "decimal", + "description": "Exchange rate between tokenIn and tokenOut", + "type": "number" + }, + "priceImpactPct": { + "format": "decimal", + "description": "Estimated price impact percentage (0-100)", + "type": "number" + }, + "minAmountOut": { + "format": "decimal", + "description": "Minimum amount of tokenOut that will be accepted", + "type": "number" + }, + "maxAmountIn": { + "format": "decimal", + "description": "Maximum amount of tokenIn that will be spent", + "type": "number" + }, + "poolAddress": { + "description": "Pool address for AMM/CLMM swaps", + "type": "string" + }, + "routePath": { + "description": "Route path for router-based swaps", + "type": "string" + }, + "slippagePct": { + "format": "decimal", + "description": "Slippage tolerance percentage", + "type": "number" + } + }, + "required": [ + "tokenIn", + "tokenOut", + "amountIn", + "amountOut", + "price", + "priceImpactPct", + "minAmountOut", + "maxAmountIn" + ] + }, + "ChainExecuteSwapResponse": { + "type": "object", + "properties": { + "signature": { + "description": "Transaction signature/hash", + "type": "string" + }, + "status": { + "description": "Transaction status: 0 = PENDING, 1 = CONFIRMED, -1 = FAILED", + "type": "number" + }, + "data": { + "$ref": "#/components/schemas/ChainExecuteSwapResponseData" + } + }, + "required": [ + "signature", + "status" + ] + }, + "ChainExecuteSwapResponseData": { + "type": "object", + "properties": { + "tokenIn": { + "description": "Address of the token swapped from", + "type": "string" + }, + "tokenOut": { + "description": "Address of the token swapped to", + "type": "string" + }, + "amountIn": { + "format": "decimal", + "description": "Actual amount of tokenIn swapped", + "type": "number" + }, + "amountOut": { + "format": "decimal", + "description": "Actual amount of tokenOut received", + "type": "number" + }, + "fee": { + "format": "decimal", + "description": "Transaction fee paid", + "type": "number" + }, + "baseTokenBalanceChange": { + "format": "decimal", + "description": "Change in base token balance (negative for decrease)", + "type": "number" + }, + "quoteTokenBalanceChange": { + "format": "decimal", + "description": "Change in quote token balance (negative for decrease)", + "type": "number" + }, + "slippagePct": { + "format": "decimal", + "description": "Slippage tolerance percentage actually applied to the swap", + "type": "number" + }, + "poolAddress": { + "description": "Pool the swap executed against. Set by the pool-scoped routes (/trading/clmm, /trading/amm), which resolve exactly one pool; a router picks its own path across pools and leaves this unset. Without it a settled fill cannot be reconciled to a venue without refetching the transaction.", + "type": "string" + } + }, + "required": [ + "tokenIn", + "tokenOut", + "amountIn", + "amountOut", + "fee", + "baseTokenBalanceChange", + "quoteTokenBalanceChange" + ] + }, + "WrapRequest": { + "type": "object", + "properties": { + "network": { + "type": "string" + }, + "address": { + "description": "Wallet address holding the native token", + "type": "string" + }, + "amount": { + "description": "Amount of the native token to wrap, in whole units (not lamports/wei)", + "type": "string", + "example": "1.0" + } + }, + "required": [ + "address", + "amount" + ] + }, + "UnwrapRequest": { + "type": "object", + "properties": { + "network": { + "type": "string" + }, + "address": { + "description": "Wallet address holding the wrapped token", + "type": "string" + }, + "amount": { + "description": "Amount of the wrapped token to unwrap, in whole units. Solana unwraps the full balance when omitted; EVM chains require it.", + "type": "string", + "example": "1.0" + } + }, + "required": [ + "address" + ] + }, + "ChainWrapResponse": { + "type": "object", + "properties": { + "signature": { + "type": "string" + }, + "status": { + "description": "TransactionStatus enum value", + "type": "number" + }, + "data": { + "$ref": "#/components/schemas/ChainWrapResponseData" + } + }, + "required": [ + "signature", + "status" + ] + }, + "ChainWrapResponseData": { + "type": "object", + "properties": { + "nonce": { + "description": "EVM transaction nonce; absent on non-EVM chains", + "type": "number" + }, + "fee": { + "type": "string" + }, + "amount": { + "type": "string" + }, + "wrappedAddress": { + "type": "string" + }, + "nativeToken": { + "type": "string" + }, + "wrappedToken": { + "type": "string" + } + }, + "required": [ + "fee", + "amount", + "wrappedAddress", + "nativeToken", + "wrappedToken" + ] + }, + "RouterQuoteSwapResponse": { + "type": "object", + "properties": { + "tokenIn": { + "description": "Address of the token being swapped from", + "type": "string" + }, + "tokenOut": { + "description": "Address of the token being swapped to", + "type": "string" + }, + "amountIn": { + "format": "decimal", + "description": "Amount of tokenIn to be swapped", + "type": "number" + }, + "amountOut": { + "format": "decimal", + "description": "Expected amount of tokenOut to receive", + "type": "number" + }, + "price": { + "format": "decimal", + "description": "Exchange rate between tokenIn and tokenOut", + "type": "number" + }, + "priceImpactPct": { + "format": "decimal", + "description": "Estimated price impact percentage (0-100)", + "type": "number" + }, + "minAmountOut": { + "format": "decimal", + "description": "Minimum amount of tokenOut that will be accepted", + "type": "number" + }, + "maxAmountIn": { + "format": "decimal", + "description": "Maximum amount of tokenIn that will be spent", + "type": "number" + }, + "poolAddress": { + "description": "Pool address for AMM/CLMM swaps", + "type": "string" + }, + "routePath": { + "description": "Route path for router-based swaps", + "type": "string" + }, + "slippagePct": { + "format": "decimal", + "description": "Slippage tolerance percentage", + "type": "number" + }, + "quoteId": { + "description": "Identifier to pass to /trading/router/execute-quote", + "type": "string" + }, + "approximation": { + "description": "True when a BUY was approximated via a sell-leg ExactIn quote because the router has no ExactOut route; amountOut is an estimate rather than exact", + "type": "boolean" + } + }, + "required": [ + "tokenIn", + "tokenOut", + "amountIn", + "amountOut", + "price", + "priceImpactPct", + "minAmountOut", + "maxAmountIn", + "quoteId" + ] + }, + "FetchPoolsRequest": { + "type": "object", + "properties": { + "network": { + "description": "Network to use", + "type": "string" + }, + "limit": { + "minimum": 1, + "maximum": 100, + "default": 50, + "description": "Maximum number of pools to return", + "type": "number" + }, + "query": { + "description": "Search query to match pools by name, tokens, or address", + "type": "string" + }, + "sortBy": { + "description": "Sort by field (connector-specific)", + "type": "string" + } + } + }, + "PoolListItem": { + "type": "object", + "properties": { + "address": { + "description": "Pool address", + "type": "string" + }, + "name": { + "description": "Pool name (e.g., SOL-USDC)", + "type": "string" + }, + "baseTokenAddress": { + "description": "Base token address", + "type": "string" + }, + "baseTokenSymbol": { + "description": "Base token symbol", + "type": "string" + }, + "quoteTokenAddress": { + "description": "Quote token address", + "type": "string" + }, + "quoteTokenSymbol": { + "description": "Quote token symbol", + "type": "string" + }, + "binStep": { + "description": "Bin step / tick spacing", + "type": "number" + }, + "baseFee": { + "format": "decimal", + "description": "Base fee percentage", + "type": "number" + }, + "price": { + "format": "decimal", + "description": "Current price", + "type": "number" + }, + "tvl": { + "format": "decimal", + "description": "Total value locked in USD", + "type": "number" + }, + "apr": { + "format": "decimal", + "description": "Annual percentage rate", + "type": "number" + }, + "apy": { + "format": "decimal", + "description": "Annual percentage yield", + "type": "number" + }, + "volume24h": { + "format": "decimal", + "description": "24-hour trading volume", + "type": "number" + }, + "fees24h": { + "format": "decimal", + "description": "24-hour fees collected", + "type": "number" + } + }, + "required": [ + "address", + "name", + "baseTokenAddress", + "baseTokenSymbol", + "quoteTokenAddress", + "quoteTokenSymbol", + "binStep", + "baseFee", + "price", + "tvl" + ] + }, + "FetchPoolsResponse": { + "type": "object", + "properties": { + "pools": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PoolListItem" + } + }, + "total": { + "description": "Total number of matching pools", + "type": "number" + }, + "page": { + "description": "Current page number", + "type": "number" + }, + "pageSize": { + "description": "Number of pools per page", + "type": "number" + } + }, + "required": [ + "pools", + "total", + "page", + "pageSize" + ] + }, + "GetPositionsOwnedRequest": { + "type": "object", + "properties": { + "network": { + "type": "string" + }, + "walletAddress": { + "type": "string" + } + }, + "required": [ + "walletAddress" + ] + }, + "BinLiquidity": { + "type": "object", + "properties": { + "binId": { + "type": "number" + }, + "price": { + "format": "decimal", + "type": "number" + }, + "baseTokenAmount": { + "format": "decimal", + "type": "number" + }, + "quoteTokenAmount": { + "format": "decimal", + "type": "number" + } + }, + "required": [ + "binId", + "price", + "baseTokenAmount", + "quoteTokenAmount" + ] + }, + "PoolInfo": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "baseTokenAddress": { + "type": "string" + }, + "quoteTokenAddress": { + "type": "string" + }, + "binStep": { + "type": "number" + }, + "feePct": { + "format": "decimal", + "type": "number" + }, + "price": { + "format": "decimal", + "type": "number" + }, + "baseTokenAmount": { + "format": "decimal", + "type": "number" + }, + "quoteTokenAmount": { + "format": "decimal", + "type": "number" + }, + "activeBinId": { + "type": "number" + }, + "bins": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BinLiquidity" + } + } + }, + "required": [ + "address", + "baseTokenAddress", + "quoteTokenAddress", + "feePct", + "price", + "baseTokenAmount", + "quoteTokenAmount", + "activeBinId" + ] + }, + "MeteoraPoolInfo": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "baseTokenAddress": { + "type": "string" + }, + "quoteTokenAddress": { + "type": "string" + }, + "binStep": { + "type": "number" + }, + "feePct": { + "format": "decimal", + "type": "number" + }, + "price": { + "format": "decimal", + "type": "number" + }, + "baseTokenAmount": { + "format": "decimal", + "type": "number" + }, + "quoteTokenAmount": { + "format": "decimal", + "type": "number" + }, + "activeBinId": { + "type": "number" + }, + "bins": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BinLiquidity" + } + }, + "dynamicFeePct": { + "type": "number" + }, + "minBinId": { + "type": "number" + }, + "maxBinId": { + "type": "number" + } + }, + "required": [ + "address", + "baseTokenAddress", + "quoteTokenAddress", + "feePct", + "price", + "baseTokenAmount", + "quoteTokenAmount", + "activeBinId", + "dynamicFeePct", + "minBinId", + "maxBinId" + ] + }, + "GetPoolInfoRequest": { + "type": "object", + "properties": { + "network": { + "type": "string" + }, + "poolAddress": { + "type": "string" + }, + "binCount": { + "description": "If > 0, include a `bins` array in the response (per-tickSpacing token amounts around the active tick, mirroring Meteora pool-info.bins[]). Default 0 = skip the bin fetch.", + "default": 0, + "minimum": 0, + "maximum": 401, + "type": "integer" + } + }, + "required": [ + "poolAddress" + ] + }, + "PositionInfo": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "poolAddress": { + "type": "string" + }, + "baseTokenAddress": { + "type": "string" + }, + "quoteTokenAddress": { + "type": "string" + }, + "baseTokenAmount": { + "format": "decimal", + "type": "number" + }, + "quoteTokenAmount": { + "format": "decimal", + "type": "number" + }, + "baseFeeAmount": { + "format": "decimal", + "type": "number" + }, + "quoteFeeAmount": { + "format": "decimal", + "type": "number" + }, + "lowerBinId": { + "type": "number" + }, + "upperBinId": { + "type": "number" + }, + "lowerPrice": { + "format": "decimal", + "type": "number" + }, + "upperPrice": { + "format": "decimal", + "type": "number" + }, + "price": { + "format": "decimal", + "type": "number" + } + }, + "required": [ + "address", + "poolAddress", + "baseTokenAddress", + "quoteTokenAddress", + "baseTokenAmount", + "quoteTokenAmount", + "baseFeeAmount", + "quoteFeeAmount", + "lowerBinId", + "upperBinId", + "lowerPrice", + "upperPrice", + "price" + ] + }, + "GetPositionInfoRequest": { + "type": "object", + "properties": { + "network": { + "type": "string" + }, + "positionAddress": { + "type": "string" + }, + "walletAddress": { + "type": "string" + } + }, + "required": [ + "positionAddress" + ] + }, + "OpenPositionRequest": { + "type": "object", + "properties": { + "network": { + "type": "string" + }, + "walletAddress": { + "type": "string" + }, + "lowerPrice": { + "format": "decimal", + "type": "number" + }, + "upperPrice": { + "format": "decimal", + "type": "number" + }, + "poolAddress": { + "type": "string" + }, + "baseTokenAmount": { + "format": "decimal", + "type": "number" + }, + "quoteTokenAmount": { + "format": "decimal", + "type": "number" + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "type": "number" + } + }, + "required": [ + "lowerPrice", + "upperPrice", + "poolAddress" + ] + }, + "OpenPositionResponse": { + "type": "object", + "properties": { + "signature": { + "type": "string" + }, + "status": { + "description": "TransactionStatus enum value", + "type": "number" + }, + "data": { + "$ref": "#/components/schemas/OpenPositionResponseData" + } + }, + "required": [ + "signature", + "status" + ] + }, + "OpenPositionResponseData": { + "type": "object", + "properties": { + "fee": { + "format": "decimal", + "type": "number" + }, + "poolAddress": { + "description": "Pool this operation acted on", + "type": "string" + }, + "positionAddress": { + "type": "string" + }, + "positionRent": { + "format": "decimal", + "type": "number" + }, + "baseTokenAmountAdded": { + "format": "decimal", + "type": "number" + }, + "quoteTokenAmountAdded": { + "format": "decimal", + "type": "number" + } + }, + "required": [ + "fee", + "positionAddress", + "positionRent", + "baseTokenAmountAdded", + "quoteTokenAmountAdded" + ] + }, + "AddLiquidityRequest": { + "type": "object", + "properties": { + "network": { + "type": "string" + }, + "walletAddress": { + "type": "string" + }, + "positionAddress": { + "type": "string" + }, + "baseTokenAmount": { + "format": "decimal", + "type": "number" + }, + "quoteTokenAmount": { + "format": "decimal", + "type": "number" + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "type": "number" + } + }, + "required": [ + "positionAddress", + "baseTokenAmount", + "quoteTokenAmount" + ] + }, + "AddLiquidityResponse": { + "type": "object", + "properties": { + "signature": { + "type": "string" + }, + "status": { + "description": "TransactionStatus enum value", + "type": "number" + }, + "data": { + "$ref": "#/components/schemas/AddLiquidityResponseData" + } + }, + "required": [ + "signature", + "status" + ] + }, + "AddLiquidityResponseData": { + "type": "object", + "properties": { + "fee": { + "format": "decimal", + "type": "number" + }, + "poolAddress": { + "description": "Pool this operation acted on", + "type": "string" + }, + "positionAddress": { + "description": "Position this operation acted on", + "type": "string" + }, + "baseTokenAmountAdded": { + "format": "decimal", + "type": "number" + }, + "quoteTokenAmountAdded": { + "format": "decimal", + "type": "number" + } + }, + "required": [ + "fee", + "baseTokenAmountAdded", + "quoteTokenAmountAdded" + ] + }, + "RemoveLiquidityRequest": { + "type": "object", + "properties": { + "network": { + "type": "string" + }, + "walletAddress": { + "type": "string" + }, + "positionAddress": { + "type": "string" + }, + "percentageToRemove": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "type": "number" + } + }, + "required": [ + "positionAddress", + "percentageToRemove" + ] + }, + "RemoveLiquidityResponse": { + "type": "object", + "properties": { + "signature": { + "type": "string" + }, + "status": { + "description": "TransactionStatus enum value", + "type": "number" + }, + "data": { + "$ref": "#/components/schemas/RemoveLiquidityResponseData" + } + }, + "required": [ + "signature", + "status" + ] + }, + "RemoveLiquidityResponseData": { + "type": "object", + "properties": { + "fee": { + "format": "decimal", + "type": "number" + }, + "poolAddress": { + "description": "Pool this operation acted on", + "type": "string" + }, + "positionAddress": { + "description": "Position this operation acted on", + "type": "string" + }, + "baseTokenAmountRemoved": { + "format": "decimal", + "type": "number" + }, + "quoteTokenAmountRemoved": { + "format": "decimal", + "type": "number" + } + }, + "required": [ + "fee", + "baseTokenAmountRemoved", + "quoteTokenAmountRemoved" + ] + }, + "CollectFeesRequest": { + "type": "object", + "properties": { + "network": { + "type": "string" + }, + "walletAddress": { + "type": "string" + }, + "positionAddress": { + "type": "string" + } + }, + "required": [ + "positionAddress" + ] + }, + "CollectFeesResponse": { + "type": "object", + "properties": { + "signature": { + "type": "string" + }, + "status": { + "description": "TransactionStatus enum value", + "type": "number" + }, + "data": { + "$ref": "#/components/schemas/CollectFeesResponseData" + } + }, + "required": [ + "signature", + "status" + ] + }, + "CollectFeesResponseData": { + "type": "object", + "properties": { + "fee": { + "format": "decimal", + "type": "number" + }, + "poolAddress": { + "description": "Pool this operation acted on", + "type": "string" + }, + "positionAddress": { + "description": "Position this operation acted on", + "type": "string" + }, + "baseFeeAmountCollected": { + "format": "decimal", + "type": "number" + }, + "quoteFeeAmountCollected": { + "format": "decimal", + "type": "number" + } + }, + "required": [ + "fee", + "baseFeeAmountCollected", + "quoteFeeAmountCollected" + ] + }, + "ClosePositionRequest": { + "type": "object", + "properties": { + "network": { + "type": "string" + }, + "walletAddress": { + "type": "string" + }, + "positionAddress": { + "type": "string" + } + }, + "required": [ + "positionAddress" + ] + }, + "ClosePositionResponse": { + "type": "object", + "properties": { + "signature": { + "type": "string" + }, + "status": { + "description": "TransactionStatus enum value", + "type": "number" + }, + "data": { + "$ref": "#/components/schemas/ClosePositionResponseData" + } + }, + "required": [ + "signature", + "status" + ] + }, + "ClosePositionResponseData": { + "type": "object", + "properties": { + "fee": { + "format": "decimal", + "type": "number" + }, + "poolAddress": { + "description": "Pool this operation acted on", + "type": "string" + }, + "positionAddress": { + "description": "Position this operation acted on", + "type": "string" + }, + "positionRentRefunded": { + "format": "decimal", + "type": "number" + }, + "baseTokenAmountRemoved": { + "format": "decimal", + "type": "number" + }, + "quoteTokenAmountRemoved": { + "format": "decimal", + "type": "number" + }, + "baseFeeAmountCollected": { + "format": "decimal", + "type": "number" + }, + "quoteFeeAmountCollected": { + "format": "decimal", + "type": "number" + } + }, + "required": [ + "fee", + "positionRentRefunded", + "baseTokenAmountRemoved", + "quoteTokenAmountRemoved", + "baseFeeAmountCollected", + "quoteFeeAmountCollected" + ] + }, + "ClmmCreatePoolRequest": { + "type": "object", + "properties": { + "network": { + "type": "string" + }, + "walletAddress": { + "type": "string" + }, + "baseToken": { + "type": "string" + }, + "quoteToken": { + "type": "string" + }, + "initialPrice": { + "format": "decimal", + "description": "Initial pool price as quote per base. If omitted, the current market price is fetched from the unified swap router so the pool opens on-market.", + "type": "number" + }, + "binStep": { + "x-connectors": [ + "meteora", + "orca" + ], + "description": "Bin/tick granularity: Meteora DLMM bin step (bps); Orca Whirlpool tick spacing.", + "type": "number" + }, + "feeBps": { + "x-connectors": [ + "meteora", + "uniswap", + "pancakeswap" + ], + "description": "Base fee in basis points: Meteora DLMM base fee; Uniswap/PancakeSwap V3 fee tier (1, 5, 30 or 100 bps; PancakeSwap also 25).", + "type": "number" + }, + "ammConfigIndex": { + "x-connectors": [ + "raydium", + "pancakeswap-sol" + ], + "description": "Fee-config index for the Raydium CLMM family: Raydium API config list index; pancakeswap-sol amm_config PDA index. Default 0.", + "type": "number" + } + }, + "required": [ + "baseToken", + "quoteToken" + ] + }, + "ClmmCreatePoolResponse": { + "type": "object", + "properties": { + "signature": { + "type": "string" + }, + "status": { + "description": "TransactionStatus enum value", + "type": "number" + }, + "poolAddress": { + "description": "Address of the newly created pool", + "type": "string" + }, + "price": { + "format": "decimal", + "description": "Initial price the pool was initialized at (quote per base)", + "type": "number" + }, + "data": { + "$ref": "#/components/schemas/ClmmCreatePoolResponseData" + } + }, + "required": [ + "signature", + "status", + "poolAddress" + ] + }, + "ClmmCreatePoolResponseData": { + "type": "object", + "properties": { + "fee": { + "format": "decimal", + "type": "number" + } + }, + "required": [ + "fee" + ] + }, + "QuotePositionRequest": { + "type": "object", + "properties": { + "network": { + "type": "string" + }, + "lowerPrice": { + "format": "decimal", + "type": "number" + }, + "upperPrice": { + "format": "decimal", + "type": "number" + }, + "poolAddress": { + "type": "string" + }, + "baseTokenAmount": { + "format": "decimal", + "type": "number" + }, + "quoteTokenAmount": { + "format": "decimal", + "type": "number" + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "type": "number" + } + }, + "required": [ + "lowerPrice", + "upperPrice", + "poolAddress" + ] + }, + "QuotePositionResponse": { + "type": "object", + "properties": { + "poolAddress": { + "description": "Pool the quote was computed against", + "type": "string" + }, + "baseLimited": { + "type": "boolean" + }, + "baseTokenAmount": { + "format": "decimal", + "type": "number" + }, + "quoteTokenAmount": { + "format": "decimal", + "type": "number" + }, + "baseTokenAmountMax": { + "format": "decimal", + "type": "number" + }, + "quoteTokenAmountMax": { + "format": "decimal", + "type": "number" + }, + "liquidity": {} + }, + "required": [ + "baseLimited", + "baseTokenAmount", + "quoteTokenAmount", + "baseTokenAmountMax", + "quoteTokenAmountMax" + ] + }, + "ClmmQuoteSwapRequest": { + "type": "object", + "properties": { + "network": { + "type": "string" + }, + "poolAddress": { + "description": "Pool address (optional - can be looked up from baseToken and quoteToken)", + "type": "string" + }, + "baseToken": { + "description": "Token to determine swap direction", + "type": "string" + }, + "quoteToken": { + "description": "The other token in the pair (optional - required if poolAddress not provided)", + "type": "string" + }, + "amount": { + "format": "decimal", + "type": "number" + }, + "side": { + "description": "Trade direction", + "enum": [ + "BUY", + "SELL" + ], + "type": "string" + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "type": "number" + } + }, + "required": [ + "baseToken", + "amount", + "side" + ] + }, + "ClmmQuoteSwapResponse": { + "type": "object", + "properties": { + "poolAddress": { + "type": "string" + }, + "tokenIn": { + "type": "string" + }, + "tokenOut": { + "type": "string" + }, + "amountIn": { + "format": "decimal", + "type": "number" + }, + "amountOut": { + "format": "decimal", + "type": "number" + }, + "price": { + "format": "decimal", + "type": "number" + }, + "slippagePct": { + "format": "decimal", + "type": "number" + }, + "minAmountOut": { + "format": "decimal", + "type": "number" + }, + "maxAmountIn": { + "format": "decimal", + "type": "number" + }, + "priceImpactPct": { + "format": "decimal", + "type": "number" + } + }, + "required": [ + "poolAddress", + "tokenIn", + "tokenOut", + "amountIn", + "amountOut", + "price", + "minAmountOut", + "maxAmountIn", + "priceImpactPct" + ] + }, + "ClmmExecuteSwapRequest": { + "type": "object", + "properties": { + "walletAddress": { + "type": "string" + }, + "network": { + "type": "string" + }, + "poolAddress": { + "description": "Pool address (optional - can be looked up from baseToken and quoteToken)", + "type": "string" + }, + "baseToken": { + "type": "string" + }, + "quoteToken": { + "description": "The other token in the pair (optional - required if poolAddress not provided)", + "type": "string" + }, + "amount": { + "format": "decimal", + "type": "number" + }, + "side": { + "enum": [ + "BUY", + "SELL" + ], + "type": "string" + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "type": "number" + } + }, + "required": [ + "baseToken", + "amount", + "side" + ] + }, + "ClmmExecuteSwapResponse": { + "type": "object", + "properties": { + "signature": { + "type": "string" + }, + "status": { + "description": "TransactionStatus enum value", + "type": "number" + }, + "data": { + "$ref": "#/components/schemas/ClmmExecuteSwapResponseData" + } + }, + "required": [ + "signature", + "status" + ] + }, + "ClmmExecuteSwapResponseData": { + "type": "object", + "properties": { + "tokenIn": { + "type": "string" + }, + "tokenOut": { + "type": "string" + }, + "amountIn": { + "format": "decimal", + "type": "number" + }, + "amountOut": { + "format": "decimal", + "type": "number" + }, + "fee": { + "format": "decimal", + "type": "number" + }, + "baseTokenBalanceChange": { + "format": "decimal", + "type": "number" + }, + "quoteTokenBalanceChange": { + "format": "decimal", + "type": "number" + }, + "slippagePct": { + "format": "decimal", + "description": "Slippage tolerance percentage actually applied to the swap", + "type": "number" + } + }, + "required": [ + "tokenIn", + "tokenOut", + "amountIn", + "amountOut", + "fee", + "baseTokenBalanceChange", + "quoteTokenBalanceChange" + ] + }, + "QuoteSwapRequest": { + "type": "object", + "properties": { + "network": { + "description": "The blockchain network to use", + "type": "string" + }, + "baseToken": { + "description": "Token to determine swap direction", + "type": "string" + }, + "quoteToken": { + "description": "The other token in the pair", + "type": "string" + }, + "amount": { + "format": "decimal", + "description": "Amount of base token to trade", + "type": "number" + }, + "side": { + "description": "Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token", + "enum": [ + "BUY", + "SELL" + ], + "type": "string" + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage", + "type": "number" + }, + "approximateIfNoExactOut": { + "description": "For BUY orders on routers without ExactOut support: approximate the required input via a sell-leg quote and return an ExactIn quote flagged as an approximation. If false, such BUY requests fail with a clear error.", + "default": true, + "type": "boolean" + } + }, + "required": [ + "baseToken", + "quoteToken", + "amount", + "side" + ] + }, + "QuoteSwapResponse": { + "type": "object", + "properties": { + "quoteId": { + "description": "Unique identifier for this quote", + "type": "string" + }, + "tokenIn": { + "description": "Address of the token being swapped from", + "type": "string" + }, + "tokenOut": { + "description": "Address of the token being swapped to", + "type": "string" + }, + "amountIn": { + "format": "decimal", + "description": "Amount of tokenIn to be swapped", + "type": "number" + }, + "amountOut": { + "format": "decimal", + "description": "Expected amount of tokenOut to receive", + "type": "number" + }, + "price": { + "format": "decimal", + "description": "Exchange rate between tokenIn and tokenOut", + "type": "number" + }, + "priceImpactPct": { + "format": "decimal", + "description": "Estimated price impact percentage (0-100)", + "type": "number" + }, + "minAmountOut": { + "format": "decimal", + "description": "Minimum amount of tokenOut that will be accepted", + "type": "number" + }, + "maxAmountIn": { + "format": "decimal", + "description": "Maximum amount of tokenIn that will be spent", + "type": "number" + }, + "approximation": { + "description": "True when a BUY was approximated via a sell-leg ExactIn quote because the router does not support ExactOut; amountOut is an estimate rather than exact", + "type": "boolean" + } + }, + "required": [ + "quoteId", + "tokenIn", + "tokenOut", + "amountIn", + "amountOut", + "price", + "priceImpactPct", + "minAmountOut", + "maxAmountIn" + ] + }, + "ExecuteQuoteRequest": { + "type": "object", + "properties": { + "walletAddress": { + "description": "Wallet address that will execute the swap", + "type": "string" + }, + "network": { + "description": "The blockchain network to use", + "type": "string" + }, + "quoteId": { + "description": "ID of the quote to execute", + "type": "string" + } + }, + "required": [ + "quoteId" + ] + }, + "ExecuteSwapRequest": { + "type": "object", + "properties": { + "walletAddress": { + "description": "Wallet address that will execute the swap", + "type": "string" + }, + "network": { + "description": "The blockchain network to use", + "type": "string" + }, + "baseToken": { + "description": "Token to determine swap direction", + "type": "string" + }, + "quoteToken": { + "description": "The other token in the pair", + "type": "string" + }, + "amount": { + "format": "decimal", + "description": "Amount of base token to trade", + "type": "number" + }, + "side": { + "description": "Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token", + "enum": [ + "BUY", + "SELL" + ], + "type": "string" + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage", + "type": "number" + }, + "approximateIfNoExactOut": { + "description": "For BUY orders on routers without ExactOut support: approximate the required input via a sell-leg quote and execute an ExactIn swap. If false, such BUY requests fail with a clear error.", + "default": true, + "type": "boolean" + } + }, + "required": [ + "baseToken", + "quoteToken", + "amount", + "side" + ] + }, + "SwapExecuteResponse": { + "type": "object", + "properties": { + "signature": { + "description": "Transaction signature/hash", + "type": "string" + }, + "status": { + "description": "Transaction status: 0 = PENDING, 1 = CONFIRMED, -1 = FAILED", + "type": "number" + }, + "data": { + "$ref": "#/components/schemas/SwapExecuteResponseData" + } + }, + "required": [ + "signature", + "status" + ] + }, + "SwapExecuteResponseData": { + "type": "object", + "properties": { + "tokenIn": { + "description": "Address of the token swapped from", + "type": "string" + }, + "tokenOut": { + "description": "Address of the token swapped to", + "type": "string" + }, + "amountIn": { + "format": "decimal", + "description": "Actual amount of tokenIn swapped", + "type": "number" + }, + "amountOut": { + "format": "decimal", + "description": "Actual amount of tokenOut received", + "type": "number" + }, + "fee": { + "format": "decimal", + "description": "Transaction fee paid", + "type": "number" + }, + "baseTokenBalanceChange": { + "format": "decimal", + "description": "Change in base token balance (negative for decrease)", + "type": "number" + }, + "quoteTokenBalanceChange": { + "format": "decimal", + "description": "Change in quote token balance (negative for decrease)", + "type": "number" + }, + "slippagePct": { + "format": "decimal", + "description": "Slippage tolerance percentage actually applied to the swap", + "type": "number" + } + }, + "required": [ + "tokenIn", + "tokenOut", + "amountIn", + "amountOut", + "fee", + "baseTokenBalanceChange", + "quoteTokenBalanceChange" + ] + } + } }, "paths": { "/config/": { @@ -1420,6 +4205,7 @@ "type": "string" }, "feePct": { + "format": "decimal", "type": "number" }, "address": { @@ -1545,6 +4331,7 @@ "type": "string" }, "feePct": { + "format": "decimal", "type": "number" }, "address": { @@ -1743,6 +4530,7 @@ "type": "string" }, "feePct": { + "format": "decimal", "type": "number" }, "address": { @@ -1909,6 +4697,7 @@ "type": "string" }, "feePct": { + "format": "decimal", "type": "number" }, "address": { @@ -1994,6 +4783,7 @@ "example": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" }, "feePct": { + "format": "decimal", "description": "Pool fee percentage (optional - fetched from pool-info if not provided)", "minimum": 0, "maximum": 100, @@ -2144,6 +4934,7 @@ "type": "string" }, "feePct": { + "format": "decimal", "type": "number" }, "address": { @@ -2391,96 +5182,24 @@ }, { "schema": { - "x-connectors": [ - "0x" - ], - "type": "boolean" - }, - "in": "query", - "name": "indicativePrice", - "required": false, - "description": "Return an indicative price instead of a firm, executable quote. An indicative quote cannot be executed with /trading/router/execute-quote." - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "tokenIn": { - "description": "Address of the token being swapped from", - "type": "string" - }, - "tokenOut": { - "description": "Address of the token being swapped to", - "type": "string" - }, - "amountIn": { - "format": "decimal", - "description": "Amount of tokenIn to be swapped", - "type": "number" - }, - "amountOut": { - "format": "decimal", - "description": "Expected amount of tokenOut to receive", - "type": "number" - }, - "price": { - "format": "decimal", - "description": "Exchange rate between tokenIn and tokenOut", - "type": "number" - }, - "priceImpactPct": { - "format": "decimal", - "description": "Estimated price impact percentage (0-100)", - "type": "number" - }, - "minAmountOut": { - "format": "decimal", - "description": "Minimum amount of tokenOut that will be accepted", - "type": "number" - }, - "maxAmountIn": { - "format": "decimal", - "description": "Maximum amount of tokenIn that will be spent", - "type": "number" - }, - "poolAddress": { - "description": "Pool address for AMM/CLMM swaps", - "type": "string" - }, - "routePath": { - "description": "Route path for router-based swaps", - "type": "string" - }, - "slippagePct": { - "format": "decimal", - "description": "Slippage tolerance percentage", - "type": "number" - }, - "quoteId": { - "description": "Identifier to pass to /trading/router/execute-quote", - "type": "string" - }, - "approximation": { - "description": "True when a BUY was approximated via a sell-leg ExactIn quote because the router has no ExactOut route; amountOut is an estimate rather than exact", - "type": "boolean" - } - }, - "required": [ - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "price", - "priceImpactPct", - "minAmountOut", - "maxAmountIn", - "quoteId" - ] + "x-connectors": [ + "0x" + ], + "type": "boolean" + }, + "in": "query", + "name": "indicativePrice", + "required": false, + "description": "Return an indicative price instead of a firm, executable quote. An indicative quote cannot be executed with /trading/router/execute-quote." + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RouterQuoteSwapResponse" } } } @@ -2547,77 +5266,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "signature": { - "description": "Transaction signature/hash", - "type": "string" - }, - "status": { - "description": "Transaction status: 0 = PENDING, 1 = CONFIRMED, -1 = FAILED", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "tokenIn": { - "description": "Address of the token swapped from", - "type": "string" - }, - "tokenOut": { - "description": "Address of the token swapped to", - "type": "string" - }, - "amountIn": { - "format": "decimal", - "description": "Actual amount of tokenIn swapped", - "type": "number" - }, - "amountOut": { - "format": "decimal", - "description": "Actual amount of tokenOut received", - "type": "number" - }, - "fee": { - "format": "decimal", - "description": "Transaction fee paid", - "type": "number" - }, - "baseTokenBalanceChange": { - "format": "decimal", - "description": "Change in base token balance (negative for decrease)", - "type": "number" - }, - "quoteTokenBalanceChange": { - "format": "decimal", - "description": "Change in quote token balance (negative for decrease)", - "type": "number" - }, - "slippagePct": { - "format": "decimal", - "description": "Slippage tolerance percentage actually applied to the swap", - "type": "number" - }, - "poolAddress": { - "description": "Pool the swap executed against. Set by the pool-scoped routes (/trading/clmm, /trading/amm), which resolve exactly one pool; a router picks its own path across pools and leaves this unset. Without it a settled fill cannot be reconciled to a venue without refetching the transaction.", - "type": "string" - } - }, - "required": [ - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "fee", - "baseTokenBalanceChange", - "quoteTokenBalanceChange" - ] - } - }, - "required": [ - "signature", - "status" - ] + "$ref": "#/components/schemas/ChainExecuteSwapResponse" } } } @@ -2727,77 +5376,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "signature": { - "description": "Transaction signature/hash", - "type": "string" - }, - "status": { - "description": "Transaction status: 0 = PENDING, 1 = CONFIRMED, -1 = FAILED", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "tokenIn": { - "description": "Address of the token swapped from", - "type": "string" - }, - "tokenOut": { - "description": "Address of the token swapped to", - "type": "string" - }, - "amountIn": { - "format": "decimal", - "description": "Actual amount of tokenIn swapped", - "type": "number" - }, - "amountOut": { - "format": "decimal", - "description": "Actual amount of tokenOut received", - "type": "number" - }, - "fee": { - "format": "decimal", - "description": "Transaction fee paid", - "type": "number" - }, - "baseTokenBalanceChange": { - "format": "decimal", - "description": "Change in base token balance (negative for decrease)", - "type": "number" - }, - "quoteTokenBalanceChange": { - "format": "decimal", - "description": "Change in quote token balance (negative for decrease)", - "type": "number" - }, - "slippagePct": { - "format": "decimal", - "description": "Slippage tolerance percentage actually applied to the swap", - "type": "number" - }, - "poolAddress": { - "description": "Pool the swap executed against. Set by the pool-scoped routes (/trading/clmm, /trading/amm), which resolve exactly one pool; a router picks its own path across pools and leaves this unset. Without it a settled fill cannot be reconciled to a venue without refetching the transaction.", - "type": "string" - } - }, - "required": [ - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "fee", - "baseTokenBalanceChange", - "quoteTokenBalanceChange" - ] - } - }, - "required": [ - "signature", - "status" - ] + "$ref": "#/components/schemas/ChainExecuteSwapResponse" } } } @@ -2871,73 +5450,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "address": { - "type": "string" - }, - "baseTokenAddress": { - "type": "string" - }, - "quoteTokenAddress": { - "type": "string" - }, - "binStep": { - "type": "number" - }, - "feePct": { - "type": "number" - }, - "price": { - "type": "number" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "activeBinId": { - "type": "number" - }, - "bins": { - "type": "array", - "items": { - "type": "object", - "properties": { - "binId": { - "type": "number" - }, - "price": { - "type": "number" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - } - }, - "required": [ - "binId", - "price", - "baseTokenAmount", - "quoteTokenAmount" - ], - "title": "BinLiquidity" - } - } - }, - "required": [ - "address", - "baseTokenAddress", - "quoteTokenAddress", - "feePct", - "price", - "baseTokenAmount", - "quoteTokenAmount", - "activeBinId" - ] + "$ref": "#/components/schemas/PoolInfo" } } } @@ -2999,63 +5512,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "address": { - "type": "string" - }, - "poolAddress": { - "type": "string" - }, - "baseTokenAddress": { - "type": "string" - }, - "quoteTokenAddress": { - "type": "string" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "baseFeeAmount": { - "type": "number" - }, - "quoteFeeAmount": { - "type": "number" - }, - "lowerBinId": { - "type": "number" - }, - "upperBinId": { - "type": "number" - }, - "lowerPrice": { - "type": "number" - }, - "upperPrice": { - "type": "number" - }, - "price": { - "type": "number" - } - }, - "required": [ - "address", - "poolAddress", - "baseTokenAddress", - "quoteTokenAddress", - "baseTokenAmount", - "quoteTokenAmount", - "baseFeeAmount", - "quoteFeeAmount", - "lowerBinId", - "upperBinId", - "lowerPrice", - "upperPrice", - "price" - ] + "$ref": "#/components/schemas/PositionInfo" } } } @@ -3088,95 +5545,38 @@ "name": "connector", "required": true, "description": "CLMM connector" - }, - { - "schema": { - "default": "solana-mainnet-beta", - "type": "string" - }, - "example": "solana-mainnet-beta", - "in": "query", - "name": "chainNetwork", - "required": true, - "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" - }, - { - "schema": { - "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", - "type": "string" - }, - "in": "query", - "name": "walletAddress", - "required": true, - "description": "Wallet address" - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "address": { - "type": "string" - }, - "poolAddress": { - "type": "string" - }, - "baseTokenAddress": { - "type": "string" - }, - "quoteTokenAddress": { - "type": "string" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "baseFeeAmount": { - "type": "number" - }, - "quoteFeeAmount": { - "type": "number" - }, - "lowerBinId": { - "type": "number" - }, - "upperBinId": { - "type": "number" - }, - "lowerPrice": { - "type": "number" - }, - "upperPrice": { - "type": "number" - }, - "price": { - "type": "number" - } - }, - "required": [ - "address", - "poolAddress", - "baseTokenAddress", - "quoteTokenAddress", - "baseTokenAmount", - "quoteTokenAmount", - "baseFeeAmount", - "quoteFeeAmount", - "lowerBinId", - "upperBinId", - "lowerPrice", - "upperPrice", - "price" - ], - "title": "PositionInfo" + }, + { + "schema": { + "default": "solana-mainnet-beta", + "type": "string" + }, + "example": "solana-mainnet-beta", + "in": "query", + "name": "chainNetwork", + "required": true, + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" + }, + { + "schema": { + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "in": "query", + "name": "walletAddress", + "required": true, + "description": "Wallet address" + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PositionInfo" } } } @@ -3296,32 +5696,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "baseLimited": { - "type": "boolean" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "baseTokenAmountMax": { - "type": "number" - }, - "quoteTokenAmountMax": { - "type": "number" - }, - "liquidity": {} - }, - "required": [ - "baseLimited", - "baseTokenAmount", - "quoteTokenAmount", - "baseTokenAmountMax", - "quoteTokenAmountMax" - ] + "$ref": "#/components/schemas/QuotePositionResponse" } } } @@ -3468,111 +5843,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "pools": { - "type": "array", - "items": { - "type": "object", - "properties": { - "address": { - "description": "Pool address", - "type": "string" - }, - "name": { - "description": "Pool name (e.g., SOL-USDC)", - "type": "string" - }, - "baseTokenAddress": { - "description": "Base token address", - "type": "string" - }, - "baseTokenSymbol": { - "description": "Base token symbol", - "type": "string" - }, - "quoteTokenAddress": { - "description": "Quote token address", - "type": "string" - }, - "quoteTokenSymbol": { - "description": "Quote token symbol", - "type": "string" - }, - "binStep": { - "description": "Bin step / tick spacing", - "type": "number" - }, - "baseFee": { - "format": "decimal", - "description": "Base fee percentage", - "type": "number" - }, - "price": { - "format": "decimal", - "description": "Current price", - "type": "number" - }, - "tvl": { - "format": "decimal", - "description": "Total value locked in USD", - "type": "number" - }, - "apr": { - "format": "decimal", - "description": "Annual percentage rate", - "type": "number" - }, - "apy": { - "format": "decimal", - "description": "Annual percentage yield", - "type": "number" - }, - "volume24h": { - "format": "decimal", - "description": "24-hour trading volume", - "type": "number" - }, - "fees24h": { - "format": "decimal", - "description": "24-hour fees collected", - "type": "number" - } - }, - "required": [ - "address", - "name", - "baseTokenAddress", - "baseTokenSymbol", - "quoteTokenAddress", - "quoteTokenSymbol", - "binStep", - "baseFee", - "price", - "tvl" - ], - "title": "PoolListItem" - } - }, - "total": { - "description": "Total number of matching pools", - "type": "number" - }, - "page": { - "description": "Current page number", - "type": "number" - }, - "pageSize": { - "description": "Number of pools per page", - "type": "number" - } - }, - "required": [ - "pools", - "total", - "page", - "pageSize" - ] + "$ref": "#/components/schemas/FetchPoolsResponse" } } } @@ -3691,70 +5962,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "tokenIn": { - "description": "Address of the token being swapped from", - "type": "string" - }, - "tokenOut": { - "description": "Address of the token being swapped to", - "type": "string" - }, - "amountIn": { - "format": "decimal", - "description": "Amount of tokenIn to be swapped", - "type": "number" - }, - "amountOut": { - "format": "decimal", - "description": "Expected amount of tokenOut to receive", - "type": "number" - }, - "price": { - "format": "decimal", - "description": "Exchange rate between tokenIn and tokenOut", - "type": "number" - }, - "priceImpactPct": { - "format": "decimal", - "description": "Estimated price impact percentage (0-100)", - "type": "number" - }, - "minAmountOut": { - "format": "decimal", - "description": "Minimum amount of tokenOut that will be accepted", - "type": "number" - }, - "maxAmountIn": { - "format": "decimal", - "description": "Maximum amount of tokenIn that will be spent", - "type": "number" - }, - "poolAddress": { - "description": "Pool address for AMM/CLMM swaps", - "type": "string" - }, - "routePath": { - "description": "Route path for router-based swaps", - "type": "string" - }, - "slippagePct": { - "format": "decimal", - "description": "Slippage tolerance percentage", - "type": "number" - } - }, - "required": [ - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "price", - "priceImpactPct", - "minAmountOut", - "maxAmountIn" - ] + "$ref": "#/components/schemas/ChainQuoteSwapResponse" } } } @@ -3856,77 +6064,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "signature": { - "description": "Transaction signature/hash", - "type": "string" - }, - "status": { - "description": "Transaction status: 0 = PENDING, 1 = CONFIRMED, -1 = FAILED", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "tokenIn": { - "description": "Address of the token swapped from", - "type": "string" - }, - "tokenOut": { - "description": "Address of the token swapped to", - "type": "string" - }, - "amountIn": { - "format": "decimal", - "description": "Actual amount of tokenIn swapped", - "type": "number" - }, - "amountOut": { - "format": "decimal", - "description": "Actual amount of tokenOut received", - "type": "number" - }, - "fee": { - "format": "decimal", - "description": "Transaction fee paid", - "type": "number" - }, - "baseTokenBalanceChange": { - "format": "decimal", - "description": "Change in base token balance (negative for decrease)", - "type": "number" - }, - "quoteTokenBalanceChange": { - "format": "decimal", - "description": "Change in quote token balance (negative for decrease)", - "type": "number" - }, - "slippagePct": { - "format": "decimal", - "description": "Slippage tolerance percentage actually applied to the swap", - "type": "number" - }, - "poolAddress": { - "description": "Pool the swap executed against. Set by the pool-scoped routes (/trading/clmm, /trading/amm), which resolve exactly one pool; a router picks its own path across pools and leaves this unset. Without it a settled fill cannot be reconciled to a venue without refetching the transaction.", - "type": "string" - } - }, - "required": [ - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "fee", - "baseTokenBalanceChange", - "quoteTokenBalanceChange" - ] - } - }, - "required": [ - "signature", - "status" - ] + "$ref": "#/components/schemas/ChainExecuteSwapResponse" } } } @@ -4036,47 +6174,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "positionAddress": { - "type": "string" - }, - "positionRent": { - "type": "number" - }, - "baseTokenAmountAdded": { - "type": "number" - }, - "quoteTokenAmountAdded": { - "type": "number" - } - }, - "required": [ - "fee", - "positionAddress", - "positionRent", - "baseTokenAmountAdded", - "quoteTokenAmountAdded" - ] - } - }, - "required": [ - "signature", - "status" - ] + "$ref": "#/components/schemas/OpenPositionResponse" } } } @@ -4172,39 +6270,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "baseTokenAmountAdded": { - "type": "number" - }, - "quoteTokenAmountAdded": { - "type": "number" - } - }, - "required": [ - "fee", - "baseTokenAmountAdded", - "quoteTokenAmountAdded" - ] - } - }, - "required": [ - "signature", - "status" - ] + "$ref": "#/components/schemas/AddLiquidityResponse" } } } @@ -4290,39 +6356,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "baseTokenAmountRemoved": { - "type": "number" - }, - "quoteTokenAmountRemoved": { - "type": "number" - } - }, - "required": [ - "fee", - "baseTokenAmountRemoved", - "quoteTokenAmountRemoved" - ] - } - }, - "required": [ - "signature", - "status" - ] + "$ref": "#/components/schemas/RemoveLiquidityResponse" } } } @@ -4390,39 +6424,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "baseFeeAmountCollected": { - "type": "number" - }, - "quoteFeeAmountCollected": { - "type": "number" - } - }, - "required": [ - "fee", - "baseFeeAmountCollected", - "quoteFeeAmountCollected" - ] - } - }, - "required": [ - "signature", - "status" - ] + "$ref": "#/components/schemas/CollectFeesResponse" } } } @@ -4490,51 +6492,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "positionRentRefunded": { - "type": "number" - }, - "baseTokenAmountRemoved": { - "type": "number" - }, - "quoteTokenAmountRemoved": { - "type": "number" - }, - "baseFeeAmountCollected": { - "type": "number" - }, - "quoteFeeAmountCollected": { - "type": "number" - } - }, - "required": [ - "fee", - "positionRentRefunded", - "baseTokenAmountRemoved", - "quoteTokenAmountRemoved", - "baseFeeAmountCollected", - "quoteFeeAmountCollected" - ] - } - }, - "required": [ - "signature", - "status" - ] + "$ref": "#/components/schemas/ClosePositionResponse" } } } @@ -4634,41 +6592,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "poolAddress": { - "description": "Address of the newly created pool", - "type": "string" - }, - "price": { - "format": "decimal", - "description": "Initial price the pool was initialized at (quote per base)", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - } - }, - "required": [ - "fee" - ] - } - }, - "required": [ - "signature", - "status", - "poolAddress" - ] + "$ref": "#/components/schemas/ClmmCreatePoolResponse" } } } @@ -4727,39 +6651,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "address": { - "type": "string" - }, - "baseTokenAddress": { - "type": "string" - }, - "quoteTokenAddress": { - "type": "string" - }, - "feePct": { - "type": "number" - }, - "price": { - "type": "number" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - } - }, - "required": [ - "address", - "baseTokenAddress", - "quoteTokenAddress", - "feePct", - "price", - "baseTokenAmount", - "quoteTokenAmount" - ] + "$ref": "#/components/schemas/AmmPoolInfo" } } } @@ -4828,73 +6720,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "poolAddress": { - "type": "string" - }, - "walletAddress": { - "type": "string" - }, - "baseTokenAddress": { - "type": "string" - }, - "quoteTokenAddress": { - "type": "string" - }, - "lpTokenAmount": { - "type": "number" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "price": { - "type": "number" - }, - "positions": { - "type": "array", - "items": { - "type": "object", - "properties": { - "positionAddress": { - "description": "Address of the individual position (NFT position account)", - "type": "string" - }, - "lpTokenAmount": { - "format": "decimal", - "description": "Liquidity held by this position (LP units)", - "type": "number" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - } - }, - "required": [ - "positionAddress", - "lpTokenAmount", - "baseTokenAmount", - "quoteTokenAmount" - ], - "title": "PositionDetail" - } - } - }, - "required": [ - "poolAddress", - "walletAddress", - "baseTokenAddress", - "quoteTokenAddress", - "lpTokenAmount", - "baseTokenAmount", - "quoteTokenAmount", - "price" - ] + "$ref": "#/components/schemas/AmmPositionInfo" } } } @@ -4956,72 +6782,7 @@ "schema": { "type": "array", "items": { - "type": "object", - "properties": { - "poolAddress": { - "type": "string" - }, - "walletAddress": { - "type": "string" - }, - "baseTokenAddress": { - "type": "string" - }, - "quoteTokenAddress": { - "type": "string" - }, - "lpTokenAmount": { - "type": "number" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "price": { - "type": "number" - }, - "positions": { - "type": "array", - "items": { - "type": "object", - "properties": { - "positionAddress": { - "description": "Address of the individual position (NFT position account)", - "type": "string" - }, - "lpTokenAmount": { - "format": "decimal", - "description": "Liquidity held by this position (LP units)", - "type": "number" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - } - }, - "required": [ - "positionAddress", - "lpTokenAmount", - "baseTokenAmount", - "quoteTokenAmount" - ] - } - } - }, - "required": [ - "poolAddress", - "walletAddress", - "baseTokenAddress", - "quoteTokenAddress", - "lpTokenAmount", - "baseTokenAmount", - "quoteTokenAmount", - "price" - ] + "$ref": "#/components/schemas/AmmPositionInfo" } } } @@ -5114,31 +6875,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "baseLimited": { - "type": "boolean" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "baseTokenAmountMax": { - "type": "number" - }, - "quoteTokenAmountMax": { - "type": "number" - } - }, - "required": [ - "baseLimited", - "baseTokenAmount", - "quoteTokenAmount", - "baseTokenAmountMax", - "quoteTokenAmountMax" - ] + "$ref": "#/components/schemas/QuoteLiquidityResponse" } } } @@ -5236,89 +6973,26 @@ "description": "Pool to trade against. Omit to resolve it from Gateway's configured pool list by token pair; pass an address to pin a pool that is not in that list." }, { - "schema": { - "format": "decimal", - "minimum": 0, - "maximum": 100, - "type": "number" - }, - "example": 1, - "in": "query", - "name": "slippagePct", - "required": false, - "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct." - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "tokenIn": { - "description": "Address of the token being swapped from", - "type": "string" - }, - "tokenOut": { - "description": "Address of the token being swapped to", - "type": "string" - }, - "amountIn": { - "format": "decimal", - "description": "Amount of tokenIn to be swapped", - "type": "number" - }, - "amountOut": { - "format": "decimal", - "description": "Expected amount of tokenOut to receive", - "type": "number" - }, - "price": { - "format": "decimal", - "description": "Exchange rate between tokenIn and tokenOut", - "type": "number" - }, - "priceImpactPct": { - "format": "decimal", - "description": "Estimated price impact percentage (0-100)", - "type": "number" - }, - "minAmountOut": { - "format": "decimal", - "description": "Minimum amount of tokenOut that will be accepted", - "type": "number" - }, - "maxAmountIn": { - "format": "decimal", - "description": "Maximum amount of tokenIn that will be spent", - "type": "number" - }, - "poolAddress": { - "description": "Pool address for AMM/CLMM swaps", - "type": "string" - }, - "routePath": { - "description": "Route path for router-based swaps", - "type": "string" - }, - "slippagePct": { - "format": "decimal", - "description": "Slippage tolerance percentage", - "type": "number" - } - }, - "required": [ - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "price", - "priceImpactPct", - "minAmountOut", - "maxAmountIn" - ] + "schema": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "type": "number" + }, + "example": 1, + "in": "query", + "name": "slippagePct", + "required": false, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct." + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChainQuoteSwapResponse" } } } @@ -5418,77 +7092,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "signature": { - "description": "Transaction signature/hash", - "type": "string" - }, - "status": { - "description": "Transaction status: 0 = PENDING, 1 = CONFIRMED, -1 = FAILED", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "tokenIn": { - "description": "Address of the token swapped from", - "type": "string" - }, - "tokenOut": { - "description": "Address of the token swapped to", - "type": "string" - }, - "amountIn": { - "format": "decimal", - "description": "Actual amount of tokenIn swapped", - "type": "number" - }, - "amountOut": { - "format": "decimal", - "description": "Actual amount of tokenOut received", - "type": "number" - }, - "fee": { - "format": "decimal", - "description": "Transaction fee paid", - "type": "number" - }, - "baseTokenBalanceChange": { - "format": "decimal", - "description": "Change in base token balance (negative for decrease)", - "type": "number" - }, - "quoteTokenBalanceChange": { - "format": "decimal", - "description": "Change in quote token balance (negative for decrease)", - "type": "number" - }, - "slippagePct": { - "format": "decimal", - "description": "Slippage tolerance percentage actually applied to the swap", - "type": "number" - }, - "poolAddress": { - "description": "Pool the swap executed against. Set by the pool-scoped routes (/trading/clmm, /trading/amm), which resolve exactly one pool; a router picks its own path across pools and leaves this unset. Without it a settled fill cannot be reconciled to a venue without refetching the transaction.", - "type": "string" - } - }, - "required": [ - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "fee", - "baseTokenBalanceChange", - "quoteTokenBalanceChange" - ] - } - }, - "required": [ - "signature", - "status" - ] + "$ref": "#/components/schemas/ChainExecuteSwapResponse" } } } @@ -5573,52 +7177,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "format": "decimal", - "type": "number" - }, - "positionAddress": { - "description": "Address of the newly opened position. Absent on fungible-LP AMMs, which hold liquidity as LP tokens rather than a position account.", - "type": "string" - }, - "positionRent": { - "format": "decimal", - "description": "Native token locked as rent for the position account, refunded on close. 0 on fungible-LP AMMs, which lock no rent.", - "type": "number" - }, - "baseTokenAmountAdded": { - "format": "decimal", - "type": "number" - }, - "quoteTokenAmountAdded": { - "format": "decimal", - "type": "number" - } - }, - "required": [ - "fee", - "positionRent", - "baseTokenAmountAdded", - "quoteTokenAmountAdded" - ] - } - }, - "required": [ - "signature", - "status" - ] + "$ref": "#/components/schemas/AmmOpenPositionResponse" } } } @@ -5710,57 +7269,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "format": "decimal", - "type": "number" - }, - "positionAddress": { - "description": "Position the liquidity went into. Absent on fungible-LP AMMs, which hold liquidity as LP tokens rather than a position account.", - "x-connectors": [ - "meteora" - ], - "type": "string" - }, - "positionRent": { - "format": "decimal", - "description": "Native token locked as rent when this call opened the position. Absent when adding to a position that already existed, and on fungible-LP AMMs.", - "x-connectors": [ - "meteora" - ], - "type": "number" - }, - "baseTokenAmountAdded": { - "format": "decimal", - "type": "number" - }, - "quoteTokenAmountAdded": { - "format": "decimal", - "type": "number" - } - }, - "required": [ - "fee", - "baseTokenAmountAdded", - "quoteTokenAmountAdded" - ] - } - }, - "required": [ - "signature", - "status" - ] + "$ref": "#/components/schemas/AmmAddLiquidityResponse" } } } @@ -5850,39 +7359,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "baseTokenAmountRemoved": { - "type": "number" - }, - "quoteTokenAmountRemoved": { - "type": "number" - } - }, - "required": [ - "fee", - "baseTokenAmountRemoved", - "quoteTokenAmountRemoved" - ] - } - }, - "required": [ - "signature", - "status" - ] + "$ref": "#/components/schemas/AmmRemoveLiquidityResponse" } } } @@ -5962,48 +7439,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "format": "decimal", - "type": "number" - }, - "positionRentRefunded": { - "format": "decimal", - "description": "Native token rent returned when the position account closed. 0 on fungible-LP AMMs, which have no position account to close.", - "type": "number" - }, - "baseTokenAmountRemoved": { - "format": "decimal", - "type": "number" - }, - "quoteTokenAmountRemoved": { - "format": "decimal", - "type": "number" - } - }, - "required": [ - "fee", - "positionRentRefunded", - "baseTokenAmountRemoved", - "quoteTokenAmountRemoved" - ] - } - }, - "required": [ - "signature", - "status" - ] + "$ref": "#/components/schemas/AmmClosePositionResponse" } } } @@ -6087,73 +7523,31 @@ "format": "decimal", "minimum": 0, "maximum": 100, - "description": "Uniswap/PancakeSwap seeding slippage percentage. Defaults to the connector's configured slippagePct.", - "type": "number", - "example": 1 - } - }, - "required": [ - "connector", - "chainNetwork", - "walletAddress", - "baseToken", - "quoteToken", - "baseTokenAmount" - ] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "poolAddress": { - "description": "Address of the newly created pool", - "type": "string" - }, - "price": { - "format": "decimal", - "description": "Initial price the pool was seeded at (quote per base)", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "baseTokenAmountAdded": { - "type": "number" - }, - "quoteTokenAmountAdded": { - "type": "number" - } - }, - "required": [ - "fee", - "baseTokenAmountAdded", - "quoteTokenAmountAdded" - ] - } - }, - "required": [ - "signature", - "status", - "poolAddress" - ] + "description": "Uniswap/PancakeSwap seeding slippage percentage. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 + } + }, + "required": [ + "connector", + "chainNetwork", + "walletAddress", + "baseToken", + "quoteToken", + "baseTokenAmount" + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatePoolResponse" } } } @@ -6191,39 +7585,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "chain": { - "type": "string" - }, - "network": { - "type": "string" - }, - "rpcUrl": { - "type": "string" - }, - "rpcProvider": { - "type": "string" - }, - "currentBlockNumber": { - "type": "number" - }, - "nativeCurrency": { - "type": "string" - }, - "swapProvider": { - "type": "string" - } - }, - "required": [ - "chain", - "network", - "rpcUrl", - "rpcProvider", - "currentBlockNumber", - "nativeCurrency", - "swapProvider" - ] + "$ref": "#/components/schemas/StatusResponse" } } } @@ -6261,50 +7623,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "feePerComputeUnit": { - "type": "number" - }, - "denomination": { - "type": "string" - }, - "computeUnits": { - "type": "number" - }, - "feeAsset": { - "type": "string" - }, - "fee": { - "type": "number" - }, - "timestamp": { - "type": "number" - }, - "gasType": { - "type": "string" - }, - "maxFeePerGas": { - "type": "number" - }, - "maxPriorityFeePerGas": { - "type": "number" - }, - "priorityFeeLevel": { - "type": "string" - }, - "priorityFeePerCUEstimate": { - "type": "number" - } - }, - "required": [ - "feePerComputeUnit", - "denomination", - "computeUnits", - "feeAsset", - "fee", - "timestamp" - ] + "$ref": "#/components/schemas/EstimateGasResponse" } } } @@ -6322,26 +7641,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "network": { - "type": "string" - }, - "address": { - "type": "string" - }, - "tokens": { - "description": "a list of token symbols or addresses", - "type": "array", - "items": { - "type": "string" - } - }, - "fetchAll": { - "description": "fetch all tokens in wallet, not just those in token list (default: false)", - "type": "boolean" - } - } + "$ref": "#/components/schemas/BalanceRequest" } } } @@ -6362,18 +7662,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "balances": { - "type": "object", - "additionalProperties": { - "type": "number" - } - } - }, - "required": [ - "balances" - ] + "$ref": "#/components/schemas/BalanceResponse" } } } @@ -6391,23 +7680,10 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "network": { - "type": "string" - }, - "signature": { - "description": "Transaction signature/hash", - "type": "string" - } - }, - "required": [ - "signature" - ] + "$ref": "#/components/schemas/PollRequest" } } - }, - "required": true + } }, "parameters": [ { @@ -6425,70 +7701,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "currentBlock": { - "type": "number" - }, - "signature": { - "type": "string" - }, - "txBlock": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "txStatus": { - "description": "Transaction status: 1 = confirmed, 0 = pending, -1 = failed, -2 = not found (unknown to the chain: never received or dropped; on Solana this is terminal once the transaction blockhash expires)", - "type": "number" - }, - "fee": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - }, - "error": { - "anyOf": [ - { - "description": "Error info if failed: \"TYPE (code): message\"", - "type": "string" - }, - { - "type": "null" - } - ] - }, - "txData": { - "anyOf": [ - { - "type": "object", - "additionalProperties": {} - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "currentBlock", - "signature", - "txBlock", - "txStatus", - "fee", - "error", - "txData" - ] + "$ref": "#/components/schemas/PollResponse" } } } @@ -6506,29 +7719,10 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "network": { - "type": "string" - }, - "address": { - "description": "Wallet address holding the native token", - "type": "string" - }, - "amount": { - "description": "Amount of the native token to wrap, in whole units (not lamports/wei)", - "type": "string", - "example": "1.0" - } - }, - "required": [ - "address", - "amount" - ] + "$ref": "#/components/schemas/WrapRequest" } } - }, - "required": true + } }, "parameters": [ { @@ -6546,51 +7740,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "nonce": { - "description": "EVM transaction nonce; absent on non-EVM chains", - "type": "number" - }, - "fee": { - "type": "string" - }, - "amount": { - "type": "string" - }, - "wrappedAddress": { - "type": "string" - }, - "nativeToken": { - "type": "string" - }, - "wrappedToken": { - "type": "string" - } - }, - "required": [ - "fee", - "amount", - "wrappedAddress", - "nativeToken", - "wrappedToken" - ] - } - }, - "required": [ - "signature", - "status" - ] + "$ref": "#/components/schemas/ChainWrapResponse" } } } @@ -6608,28 +7758,10 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "network": { - "type": "string" - }, - "address": { - "description": "Wallet address holding the wrapped token", - "type": "string" - }, - "amount": { - "description": "Amount of the wrapped token to unwrap, in whole units. Solana unwraps the full balance when omitted; EVM chains require it.", - "type": "string", - "example": "1.0" - } - }, - "required": [ - "address" - ] + "$ref": "#/components/schemas/UnwrapRequest" } } - }, - "required": true + } }, "parameters": [ { @@ -6647,51 +7779,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "nonce": { - "description": "EVM transaction nonce; absent on non-EVM chains", - "type": "number" - }, - "fee": { - "type": "string" - }, - "amount": { - "type": "string" - }, - "wrappedAddress": { - "type": "string" - }, - "nativeToken": { - "type": "string" - }, - "wrappedToken": { - "type": "string" - } - }, - "required": [ - "fee", - "amount", - "wrappedAddress", - "nativeToken", - "wrappedToken" - ] - } - }, - "required": [ - "signature", - "status" - ] + "$ref": "#/components/schemas/ChainWrapResponse" } } } diff --git a/models/gateway_generated.py b/models/gateway_generated.py new file mode 100644 index 00000000..8d6acd91 --- /dev/null +++ b/models/gateway_generated.py @@ -0,0 +1,735 @@ +# Generated from gateway-openapi.json by 'make gateway-models'. Do not edit. +# flake8: noqa: E501 + +from __future__ import annotations + +from decimal import Decimal +from enum import StrEnum +from typing import Any + +from pydantic import BaseModel, Field, condecimal, confloat, conint + + +class AmmPoolInfo(BaseModel): + address: str + base_token_address: str = Field(..., alias='baseTokenAddress') + quote_token_address: str = Field(..., alias='quoteTokenAddress') + fee_pct: Decimal = Field(..., alias='feePct') + price: Decimal + base_token_amount: Decimal = Field(..., alias='baseTokenAmount') + quote_token_amount: Decimal = Field(..., alias='quoteTokenAmount') + + +class AmmGetPoolInfoRequest(BaseModel): + network: str | None = None + pool_address: str = Field(..., alias='poolAddress') + + +class AmmAddLiquidityRequest(BaseModel): + network: str | None = None + wallet_address: str | None = Field(None, alias='walletAddress') + pool_address: str = Field(..., alias='poolAddress') + base_token_amount: Decimal = Field(..., alias='baseTokenAmount') + quote_token_amount: Decimal = Field(..., alias='quoteTokenAmount') + slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct') + + +class AmmAddLiquidityResponseData(BaseModel): + fee: Decimal + pool_address: str | None = Field(None, alias='poolAddress', description='Pool this operation acted on') + position_address: str | None = Field(None, alias='positionAddress', description='Position the liquidity went into. Absent on fungible-LP AMMs, which hold liquidity as LP tokens rather than a position account.') + position_rent: Decimal | None = Field(None, alias='positionRent', description='Native token locked as rent when this call opened the position. Absent when adding to a position that already existed, and on fungible-LP AMMs.') + base_token_amount_added: Decimal = Field(..., alias='baseTokenAmountAdded') + quote_token_amount_added: Decimal = Field(..., alias='quoteTokenAmountAdded') + + +class AmmOpenPositionResponseData(BaseModel): + fee: Decimal + pool_address: str | None = Field(None, alias='poolAddress', description='Pool this operation acted on') + position_address: str | None = Field(None, alias='positionAddress', description='Address of the newly opened position. Absent on fungible-LP AMMs, which hold liquidity as LP tokens rather than a position account.') + position_rent: Decimal = Field(..., alias='positionRent', description='Native token locked as rent for the position account, refunded on close. 0 on fungible-LP AMMs, which lock no rent.') + base_token_amount_added: Decimal = Field(..., alias='baseTokenAmountAdded') + quote_token_amount_added: Decimal = Field(..., alias='quoteTokenAmountAdded') + + +class AmmClosePositionResponseData(BaseModel): + fee: Decimal + pool_address: str | None = Field(None, alias='poolAddress', description='Pool this operation acted on') + position_address: str | None = Field(None, alias='positionAddress', description='Position this operation acted on') + position_rent_refunded: Decimal = Field(..., alias='positionRentRefunded', description='Native token rent returned when the position account closed. 0 on fungible-LP AMMs, which have no position account to close.') + base_token_amount_removed: Decimal = Field(..., alias='baseTokenAmountRemoved') + quote_token_amount_removed: Decimal = Field(..., alias='quoteTokenAmountRemoved') + + +class QuoteLiquidityRequest(BaseModel): + network: str | None = None + pool_address: str = Field(..., alias='poolAddress') + base_token_amount: Decimal = Field(..., alias='baseTokenAmount') + quote_token_amount: Decimal = Field(..., alias='quoteTokenAmount') + slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct') + + +class QuoteLiquidityResponse(BaseModel): + pool_address: str | None = Field(None, alias='poolAddress', description='Pool the quote was computed against') + base_limited: bool = Field(..., alias='baseLimited') + base_token_amount: Decimal = Field(..., alias='baseTokenAmount') + quote_token_amount: Decimal = Field(..., alias='quoteTokenAmount') + base_token_amount_max: Decimal = Field(..., alias='baseTokenAmountMax') + quote_token_amount_max: Decimal = Field(..., alias='quoteTokenAmountMax') + + +class AmmRemoveLiquidityRequest(BaseModel): + network: str | None = None + wallet_address: str | None = Field(None, alias='walletAddress') + pool_address: str = Field(..., alias='poolAddress') + percentage_to_remove: condecimal(ge=Decimal('0'), le=Decimal('100')) = Field(..., alias='percentageToRemove') + + +class AmmRemoveLiquidityResponseData(BaseModel): + fee: Decimal + pool_address: str | None = Field(None, alias='poolAddress', description='Pool this operation acted on') + position_address: str | None = Field(None, alias='positionAddress', description='Position this operation acted on') + base_token_amount_removed: Decimal = Field(..., alias='baseTokenAmountRemoved') + quote_token_amount_removed: Decimal = Field(..., alias='quoteTokenAmountRemoved') + + +class CreatePoolRequest(BaseModel): + network: str | None = None + wallet_address: str | None = Field(None, alias='walletAddress') + base_token: str = Field(..., alias='baseToken', description='Base token symbol or address (becomes the pool base)') + quote_token: str = Field(..., alias='quoteToken', description='Quote token symbol or address (becomes the pool quote)') + base_token_amount: Decimal = Field(..., alias='baseTokenAmount', description='Amount of base token to seed the pool with') + quote_token_amount: Decimal | None = Field(None, alias='quoteTokenAmount', description='Amount of quote token to seed with. If provided, the base:quote ratio sets the initial price. If omitted (and no initialPrice), the price is fetched from the market.') + initial_price: Decimal | None = Field(None, alias='initialPrice', description='Initial price as quote per base. Overrides quoteTokenAmount. If both are omitted, the current market price is fetched from the unified swap router so the pool opens on-market.') + + +class CreatePoolResponseData(BaseModel): + fee: Decimal + base_token_amount_added: Decimal = Field(..., alias='baseTokenAmountAdded') + quote_token_amount_added: Decimal = Field(..., alias='quoteTokenAmountAdded') + + +class PositionDetail(BaseModel): + position_address: str = Field(..., alias='positionAddress', description='Address of the individual position (NFT position account)') + lp_token_amount: Decimal = Field(..., alias='lpTokenAmount', description='Liquidity held by this position (LP units)') + base_token_amount: Decimal = Field(..., alias='baseTokenAmount') + quote_token_amount: Decimal = Field(..., alias='quoteTokenAmount') + + +class AmmPositionInfo(BaseModel): + pool_address: str = Field(..., alias='poolAddress') + wallet_address: str = Field(..., alias='walletAddress') + base_token_address: str = Field(..., alias='baseTokenAddress') + quote_token_address: str = Field(..., alias='quoteTokenAddress') + lp_token_amount: Decimal = Field(..., alias='lpTokenAmount') + base_token_amount: Decimal = Field(..., alias='baseTokenAmount') + quote_token_amount: Decimal = Field(..., alias='quoteTokenAmount') + price: Decimal + positions: list[PositionDetail] | None = None + + +class AmmGetPositionInfoRequest(BaseModel): + network: str | None = None + pool_address: str = Field(..., alias='poolAddress') + wallet_address: str | None = Field(None, alias='walletAddress') + + +class Side(StrEnum): + buy = 'BUY' + sell = 'SELL' + + +class AmmQuoteSwapRequest(BaseModel): + network: str | None = None + pool_address: str | None = Field(None, alias='poolAddress', description='Pool address (optional - can be looked up from baseToken and quoteToken)') + base_token: str = Field(..., alias='baseToken', description='Token to determine swap direction') + quote_token: str | None = Field(None, alias='quoteToken', description='The other token in the pair (optional - required if poolAddress not provided)') + amount: Decimal + side: Side = Field(..., description='Trade direction') + slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct') + + +class AmmQuoteSwapResponse(BaseModel): + pool_address: str = Field(..., alias='poolAddress') + token_in: str = Field(..., alias='tokenIn') + token_out: str = Field(..., alias='tokenOut') + amount_in: Decimal = Field(..., alias='amountIn') + amount_out: Decimal = Field(..., alias='amountOut') + price: Decimal + slippage_pct: Decimal | None = Field(None, alias='slippagePct') + min_amount_out: Decimal = Field(..., alias='minAmountOut') + max_amount_in: Decimal = Field(..., alias='maxAmountIn') + price_impact_pct: Decimal = Field(..., alias='priceImpactPct') + + +class AmmExecuteSwapRequest(BaseModel): + wallet_address: str | None = Field(None, alias='walletAddress') + network: str | None = None + pool_address: str | None = Field(None, alias='poolAddress', description='Pool address (optional - can be looked up from baseToken and quoteToken)') + base_token: str = Field(..., alias='baseToken') + quote_token: str | None = Field(None, alias='quoteToken', description='The other token in the pair (optional - required if poolAddress not provided)') + amount: Decimal + side: Side + slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct') + + +class AmmExecuteSwapResponseData(BaseModel): + token_in: str = Field(..., alias='tokenIn') + token_out: str = Field(..., alias='tokenOut') + amount_in: Decimal = Field(..., alias='amountIn') + amount_out: Decimal = Field(..., alias='amountOut') + fee: Decimal + base_token_balance_change: Decimal = Field(..., alias='baseTokenBalanceChange') + quote_token_balance_change: Decimal = Field(..., alias='quoteTokenBalanceChange') + slippage_pct: Decimal | None = Field(None, alias='slippagePct', description='Slippage tolerance percentage actually applied to the swap') + + +class EstimateGasRequest(BaseModel): + network: str | None = None + + +class EstimateGasResponse(BaseModel): + fee_per_compute_unit: Decimal = Field(..., alias='feePerComputeUnit') + denomination: str + compute_units: float = Field(..., alias='computeUnits') + fee_asset: str = Field(..., alias='feeAsset') + fee: Decimal + timestamp: float + gas_type: str | None = Field(None, alias='gasType') + max_fee_per_gas: Decimal | None = Field(None, alias='maxFeePerGas') + max_priority_fee_per_gas: Decimal | None = Field(None, alias='maxPriorityFeePerGas') + priority_fee_level: str | None = Field(None, alias='priorityFeeLevel') + priority_fee_per_cu_estimate: Decimal | None = Field(None, alias='priorityFeePerCUEstimate') + + +class BalanceRequest(BaseModel): + network: str | None = None + address: str | None = None + tokens: list[str] | None = Field(None, description='a list of token symbols or addresses') + fetch_all: bool | None = Field(None, alias='fetchAll', description='fetch all tokens in wallet, not just those in token list (default: false)') + + +class BalanceResponse(BaseModel): + balances: dict[str, float] + + +class TokensRequest(BaseModel): + network: str | None = None + token_symbols: str | list[str] | None = Field(None, alias='tokenSymbols') + + +class Token(BaseModel): + symbol: str + address: str + decimals: float + name: str + + +class TokensResponse(BaseModel): + tokens: list[Token] + + +class PollRequest(BaseModel): + network: str | None = None + signature: str = Field(..., description='Transaction signature/hash') + + +class PollResponse(BaseModel): + current_block: float = Field(..., alias='currentBlock') + signature: str + tx_block: float | None = Field(..., alias='txBlock') + tx_status: float = Field(..., alias='txStatus', description='Transaction status: 1 = confirmed, 0 = pending, -1 = failed, -2 = not found (unknown to the chain: never received or dropped; on Solana this is terminal once the transaction blockhash expires)') + fee: float | None + error: str | None + tx_data: dict[str, Any] | None = Field(..., alias='txData') + + +class StatusRequest(BaseModel): + network: str | None = None + + +class StatusResponse(BaseModel): + chain: str + network: str + rpc_url: str = Field(..., alias='rpcUrl') + rpc_provider: str = Field(..., alias='rpcProvider') + current_block_number: float = Field(..., alias='currentBlockNumber') + native_currency: str = Field(..., alias='nativeCurrency') + swap_provider: str = Field(..., alias='swapProvider') + + +class ChainQuoteSwapResponse(BaseModel): + token_in: str = Field(..., alias='tokenIn', description='Address of the token being swapped from') + token_out: str = Field(..., alias='tokenOut', description='Address of the token being swapped to') + amount_in: Decimal = Field(..., alias='amountIn', description='Amount of tokenIn to be swapped') + amount_out: Decimal = Field(..., alias='amountOut', description='Expected amount of tokenOut to receive') + price: Decimal = Field(..., description='Exchange rate between tokenIn and tokenOut') + price_impact_pct: Decimal = Field(..., alias='priceImpactPct', description='Estimated price impact percentage (0-100)') + min_amount_out: Decimal = Field(..., alias='minAmountOut', description='Minimum amount of tokenOut that will be accepted') + max_amount_in: Decimal = Field(..., alias='maxAmountIn', description='Maximum amount of tokenIn that will be spent') + pool_address: str | None = Field(None, alias='poolAddress', description='Pool address for AMM/CLMM swaps') + route_path: str | None = Field(None, alias='routePath', description='Route path for router-based swaps') + slippage_pct: Decimal | None = Field(None, alias='slippagePct', description='Slippage tolerance percentage') + + +class ChainExecuteSwapResponseData(BaseModel): + token_in: str = Field(..., alias='tokenIn', description='Address of the token swapped from') + token_out: str = Field(..., alias='tokenOut', description='Address of the token swapped to') + amount_in: Decimal = Field(..., alias='amountIn', description='Actual amount of tokenIn swapped') + amount_out: Decimal = Field(..., alias='amountOut', description='Actual amount of tokenOut received') + fee: Decimal = Field(..., description='Transaction fee paid') + base_token_balance_change: Decimal = Field(..., alias='baseTokenBalanceChange', description='Change in base token balance (negative for decrease)') + quote_token_balance_change: Decimal = Field(..., alias='quoteTokenBalanceChange', description='Change in quote token balance (negative for decrease)') + slippage_pct: Decimal | None = Field(None, alias='slippagePct', description='Slippage tolerance percentage actually applied to the swap') + pool_address: str | None = Field(None, alias='poolAddress', description='Pool the swap executed against. Set by the pool-scoped routes (/trading/clmm, /trading/amm), which resolve exactly one pool; a router picks its own path across pools and leaves this unset. Without it a settled fill cannot be reconciled to a venue without refetching the transaction.') + + +class WrapRequest(BaseModel): + network: str | None = None + address: str = Field(..., description='Wallet address holding the native token') + amount: str = Field(..., description='Amount of the native token to wrap, in whole units (not lamports/wei)', examples=['1.0']) + + +class UnwrapRequest(BaseModel): + network: str | None = None + address: str = Field(..., description='Wallet address holding the wrapped token') + amount: str | None = Field(None, description='Amount of the wrapped token to unwrap, in whole units. Solana unwraps the full balance when omitted; EVM chains require it.', examples=['1.0']) + + +class ChainWrapResponseData(BaseModel): + nonce: float | None = Field(None, description='EVM transaction nonce; absent on non-EVM chains') + fee: str + amount: str + wrapped_address: str = Field(..., alias='wrappedAddress') + native_token: str = Field(..., alias='nativeToken') + wrapped_token: str = Field(..., alias='wrappedToken') + + +class RouterQuoteSwapResponse(BaseModel): + token_in: str = Field(..., alias='tokenIn', description='Address of the token being swapped from') + token_out: str = Field(..., alias='tokenOut', description='Address of the token being swapped to') + amount_in: Decimal = Field(..., alias='amountIn', description='Amount of tokenIn to be swapped') + amount_out: Decimal = Field(..., alias='amountOut', description='Expected amount of tokenOut to receive') + price: Decimal = Field(..., description='Exchange rate between tokenIn and tokenOut') + price_impact_pct: Decimal = Field(..., alias='priceImpactPct', description='Estimated price impact percentage (0-100)') + min_amount_out: Decimal = Field(..., alias='minAmountOut', description='Minimum amount of tokenOut that will be accepted') + max_amount_in: Decimal = Field(..., alias='maxAmountIn', description='Maximum amount of tokenIn that will be spent') + pool_address: str | None = Field(None, alias='poolAddress', description='Pool address for AMM/CLMM swaps') + route_path: str | None = Field(None, alias='routePath', description='Route path for router-based swaps') + slippage_pct: Decimal | None = Field(None, alias='slippagePct', description='Slippage tolerance percentage') + quote_id: str = Field(..., alias='quoteId', description='Identifier to pass to /trading/router/execute-quote') + approximation: bool | None = Field(None, description='True when a BUY was approximated via a sell-leg ExactIn quote because the router has no ExactOut route; amountOut is an estimate rather than exact') + + +class FetchPoolsRequest(BaseModel): + network: str | None = Field(None, description='Network to use') + limit: confloat(ge=1.0, le=100.0) | None = Field(50, description='Maximum number of pools to return') + query: str | None = Field(None, description='Search query to match pools by name, tokens, or address') + sort_by: str | None = Field(None, alias='sortBy', description='Sort by field (connector-specific)') + + +class PoolListItem(BaseModel): + address: str = Field(..., description='Pool address') + name: str = Field(..., description='Pool name (e.g., SOL-USDC)') + base_token_address: str = Field(..., alias='baseTokenAddress', description='Base token address') + base_token_symbol: str = Field(..., alias='baseTokenSymbol', description='Base token symbol') + quote_token_address: str = Field(..., alias='quoteTokenAddress', description='Quote token address') + quote_token_symbol: str = Field(..., alias='quoteTokenSymbol', description='Quote token symbol') + bin_step: float = Field(..., alias='binStep', description='Bin step / tick spacing') + base_fee: Decimal = Field(..., alias='baseFee', description='Base fee percentage') + price: Decimal = Field(..., description='Current price') + tvl: Decimal = Field(..., description='Total value locked in USD') + apr: Decimal | None = Field(None, description='Annual percentage rate') + apy: Decimal | None = Field(None, description='Annual percentage yield') + volume24h: Decimal | None = Field(None, description='24-hour trading volume') + fees24h: Decimal | None = Field(None, description='24-hour fees collected') + + +class FetchPoolsResponse(BaseModel): + pools: list[PoolListItem] + total: float = Field(..., description='Total number of matching pools') + page: float = Field(..., description='Current page number') + page_size: float = Field(..., alias='pageSize', description='Number of pools per page') + + +class GetPositionsOwnedRequest(BaseModel): + network: str | None = None + wallet_address: str = Field(..., alias='walletAddress') + + +class BinLiquidity(BaseModel): + bin_id: float = Field(..., alias='binId') + price: Decimal + base_token_amount: Decimal = Field(..., alias='baseTokenAmount') + quote_token_amount: Decimal = Field(..., alias='quoteTokenAmount') + + +class PoolInfo(BaseModel): + address: str + base_token_address: str = Field(..., alias='baseTokenAddress') + quote_token_address: str = Field(..., alias='quoteTokenAddress') + bin_step: float | None = Field(None, alias='binStep') + fee_pct: Decimal = Field(..., alias='feePct') + price: Decimal + base_token_amount: Decimal = Field(..., alias='baseTokenAmount') + quote_token_amount: Decimal = Field(..., alias='quoteTokenAmount') + active_bin_id: float = Field(..., alias='activeBinId') + bins: list[BinLiquidity] | None = None + + +class MeteoraPoolInfo(BaseModel): + address: str + base_token_address: str = Field(..., alias='baseTokenAddress') + quote_token_address: str = Field(..., alias='quoteTokenAddress') + bin_step: float | None = Field(None, alias='binStep') + fee_pct: Decimal = Field(..., alias='feePct') + price: Decimal + base_token_amount: Decimal = Field(..., alias='baseTokenAmount') + quote_token_amount: Decimal = Field(..., alias='quoteTokenAmount') + active_bin_id: float = Field(..., alias='activeBinId') + bins: list[BinLiquidity] | None = None + dynamic_fee_pct: float = Field(..., alias='dynamicFeePct') + min_bin_id: float = Field(..., alias='minBinId') + max_bin_id: float = Field(..., alias='maxBinId') + + +class GetPoolInfoRequest(BaseModel): + network: str | None = None + pool_address: str = Field(..., alias='poolAddress') + bin_count: conint(ge=0, le=401) | None = Field(0, alias='binCount', description='If > 0, include a `bins` array in the response (per-tickSpacing token amounts around the active tick, mirroring Meteora pool-info.bins[]). Default 0 = skip the bin fetch.') + + +class PositionInfo(BaseModel): + address: str + pool_address: str = Field(..., alias='poolAddress') + base_token_address: str = Field(..., alias='baseTokenAddress') + quote_token_address: str = Field(..., alias='quoteTokenAddress') + base_token_amount: Decimal = Field(..., alias='baseTokenAmount') + quote_token_amount: Decimal = Field(..., alias='quoteTokenAmount') + base_fee_amount: Decimal = Field(..., alias='baseFeeAmount') + quote_fee_amount: Decimal = Field(..., alias='quoteFeeAmount') + lower_bin_id: float = Field(..., alias='lowerBinId') + upper_bin_id: float = Field(..., alias='upperBinId') + lower_price: Decimal = Field(..., alias='lowerPrice') + upper_price: Decimal = Field(..., alias='upperPrice') + price: Decimal + + +class GetPositionInfoRequest(BaseModel): + network: str | None = None + position_address: str = Field(..., alias='positionAddress') + wallet_address: str | None = Field(None, alias='walletAddress') + + +class OpenPositionRequest(BaseModel): + network: str | None = None + wallet_address: str | None = Field(None, alias='walletAddress') + lower_price: Decimal = Field(..., alias='lowerPrice') + upper_price: Decimal = Field(..., alias='upperPrice') + pool_address: str = Field(..., alias='poolAddress') + base_token_amount: Decimal | None = Field(None, alias='baseTokenAmount') + quote_token_amount: Decimal | None = Field(None, alias='quoteTokenAmount') + slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct') + + +class OpenPositionResponseData(BaseModel): + fee: Decimal + pool_address: str | None = Field(None, alias='poolAddress', description='Pool this operation acted on') + position_address: str = Field(..., alias='positionAddress') + position_rent: Decimal = Field(..., alias='positionRent') + base_token_amount_added: Decimal = Field(..., alias='baseTokenAmountAdded') + quote_token_amount_added: Decimal = Field(..., alias='quoteTokenAmountAdded') + + +class AddLiquidityRequest(BaseModel): + network: str | None = None + wallet_address: str | None = Field(None, alias='walletAddress') + position_address: str = Field(..., alias='positionAddress') + base_token_amount: Decimal = Field(..., alias='baseTokenAmount') + quote_token_amount: Decimal = Field(..., alias='quoteTokenAmount') + slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct') + + +class AddLiquidityResponseData(BaseModel): + fee: Decimal + pool_address: str | None = Field(None, alias='poolAddress', description='Pool this operation acted on') + position_address: str | None = Field(None, alias='positionAddress', description='Position this operation acted on') + base_token_amount_added: Decimal = Field(..., alias='baseTokenAmountAdded') + quote_token_amount_added: Decimal = Field(..., alias='quoteTokenAmountAdded') + + +class RemoveLiquidityRequest(BaseModel): + network: str | None = None + wallet_address: str | None = Field(None, alias='walletAddress') + position_address: str = Field(..., alias='positionAddress') + percentage_to_remove: condecimal(ge=Decimal('0'), le=Decimal('100')) = Field(..., alias='percentageToRemove') + + +class RemoveLiquidityResponseData(BaseModel): + fee: Decimal + pool_address: str | None = Field(None, alias='poolAddress', description='Pool this operation acted on') + position_address: str | None = Field(None, alias='positionAddress', description='Position this operation acted on') + base_token_amount_removed: Decimal = Field(..., alias='baseTokenAmountRemoved') + quote_token_amount_removed: Decimal = Field(..., alias='quoteTokenAmountRemoved') + + +class CollectFeesRequest(BaseModel): + network: str | None = None + wallet_address: str | None = Field(None, alias='walletAddress') + position_address: str = Field(..., alias='positionAddress') + + +class CollectFeesResponseData(BaseModel): + fee: Decimal + pool_address: str | None = Field(None, alias='poolAddress', description='Pool this operation acted on') + position_address: str | None = Field(None, alias='positionAddress', description='Position this operation acted on') + base_fee_amount_collected: Decimal = Field(..., alias='baseFeeAmountCollected') + quote_fee_amount_collected: Decimal = Field(..., alias='quoteFeeAmountCollected') + + +class ClosePositionRequest(BaseModel): + network: str | None = None + wallet_address: str | None = Field(None, alias='walletAddress') + position_address: str = Field(..., alias='positionAddress') + + +class ClosePositionResponseData(BaseModel): + fee: Decimal + pool_address: str | None = Field(None, alias='poolAddress', description='Pool this operation acted on') + position_address: str | None = Field(None, alias='positionAddress', description='Position this operation acted on') + position_rent_refunded: Decimal = Field(..., alias='positionRentRefunded') + base_token_amount_removed: Decimal = Field(..., alias='baseTokenAmountRemoved') + quote_token_amount_removed: Decimal = Field(..., alias='quoteTokenAmountRemoved') + base_fee_amount_collected: Decimal = Field(..., alias='baseFeeAmountCollected') + quote_fee_amount_collected: Decimal = Field(..., alias='quoteFeeAmountCollected') + + +class ClmmCreatePoolRequest(BaseModel): + network: str | None = None + wallet_address: str | None = Field(None, alias='walletAddress') + base_token: str = Field(..., alias='baseToken') + quote_token: str = Field(..., alias='quoteToken') + initial_price: Decimal | None = Field(None, alias='initialPrice', description='Initial pool price as quote per base. If omitted, the current market price is fetched from the unified swap router so the pool opens on-market.') + bin_step: float | None = Field(None, alias='binStep', description='Bin/tick granularity: Meteora DLMM bin step (bps); Orca Whirlpool tick spacing.') + fee_bps: float | None = Field(None, alias='feeBps', description='Base fee in basis points: Meteora DLMM base fee; Uniswap/PancakeSwap V3 fee tier (1, 5, 30 or 100 bps; PancakeSwap also 25).') + amm_config_index: float | None = Field(None, alias='ammConfigIndex', description='Fee-config index for the Raydium CLMM family: Raydium API config list index; pancakeswap-sol amm_config PDA index. Default 0.') + + +class ClmmCreatePoolResponseData(BaseModel): + fee: Decimal + + +class QuotePositionRequest(BaseModel): + network: str | None = None + lower_price: Decimal = Field(..., alias='lowerPrice') + upper_price: Decimal = Field(..., alias='upperPrice') + pool_address: str = Field(..., alias='poolAddress') + base_token_amount: Decimal | None = Field(None, alias='baseTokenAmount') + quote_token_amount: Decimal | None = Field(None, alias='quoteTokenAmount') + slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct') + + +class QuotePositionResponse(BaseModel): + pool_address: str | None = Field(None, alias='poolAddress', description='Pool the quote was computed against') + base_limited: bool = Field(..., alias='baseLimited') + base_token_amount: Decimal = Field(..., alias='baseTokenAmount') + quote_token_amount: Decimal = Field(..., alias='quoteTokenAmount') + base_token_amount_max: Decimal = Field(..., alias='baseTokenAmountMax') + quote_token_amount_max: Decimal = Field(..., alias='quoteTokenAmountMax') + liquidity: Any | None = None + + +class ClmmQuoteSwapRequest(BaseModel): + network: str | None = None + pool_address: str | None = Field(None, alias='poolAddress', description='Pool address (optional - can be looked up from baseToken and quoteToken)') + base_token: str = Field(..., alias='baseToken', description='Token to determine swap direction') + quote_token: str | None = Field(None, alias='quoteToken', description='The other token in the pair (optional - required if poolAddress not provided)') + amount: Decimal + side: Side = Field(..., description='Trade direction') + slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct') + + +class ClmmQuoteSwapResponse(BaseModel): + pool_address: str = Field(..., alias='poolAddress') + token_in: str = Field(..., alias='tokenIn') + token_out: str = Field(..., alias='tokenOut') + amount_in: Decimal = Field(..., alias='amountIn') + amount_out: Decimal = Field(..., alias='amountOut') + price: Decimal + slippage_pct: Decimal | None = Field(None, alias='slippagePct') + min_amount_out: Decimal = Field(..., alias='minAmountOut') + max_amount_in: Decimal = Field(..., alias='maxAmountIn') + price_impact_pct: Decimal = Field(..., alias='priceImpactPct') + + +class ClmmExecuteSwapRequest(BaseModel): + wallet_address: str | None = Field(None, alias='walletAddress') + network: str | None = None + pool_address: str | None = Field(None, alias='poolAddress', description='Pool address (optional - can be looked up from baseToken and quoteToken)') + base_token: str = Field(..., alias='baseToken') + quote_token: str | None = Field(None, alias='quoteToken', description='The other token in the pair (optional - required if poolAddress not provided)') + amount: Decimal + side: Side + slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct') + + +class ClmmExecuteSwapResponseData(BaseModel): + token_in: str = Field(..., alias='tokenIn') + token_out: str = Field(..., alias='tokenOut') + amount_in: Decimal = Field(..., alias='amountIn') + amount_out: Decimal = Field(..., alias='amountOut') + fee: Decimal + base_token_balance_change: Decimal = Field(..., alias='baseTokenBalanceChange') + quote_token_balance_change: Decimal = Field(..., alias='quoteTokenBalanceChange') + slippage_pct: Decimal | None = Field(None, alias='slippagePct', description='Slippage tolerance percentage actually applied to the swap') + + +class QuoteSwapRequest(BaseModel): + network: str | None = Field(None, description='The blockchain network to use') + base_token: str = Field(..., alias='baseToken', description='Token to determine swap direction') + quote_token: str = Field(..., alias='quoteToken', description='The other token in the pair') + amount: Decimal = Field(..., description='Amount of base token to trade') + side: Side = Field(..., description='Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token') + slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct', description='Maximum acceptable slippage percentage') + approximate_if_no_exact_out: bool | None = Field(True, alias='approximateIfNoExactOut', description='For BUY orders on routers without ExactOut support: approximate the required input via a sell-leg quote and return an ExactIn quote flagged as an approximation. If false, such BUY requests fail with a clear error.') + + +class QuoteSwapResponse(BaseModel): + quote_id: str = Field(..., alias='quoteId', description='Unique identifier for this quote') + token_in: str = Field(..., alias='tokenIn', description='Address of the token being swapped from') + token_out: str = Field(..., alias='tokenOut', description='Address of the token being swapped to') + amount_in: Decimal = Field(..., alias='amountIn', description='Amount of tokenIn to be swapped') + amount_out: Decimal = Field(..., alias='amountOut', description='Expected amount of tokenOut to receive') + price: Decimal = Field(..., description='Exchange rate between tokenIn and tokenOut') + price_impact_pct: Decimal = Field(..., alias='priceImpactPct', description='Estimated price impact percentage (0-100)') + min_amount_out: Decimal = Field(..., alias='minAmountOut', description='Minimum amount of tokenOut that will be accepted') + max_amount_in: Decimal = Field(..., alias='maxAmountIn', description='Maximum amount of tokenIn that will be spent') + approximation: bool | None = Field(None, description='True when a BUY was approximated via a sell-leg ExactIn quote because the router does not support ExactOut; amountOut is an estimate rather than exact') + + +class ExecuteQuoteRequest(BaseModel): + wallet_address: str | None = Field(None, alias='walletAddress', description='Wallet address that will execute the swap') + network: str | None = Field(None, description='The blockchain network to use') + quote_id: str = Field(..., alias='quoteId', description='ID of the quote to execute') + + +class ExecuteSwapRequest(BaseModel): + wallet_address: str | None = Field(None, alias='walletAddress', description='Wallet address that will execute the swap') + network: str | None = Field(None, description='The blockchain network to use') + base_token: str = Field(..., alias='baseToken', description='Token to determine swap direction') + quote_token: str = Field(..., alias='quoteToken', description='The other token in the pair') + amount: Decimal = Field(..., description='Amount of base token to trade') + side: Side = Field(..., description='Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token') + slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct', description='Maximum acceptable slippage percentage') + approximate_if_no_exact_out: bool | None = Field(True, alias='approximateIfNoExactOut', description='For BUY orders on routers without ExactOut support: approximate the required input via a sell-leg quote and execute an ExactIn swap. If false, such BUY requests fail with a clear error.') + + +class SwapExecuteResponseData(BaseModel): + token_in: str = Field(..., alias='tokenIn', description='Address of the token swapped from') + token_out: str = Field(..., alias='tokenOut', description='Address of the token swapped to') + amount_in: Decimal = Field(..., alias='amountIn', description='Actual amount of tokenIn swapped') + amount_out: Decimal = Field(..., alias='amountOut', description='Actual amount of tokenOut received') + fee: Decimal = Field(..., description='Transaction fee paid') + base_token_balance_change: Decimal = Field(..., alias='baseTokenBalanceChange', description='Change in base token balance (negative for decrease)') + quote_token_balance_change: Decimal = Field(..., alias='quoteTokenBalanceChange', description='Change in quote token balance (negative for decrease)') + slippage_pct: Decimal | None = Field(None, alias='slippagePct', description='Slippage tolerance percentage actually applied to the swap') + + +class AmmAddLiquidityResponse(BaseModel): + signature: str + status: float = Field(..., description='TransactionStatus enum value') + data: AmmAddLiquidityResponseData | None = None + + +class AmmOpenPositionResponse(BaseModel): + signature: str + status: float = Field(..., description='TransactionStatus enum value') + data: AmmOpenPositionResponseData | None = None + + +class AmmClosePositionResponse(BaseModel): + signature: str + status: float = Field(..., description='TransactionStatus enum value') + data: AmmClosePositionResponseData | None = None + + +class AmmRemoveLiquidityResponse(BaseModel): + signature: str + status: float = Field(..., description='TransactionStatus enum value') + data: AmmRemoveLiquidityResponseData | None = None + + +class CreatePoolResponse(BaseModel): + signature: str + status: float = Field(..., description='TransactionStatus enum value') + pool_address: str = Field(..., alias='poolAddress', description='Address of the newly created pool') + price: Decimal | None = Field(None, description='Initial price the pool was seeded at (quote per base)') + data: CreatePoolResponseData | None = None + + +class AmmExecuteSwapResponse(BaseModel): + signature: str + status: float = Field(..., description='TransactionStatus enum value') + data: AmmExecuteSwapResponseData | None = None + + +class ChainExecuteSwapResponse(BaseModel): + signature: str = Field(..., description='Transaction signature/hash') + status: float = Field(..., description='Transaction status: 0 = PENDING, 1 = CONFIRMED, -1 = FAILED') + data: ChainExecuteSwapResponseData | None = None + + +class ChainWrapResponse(BaseModel): + signature: str + status: float = Field(..., description='TransactionStatus enum value') + data: ChainWrapResponseData | None = None + + +class OpenPositionResponse(BaseModel): + signature: str + status: float = Field(..., description='TransactionStatus enum value') + data: OpenPositionResponseData | None = None + + +class AddLiquidityResponse(BaseModel): + signature: str + status: float = Field(..., description='TransactionStatus enum value') + data: AddLiquidityResponseData | None = None + + +class RemoveLiquidityResponse(BaseModel): + signature: str + status: float = Field(..., description='TransactionStatus enum value') + data: RemoveLiquidityResponseData | None = None + + +class CollectFeesResponse(BaseModel): + signature: str + status: float = Field(..., description='TransactionStatus enum value') + data: CollectFeesResponseData | None = None + + +class ClosePositionResponse(BaseModel): + signature: str + status: float = Field(..., description='TransactionStatus enum value') + data: ClosePositionResponseData | None = None + + +class ClmmCreatePoolResponse(BaseModel): + signature: str + status: float = Field(..., description='TransactionStatus enum value') + pool_address: str = Field(..., alias='poolAddress', description='Address of the newly created pool') + price: Decimal | None = Field(None, description='Initial price the pool was initialized at (quote per base)') + data: ClmmCreatePoolResponseData | None = None + + +class ClmmExecuteSwapResponse(BaseModel): + signature: str + status: float = Field(..., description='TransactionStatus enum value') + data: ClmmExecuteSwapResponseData | None = None + + +class SwapExecuteResponse(BaseModel): + signature: str = Field(..., description='Transaction signature/hash') + status: float = Field(..., description='Transaction status: 0 = PENDING, 1 = CONFIRMED, -1 = FAILED') + data: SwapExecuteResponseData | None = None diff --git a/routers/gateway_clmm.py b/routers/gateway_clmm.py index 039b80c7..e50cae41 100644 --- a/routers/gateway_clmm.py +++ b/routers/gateway_clmm.py @@ -1393,7 +1393,10 @@ async def create_clmm_pool( initial_price=float(request.initial_price) if request.initial_price is not None else None, extra_params=request.extra_params, )) - return AMMCreatePoolResponse(**result) + # Gateway reports status as a number; every other write path maps it to the shared + # SUBMITTED/CONFIRMED/FAILED vocabulary. Splatting it raw made this route fail + # validation on every successful create, since the model declares status as a string. + return AMMCreatePoolResponse(**{**result, "status": get_transaction_status_from_response(result)}) except HTTPException: raise diff --git a/routers/gateway_swap.py b/routers/gateway_swap.py index 45f8315f..9e826563 100644 --- a/routers/gateway_swap.py +++ b/routers/gateway_swap.py @@ -53,7 +53,7 @@ async def get_swap_quote( """ try: validate_extra_params(request.extra_params, SWAP_EXTRA_PARAMS_SPEC, - request.connector, "the quote-swap routes") + request.connector, "/trading/{router,clmm,amm}/quote-swap") if not await accounts_service.gateway_client.ping(): raise HTTPException(status_code=503, detail="Gateway service is not available") @@ -130,7 +130,7 @@ async def execute_swap( """ try: validate_extra_params(request.extra_params, SWAP_EXTRA_PARAMS_SPEC, - request.connector, "the execute-swap routes") + request.connector, "/trading/{router,clmm,amm}/execute-swap") if not await accounts_service.gateway_client.ping(): raise HTTPException(status_code=503, detail="Gateway service is not available") diff --git a/test/test_gateway_models_match_spec.py b/test/test_gateway_models_match_spec.py new file mode 100644 index 00000000..f5f9ad19 --- /dev/null +++ b/test/test_gateway_models_match_spec.py @@ -0,0 +1,170 @@ +"""The Gateway field names this service depends on must exist in Gateway's OpenAPI spec. + +`test_gateway_paths_exist` pins the routes; this pins what travels over them. A field +rename in Gateway is invisible here until a response arrives with the old key missing — +and because every reader is a `.get()`, nothing raises. It silently reads `None`, which +is how a renamed fee field becomes a recorded fee of zero rather than an error. + +Three checks, each guarding a different half of the wire: + +- The vendored `models/gateway_generated.py` is a faithful mirror of the spec's schemas, + so the pins below compare against Gateway's actual shapes rather than a stale memory + of them. +- Every field the passthrough models declare exists in the Gateway schema they are + built from. Those models are constructed by splatting a Gateway response + (`Model(**result)`), so a field Gateway does not send is dead on arrival. +- Every camelCase key `services/gateway_client.py` writes or reads appears in the spec. + This is the only check that reaches GET query parameters, which live in the spec as + `parameters` and so are absent from `components.schemas` entirely — no generated + model covers them. + +Refresh the spec and models together when adopting a Gateway change: + + cd ../gateway && pnpm generate:openapi + cp ../gateway/openapi.json gateway-openapi.json + make gateway-models +""" +import json +import re +import subprocess +import sys +from pathlib import Path + +import pytest + +_REPO_ROOT = Path(__file__).resolve().parent.parent +SPEC_PATH = _REPO_ROOT / "gateway-openapi.json" +GENERATED_PATH = _REPO_ROOT / "models" / "gateway_generated.py" +CLIENT_PATH = _REPO_ROOT / "services" / "gateway_client.py" + +# Models built by splatting a Gateway response, mapped to the Gateway schema that +# response conforms to. A model appears once per schema it is fed from: the create-pool +# and write-transaction responses are shared across the AMM and CLMM surfaces, and each +# of those schemas must independently satisfy it. +PASSTHROUGH_MODELS = [ + ("CLMMPoolInfoResponse", "PoolInfo"), + ("CLMMPoolBin", "BinLiquidity"), + ("CLMMQuotePositionResponse", "QuotePositionResponse"), + ("AMMPoolInfoResponse", "AmmPoolInfo"), + ("AMMPositionInfoResponse", "AmmPositionInfo"), + ("AMMPositionDetail", "PositionDetail"), + ("AMMQuoteLiquidityResponse", "QuoteLiquidityResponse"), + ("AMMCreatePoolResponse", "CreatePoolResponse"), + ("AMMCreatePoolResponse", "ClmmCreatePoolResponse"), + ("AMMTransactionResponse", "AmmAddLiquidityResponse"), + ("AMMTransactionResponse", "AmmRemoveLiquidityResponse"), + ("AMMTransactionResponse", "AmmOpenPositionResponse"), + ("AMMTransactionResponse", "AmmClosePositionResponse"), +] + +# camelCase strings in the client that address Gateway's YAML config tree rather than an +# HTTP field — `/config` returns the config as-is, so these names are namespaced keys +# ("apiKeys.helius", "solana.defaultWallet") and no route schema declares them. Listed +# individually so a genuinely renamed wire field cannot hide behind the exemption. +CONFIG_TREE_KEYS = {"apiKeys", "defaultNetwork", "defaultWallet"} + + +def _spec() -> dict: + return json.loads(SPEC_PATH.read_text()) + + +def _schema_property_names(spec: dict) -> set: + """Every property and query-parameter name anywhere in the spec.""" + names = set() + + def walk(node): + if isinstance(node, list): + for item in node: + walk(item) + return + if not isinstance(node, dict): + return + for key, value in node.items(): + if key == "properties" and isinstance(value, dict): + names.update(value) + if key == "parameters" and isinstance(value, list): + names.update(p["name"] for p in value if isinstance(p, dict) and "name" in p) + walk(value) + + walk(spec) + return names + + +def test_the_generated_models_match_the_vendored_spec(): + """Regenerating must reproduce the committed file exactly. + + The models are vendored so they can be imported without a build step and reviewed as + a diff. That only holds while the committed copy is what the spec produces — a + hand-edit, or a spec refreshed without rerunning the generator, and the pins below + start comparing against something Gateway never described. + """ + result = subprocess.run( + [ + sys.executable, "-m", "datamodel_code_generator", + "--input", str(SPEC_PATH), + "--input-file-type", "openapi", + "--openapi-scopes", "schemas", + "--output", "/dev/stdout", + "--output-model-type", "pydantic_v2.BaseModel", + "--snake-case-field", + "--target-python-version", "3.12", + "--disable-timestamp", + "--formatters", "black", + "--formatters", "isort", + "--custom-file-header", GENERATED_PATH.read_text().split("\n\n")[0], + ], + capture_output=True, text=True, + ) + assert result.returncode == 0, f"datamodel-codegen failed:\n{result.stderr}" + assert result.stdout == GENERATED_PATH.read_text(), ( + f"{GENERATED_PATH.name} is not what {SPEC_PATH.name} generates. " + "Run `make gateway-models` and commit the result." + ) + + +def _wire_names(model) -> set: + """Field names as they travel on the wire — the alias where one is set.""" + return {(field.alias or name) for name, field in model.model_fields.items()} + + +@pytest.mark.parametrize("model_name,schema_name", PASSTHROUGH_MODELS) +def test_passthrough_models_only_declare_fields_gateway_sends(model_name, schema_name): + from models import gateway_generated, gateway_trading + + declared = _wire_names(getattr(gateway_trading, model_name)) + sent = _wire_names(getattr(gateway_generated, schema_name)) + missing = sorted(declared - sent) + assert not missing, ( + f"{model_name} declares {missing}, which Gateway's {schema_name} does not send. " + f"Because {model_name} is built as {model_name}(**gateway_response), those fields " + "read as their defaults forever rather than raising. Follow the rename or drop them." + ) + # The reverse is deliberate, not a defect: Gateway sends fields this service has no + # use for, and Pydantic drops them. + + +def test_every_wire_key_the_client_uses_exists_in_the_spec(): + """Guards the request side, which the passthrough models above never touch. + + The client hand-writes camelCase keys into query params and JSON bodies. GET + parameters are the reason this cannot be replaced by a generated request model: + the spec carries them as `parameters`, not as a schema. + """ + used = set(re.findall(r'"([a-z][a-zA-Z0-9]*[A-Z][a-zA-Z0-9]*)"', CLIENT_PATH.read_text())) + unknown = sorted(used - _schema_property_names(_spec()) - CONFIG_TREE_KEYS) + assert not unknown, ( + "GatewayClient sends or reads keys Gateway's spec does not declare:\n " + + "\n ".join(unknown) + + f"\n\nSpec: {SPEC_PATH.name}. Either the client is stale and should follow the " + "rename, or the key addresses Gateway's config tree and belongs in CONFIG_TREE_KEYS." + ) + + +def test_the_checks_above_are_not_vacuous(): + """A truncated spec or a regex matching nothing would pass every check silently.""" + spec = _spec() + assert len(spec["components"]["schemas"]) > 50, "components.schemas looks truncated" + assert len(_schema_property_names(spec)) > 100, "Found almost no property names in the spec" + assert len(re.findall(r'"([a-z][a-zA-Z0-9]*[A-Z][a-zA-Z0-9]*)"', CLIENT_PATH.read_text())) > 50, ( + "Found almost no camelCase literals in the client — has the regex gone stale?" + ) From 6e6a051b72ec023b459899624ad368f093da4a26 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Wed, 19 Aug 2026 17:11:25 -0700 Subject: [PATCH 21/54] chore(gateway): adopt Gateway's request-body components (GW-9) Gateway now publishes 23 of 28 request bodies as named components, up from 4, so the generated models carry the shapes callers actually send: connector, chainNetwork and the connector-specific extras this client passes through extra_params. Every trading POST GatewayClient builds now has a model whose field set matches the keys it writes. Spec and models refreshed together; the contract checks pass unchanged. --- gateway-openapi.json | 2781 ++++++++++++++++++----------------- models/gateway_generated.py | 308 +++- 2 files changed, 1707 insertions(+), 1382 deletions(-) diff --git a/gateway-openapi.json b/gateway-openapi.json index 88d0c2e0..f674df0b 100644 --- a/gateway-openapi.json +++ b/gateway-openapi.json @@ -696,50 +696,6 @@ "priceImpactPct" ] }, - "AmmExecuteSwapRequest": { - "type": "object", - "properties": { - "walletAddress": { - "type": "string" - }, - "network": { - "type": "string" - }, - "poolAddress": { - "description": "Pool address (optional - can be looked up from baseToken and quoteToken)", - "type": "string" - }, - "baseToken": { - "type": "string" - }, - "quoteToken": { - "description": "The other token in the pair (optional - required if poolAddress not provided)", - "type": "string" - }, - "amount": { - "format": "decimal", - "type": "number" - }, - "side": { - "enum": [ - "BUY", - "SELL" - ], - "type": "string" - }, - "slippagePct": { - "format": "decimal", - "minimum": 0, - "maximum": 100, - "type": "number" - } - }, - "required": [ - "baseToken", - "amount", - "side" - ] - }, "AmmExecuteSwapResponse": { "type": "object", "properties": { @@ -808,7 +764,25 @@ "type": "object", "properties": { "network": { - "type": "string" + "description": "Network to use. Defaults to the chain's configured default network.", + "enum": [ + "devnet", + "mainnet-beta", + "arbitrum", + "avalanche", + "base", + "bsc", + "celo", + "mainnet", + "optimism", + "polygon", + "robinhoodchain-testnet", + "robinhoodchain", + "sepolia", + "unichain" + ], + "type": "string", + "example": "mainnet-beta" } } }, @@ -867,7 +841,25 @@ "type": "object", "properties": { "network": { - "type": "string" + "description": "Network to use. Defaults to the chain's configured default network.", + "enum": [ + "devnet", + "mainnet-beta", + "arbitrum", + "avalanche", + "base", + "bsc", + "celo", + "mainnet", + "optimism", + "polygon", + "robinhoodchain-testnet", + "robinhoodchain", + "sepolia", + "unichain" + ], + "type": "string", + "example": "mainnet-beta" }, "address": { "type": "string" @@ -903,7 +895,25 @@ "type": "object", "properties": { "network": { - "type": "string" + "description": "Network to use. Defaults to the chain's configured default network.", + "enum": [ + "devnet", + "mainnet-beta", + "arbitrum", + "avalanche", + "base", + "bsc", + "celo", + "mainnet", + "optimism", + "polygon", + "robinhoodchain-testnet", + "robinhoodchain", + "sepolia", + "unichain" + ], + "type": "string", + "example": "mainnet-beta" }, "tokenSymbols": { "anyOf": [ @@ -958,7 +968,25 @@ "type": "object", "properties": { "network": { - "type": "string" + "description": "Network to use. Defaults to the chain's configured default network.", + "enum": [ + "devnet", + "mainnet-beta", + "arbitrum", + "avalanche", + "base", + "bsc", + "celo", + "mainnet", + "optimism", + "polygon", + "robinhoodchain-testnet", + "robinhoodchain", + "sepolia", + "unichain" + ], + "type": "string", + "example": "mainnet-beta" }, "signature": { "description": "Transaction signature/hash", @@ -1039,7 +1067,25 @@ "type": "object", "properties": { "network": { - "type": "string" + "description": "Network to use. Defaults to the chain's configured default network.", + "enum": [ + "devnet", + "mainnet-beta", + "arbitrum", + "avalanche", + "base", + "bsc", + "celo", + "mainnet", + "optimism", + "polygon", + "robinhoodchain-testnet", + "robinhoodchain", + "sepolia", + "unichain" + ], + "type": "string", + "example": "mainnet-beta" } } }, @@ -1224,7 +1270,25 @@ "type": "object", "properties": { "network": { - "type": "string" + "description": "Network to use. Defaults to the chain's configured default network.", + "enum": [ + "devnet", + "mainnet-beta", + "arbitrum", + "avalanche", + "base", + "bsc", + "celo", + "mainnet", + "optimism", + "polygon", + "robinhoodchain-testnet", + "robinhoodchain", + "sepolia", + "unichain" + ], + "type": "string", + "example": "mainnet-beta" }, "address": { "description": "Wallet address holding the native token", @@ -1245,7 +1309,25 @@ "type": "object", "properties": { "network": { - "type": "string" + "description": "Network to use. Defaults to the chain's configured default network.", + "enum": [ + "devnet", + "mainnet-beta", + "arbitrum", + "avalanche", + "base", + "bsc", + "celo", + "mainnet", + "optimism", + "polygon", + "robinhoodchain-testnet", + "robinhoodchain", + "sepolia", + "unichain" + ], + "type": "string", + "example": "mainnet-beta" }, "address": { "description": "Wallet address holding the wrapped token", @@ -2176,57 +2258,6 @@ "quoteFeeAmountCollected" ] }, - "ClmmCreatePoolRequest": { - "type": "object", - "properties": { - "network": { - "type": "string" - }, - "walletAddress": { - "type": "string" - }, - "baseToken": { - "type": "string" - }, - "quoteToken": { - "type": "string" - }, - "initialPrice": { - "format": "decimal", - "description": "Initial pool price as quote per base. If omitted, the current market price is fetched from the unified swap router so the pool opens on-market.", - "type": "number" - }, - "binStep": { - "x-connectors": [ - "meteora", - "orca" - ], - "description": "Bin/tick granularity: Meteora DLMM bin step (bps); Orca Whirlpool tick spacing.", - "type": "number" - }, - "feeBps": { - "x-connectors": [ - "meteora", - "uniswap", - "pancakeswap" - ], - "description": "Base fee in basis points: Meteora DLMM base fee; Uniswap/PancakeSwap V3 fee tier (1, 5, 30 or 100 bps; PancakeSwap also 25).", - "type": "number" - }, - "ammConfigIndex": { - "x-connectors": [ - "raydium", - "pancakeswap-sol" - ], - "description": "Fee-config index for the Raydium CLMM family: Raydium API config list index; pancakeswap-sol amm_config PDA index. Default 0.", - "type": "number" - } - }, - "required": [ - "baseToken", - "quoteToken" - ] - }, "ClmmCreatePoolResponse": { "type": "object", "properties": { @@ -2438,50 +2469,6 @@ "priceImpactPct" ] }, - "ClmmExecuteSwapRequest": { - "type": "object", - "properties": { - "walletAddress": { - "type": "string" - }, - "network": { - "type": "string" - }, - "poolAddress": { - "description": "Pool address (optional - can be looked up from baseToken and quoteToken)", - "type": "string" - }, - "baseToken": { - "type": "string" - }, - "quoteToken": { - "description": "The other token in the pair (optional - required if poolAddress not provided)", - "type": "string" - }, - "amount": { - "format": "decimal", - "type": "number" - }, - "side": { - "enum": [ - "BUY", - "SELL" - ], - "type": "string" - }, - "slippagePct": { - "format": "decimal", - "minimum": 0, - "maximum": 100, - "type": "number" - } - }, - "required": [ - "baseToken", - "amount", - "side" - ] - }, "ClmmExecuteSwapResponse": { "type": "object", "properties": { @@ -2799,43 +2786,1147 @@ "baseTokenBalanceChange", "quoteTokenBalanceChange" ] - } - } - }, - "paths": { - "/config/": { - "get": { - "tags": [ - "/config" - ], - "description": "Get configuration settings. Returns all configurations if no parameters are specified. Use namespace to get a specific config (e.g., server, ethereum-mainnet, solana-mainnet-beta, uniswap).", - "parameters": [ - { - "schema": { - "type": "string" - }, - "examples": { - "server": { - "value": "server" - }, - "ethereum-mainnet": { - "value": "ethereum-mainnet" - }, - "solana-mainnet-beta": { - "value": "solana-mainnet-beta" - }, - "uniswap": { - "value": "uniswap" - } - }, - "in": "query", - "name": "namespace", - "required": false, - "description": "Optional configuration namespace (e.g., \"server\", \"ethereum-mainnet\", \"solana-mainnet-beta\", \"uniswap\")" + }, + "AmmCreatePoolRequest": { + "type": "object", + "properties": { + "connector": { + "description": "AMM connector", + "enum": [ + "meteora", + "raydium", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" + }, + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "walletAddress": { + "description": "Wallet address (pool creator + payer)", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "baseToken": { + "description": "Base token symbol or address (becomes the pool base)", + "type": "string" + }, + "quoteToken": { + "description": "Quote token symbol or address (becomes the pool quote)", + "type": "string" + }, + "baseTokenAmount": { + "format": "decimal", + "description": "Amount of base token to seed the pool with", + "type": "number" + }, + "quoteTokenAmount": { + "format": "decimal", + "description": "Amount of quote token to seed with. If provided, the base:quote ratio sets the initial price. If omitted (and no initialPrice), the price is fetched from the market.", + "type": "number" + }, + "initialPrice": { + "format": "decimal", + "description": "Initial price as quote per base. Overrides quoteTokenAmount. If both are omitted, the current market price is fetched from the unified swap router so the pool opens on-market.", + "type": "number" + }, + "configAddress": { + "x-connectors": [ + "meteora" + ], + "description": "Meteora DAMM v2 config account address (required for the meteora connector — configs are permissionless accounts with no index derivation, so the address must be explicit).", + "type": "string" + }, + "ammConfigIndex": { + "x-connectors": [ + "raydium" + ], + "description": "Raydium CPMM fee-config index (optional; defaults to the first available config).", + "type": "number" + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Uniswap/PancakeSwap seeding slippage percentage. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 } - ], - "responses": { - "200": { + }, + "required": [ + "connector", + "chainNetwork", + "walletAddress", + "baseToken", + "quoteToken", + "baseTokenAmount" + ] + }, + "AmmAddRequest": { + "type": "object", + "properties": { + "connector": { + "description": "AMM connector", + "enum": [ + "meteora", + "raydium", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" + }, + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "walletAddress": { + "description": "Wallet address", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "poolAddress": { + "description": "Pool contract address", + "type": "string" + }, + "baseTokenAmount": { + "format": "decimal", + "description": "Amount of base token to add", + "type": "number" + }, + "quoteTokenAmount": { + "format": "decimal", + "description": "Amount of quote token to add", + "type": "number" + }, + "positionAddress": { + "x-connectors": [ + "meteora" + ], + "description": "meteora only (DAMM v2 positions are NFTs): add to this specific position. Omit to open a new position. Ignored by fungible-LP AMMs.", + "type": "string" + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 + } + }, + "required": [ + "connector", + "chainNetwork", + "walletAddress", + "poolAddress", + "baseTokenAmount", + "quoteTokenAmount" + ] + }, + "AmmRemoveRequest": { + "type": "object", + "properties": { + "connector": { + "description": "AMM connector", + "enum": [ + "meteora", + "raydium", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" + }, + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "walletAddress": { + "description": "Wallet address", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "poolAddress": { + "description": "Pool contract address", + "type": "string" + }, + "positionAddress": { + "x-connectors": [ + "meteora" + ], + "description": "Required for meteora (DAMM v2 positions are NFTs): the specific position to remove from. List positions with position-info or positions-owned. Ignored by fungible-LP AMMs.", + "type": "string" + }, + "percentageToRemove": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Percentage of liquidity to remove", + "default": 100, + "type": "number", + "example": 100 + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 + } + }, + "required": [ + "connector", + "chainNetwork", + "walletAddress", + "poolAddress", + "percentageToRemove" + ] + }, + "AmmOpenRequest": { + "type": "object", + "properties": { + "connector": { + "description": "AMM connector", + "enum": [ + "meteora", + "raydium", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" + }, + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "walletAddress": { + "description": "Wallet that will own the position", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "poolAddress": { + "description": "Pool to open the position in", + "type": "string" + }, + "baseTokenAmount": { + "format": "decimal", + "description": "Amount of base token to deposit", + "type": "number" + }, + "quoteTokenAmount": { + "format": "decimal", + "description": "Amount of quote token to deposit", + "type": "number" + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 + } + }, + "required": [ + "connector", + "chainNetwork", + "walletAddress", + "poolAddress", + "baseTokenAmount", + "quoteTokenAmount" + ] + }, + "AmmCloseRequest": { + "type": "object", + "properties": { + "connector": { + "description": "AMM connector", + "enum": [ + "meteora", + "raydium", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" + }, + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "walletAddress": { + "description": "Wallet that owns the position", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "poolAddress": { + "description": "Pool the position belongs to", + "type": "string" + }, + "positionAddress": { + "description": "Position to close. Required on AMMs whose positions are discrete accounts (meteora DAMM v2), where a wallet may hold several per pool. Ignored by fungible-LP AMMs, which hold one LP balance per pool.", + "x-connectors": [ + "meteora" + ], + "type": "string" + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage on the withdrawn amounts.", + "type": "number", + "example": 1 + } + }, + "required": [ + "connector", + "chainNetwork", + "walletAddress", + "poolAddress" + ] + }, + "ClmmOpenRequest": { + "type": "object", + "properties": { + "connector": { + "description": "CLMM connector", + "enum": [ + "meteora", + "raydium", + "pancakeswap-sol", + "orca", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" + }, + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "walletAddress": { + "description": "Wallet address", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "lowerPrice": { + "format": "decimal", + "description": "Lower price bound for the position", + "type": "number", + "example": 150 + }, + "upperPrice": { + "format": "decimal", + "description": "Upper price bound for the position", + "type": "number", + "example": 250 + }, + "poolAddress": { + "description": "Pool address", + "type": "string", + "example": "2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3" + }, + "baseTokenAmount": { + "format": "decimal", + "description": "Amount of base token to deposit", + "type": "number", + "example": 0.01 + }, + "quoteTokenAmount": { + "format": "decimal", + "description": "Amount of quote token to deposit", + "type": "number", + "example": 2 + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 + }, + "strategyType": { + "x-connectors": [ + "meteora" + ], + "description": "Strategy type for Meteora positions (0=Spot, 1=Curve). Only applies to Meteora connector.", + "type": "number", + "example": 0 + } + }, + "required": [ + "connector", + "chainNetwork", + "walletAddress", + "lowerPrice", + "upperPrice", + "poolAddress" + ] + }, + "ClmmAddRequest": { + "type": "object", + "properties": { + "connector": { + "description": "CLMM connector", + "enum": [ + "meteora", + "raydium", + "pancakeswap-sol", + "orca", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" + }, + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "walletAddress": { + "description": "Wallet address", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "positionAddress": { + "description": "Position address", + "type": "string", + "example": "" + }, + "baseTokenAmount": { + "format": "decimal", + "description": "Amount of base token to deposit (omit for single-sided quote deposit)", + "type": "number", + "example": 0.01 + }, + "quoteTokenAmount": { + "format": "decimal", + "description": "Amount of quote token to deposit (omit for single-sided base deposit)", + "type": "number", + "example": 2 + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 + }, + "strategyType": { + "x-connectors": [ + "meteora" + ], + "description": "Strategy type for Meteora positions (0=Spot, 1=Curve). Only applies to Meteora connector.", + "type": "number", + "example": 0 + } + }, + "required": [ + "connector", + "chainNetwork", + "walletAddress", + "positionAddress" + ] + }, + "ClmmRemoveRequest": { + "type": "object", + "properties": { + "connector": { + "description": "CLMM connector", + "enum": [ + "meteora", + "raydium", + "pancakeswap-sol", + "orca", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" + }, + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "walletAddress": { + "description": "Wallet address", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "positionAddress": { + "description": "Position address", + "type": "string", + "example": "" + }, + "percentageToRemove": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Percentage of liquidity to remove", + "default": 100, + "type": "number", + "example": 100 + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage. Only applies to the Orca connector; defaults to Orca's configured slippagePct.", + "type": "number", + "example": 1 + } + }, + "required": [ + "connector", + "chainNetwork", + "walletAddress", + "positionAddress", + "percentageToRemove" + ] + }, + "ClmmCollectFeesRequest": { + "type": "object", + "properties": { + "connector": { + "description": "CLMM connector", + "enum": [ + "meteora", + "raydium", + "pancakeswap-sol", + "orca", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" + }, + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "walletAddress": { + "description": "Wallet address", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "positionAddress": { + "description": "Position address", + "type": "string", + "example": "" + } + }, + "required": [ + "connector", + "chainNetwork", + "walletAddress", + "positionAddress" + ] + }, + "ClmmCloseRequest": { + "type": "object", + "properties": { + "connector": { + "description": "CLMM connector", + "enum": [ + "meteora", + "raydium", + "pancakeswap-sol", + "orca", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" + }, + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "walletAddress": { + "description": "Wallet address", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "positionAddress": { + "description": "Position address", + "type": "string", + "example": "" + } + }, + "required": [ + "connector", + "chainNetwork", + "walletAddress", + "positionAddress" + ] + }, + "ClmmCreatePoolRequest": { + "type": "object", + "properties": { + "connector": { + "description": "CLMM connector", + "enum": [ + "meteora", + "raydium", + "pancakeswap-sol", + "orca", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" + }, + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "walletAddress": { + "description": "Wallet address (pool creator + payer)", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "baseToken": { + "type": "string" + }, + "quoteToken": { + "type": "string" + }, + "initialPrice": { + "format": "decimal", + "description": "Initial pool price as quote per base. If omitted, the current market price is fetched from the unified swap router so the pool opens on-market.", + "type": "number" + }, + "binStep": { + "x-connectors": [ + "meteora", + "orca" + ], + "description": "Bin/tick granularity: Meteora DLMM bin step (bps); Orca Whirlpool tick spacing.", + "type": "number" + }, + "feeBps": { + "x-connectors": [ + "meteora", + "uniswap", + "pancakeswap" + ], + "description": "Base fee in basis points: Meteora DLMM base fee; Uniswap/PancakeSwap V3 fee tier (1, 5, 30 or 100 bps; PancakeSwap also 25).", + "type": "number" + }, + "ammConfigIndex": { + "x-connectors": [ + "raydium", + "pancakeswap-sol" + ], + "description": "Fee-config index for the Raydium CLMM family: Raydium API config list index; pancakeswap-sol amm_config PDA index. Default 0.", + "type": "number" + } + }, + "required": [ + "connector", + "chainNetwork", + "walletAddress", + "baseToken", + "quoteToken" + ] + }, + "RouterExecuteQuoteRequest": { + "type": "object", + "properties": { + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "connector": { + "description": "Router connector. Defaults to the network's swapProvider", + "enum": [ + "jupiter", + "dflow", + "okx", + "titan", + "uniswap", + "pancakeswap", + "0x" + ], + "default": "jupiter", + "type": "string", + "example": "jupiter" + }, + "walletAddress": { + "description": "Wallet address that will execute the quote", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "quoteId": { + "description": "ID of a quote returned by /trading/router/quote-swap", + "type": "string" + } + }, + "required": [ + "chainNetwork", + "walletAddress", + "quoteId" + ] + }, + "RouterExecuteSwapRequest": { + "type": "object", + "properties": { + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "connector": { + "description": "Router connector. Defaults to the network's swapProvider", + "enum": [ + "jupiter", + "dflow", + "okx", + "titan", + "uniswap", + "pancakeswap", + "0x" + ], + "default": "jupiter", + "type": "string", + "example": "jupiter" + }, + "walletAddress": { + "description": "Wallet address that will execute the swap", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "baseToken": { + "description": "Symbol or address of the base token", + "default": "SOL", + "type": "string" + }, + "quoteToken": { + "description": "Symbol or address of the quote token", + "default": "USDC", + "type": "string" + }, + "amount": { + "format": "decimal", + "description": "Amount of base token to trade", + "default": 0.01, + "type": "number" + }, + "side": { + "description": "BUY means buying base token with quote token, SELL means selling base token for quote token", + "enum": [ + "BUY", + "SELL" + ], + "default": "SELL", + "type": "string" + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 + }, + "approximateIfNoExactOut": { + "description": "For BUY orders when the router has no ExactOut route: approximate via a sell-leg ExactIn swap instead of failing.", + "default": true, + "x-connectors": [ + "jupiter", + "dflow", + "okx", + "titan" + ], + "type": "boolean" + } + }, + "required": [ + "chainNetwork", + "walletAddress", + "baseToken", + "quoteToken", + "amount", + "side" + ] + }, + "AmmExecuteSwapRequest": { + "type": "object", + "properties": { + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "connector": { + "description": "AMM connector to execute the swap against", + "enum": [ + "meteora", + "raydium", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" + }, + "walletAddress": { + "description": "Wallet address that will execute the swap", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "baseToken": { + "description": "Symbol or address of the base token", + "default": "SOL", + "type": "string" + }, + "quoteToken": { + "description": "Symbol or address of the quote token", + "default": "USDC", + "type": "string" + }, + "amount": { + "format": "decimal", + "description": "Amount of base token to trade", + "default": 0.01, + "type": "number" + }, + "side": { + "description": "BUY means buying base token with quote token, SELL means selling base token for quote token", + "enum": [ + "BUY", + "SELL" + ], + "default": "SELL", + "type": "string" + }, + "poolAddress": { + "description": "Pool to trade against. Omit to resolve it from Gateway's configured pool list by token pair; pass an address to pin a pool that is not in that list.", + "type": "string" + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 + } + }, + "required": [ + "chainNetwork", + "walletAddress", + "baseToken", + "quoteToken", + "amount", + "side" + ] + }, + "ClmmExecuteSwapRequest": { + "type": "object", + "properties": { + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "connector": { + "description": "CLMM connector to execute the swap against", + "enum": [ + "meteora", + "raydium", + "orca", + "pancakeswap-sol", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" + }, + "walletAddress": { + "description": "Wallet address that will execute the swap", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "baseToken": { + "description": "Symbol or address of the base token", + "default": "SOL", + "type": "string" + }, + "quoteToken": { + "description": "Symbol or address of the quote token", + "default": "USDC", + "type": "string" + }, + "amount": { + "format": "decimal", + "description": "Amount of base token to trade", + "default": 0.01, + "type": "number" + }, + "side": { + "description": "BUY means buying base token with quote token, SELL means selling base token for quote token", + "enum": [ + "BUY", + "SELL" + ], + "default": "SELL", + "type": "string" + }, + "poolAddress": { + "description": "Pool to trade against. Omit to resolve it from Gateway's configured pool list by token pair; pass an address to pin a pool that is not in that list.", + "type": "string" + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 + } + }, + "required": [ + "chainNetwork", + "walletAddress", + "baseToken", + "quoteToken", + "amount", + "side" + ] + }, + "AllowancesRequest": { + "type": "object", + "properties": { + "network": { + "description": "The Ethereum network to use", + "default": "mainnet", + "enum": [ + "arbitrum", + "avalanche", + "base", + "bsc", + "celo", + "mainnet", + "optimism", + "polygon", + "robinhoodchain-testnet", + "robinhoodchain", + "sepolia", + "unichain" + ], + "type": "string" + }, + "address": { + "description": "Ethereum wallet address", + "default": "0xDA50C69342216b538Daf06FfECDa7363E0B96684", + "type": "string" + }, + "spender": { + "description": "Connector name (e.g., uniswap/clmm, uniswap/amm, 0x/router) or contract address", + "type": "string", + "example": "uniswap/router" + }, + "tokens": { + "description": "Array of token symbols or addresses", + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "USDC", + "WETH" + ] + } + }, + "required": [ + "spender", + "tokens" + ] + }, + "ApproveRequest": { + "type": "object", + "properties": { + "network": { + "description": "The Ethereum network to use", + "default": "mainnet", + "enum": [ + "arbitrum", + "avalanche", + "base", + "bsc", + "celo", + "mainnet", + "optimism", + "polygon", + "robinhoodchain-testnet", + "robinhoodchain", + "sepolia", + "unichain" + ], + "type": "string" + }, + "address": { + "description": "Ethereum wallet address", + "default": "0xDA50C69342216b538Daf06FfECDa7363E0B96684", + "type": "string" + }, + "spender": { + "description": "Connector name (e.g., uniswap/clmm, uniswap/amm, 0x/router) contract address", + "type": "string", + "example": "uniswap/router" + }, + "token": { + "description": "Token symbol or address", + "type": "string", + "example": "USDC" + }, + "amount": { + "description": "The amount to approve. If not provided, defaults to maximum amount (unlimited approval).", + "default": "", + "type": "string" + } + }, + "required": [ + "spender", + "token" + ] + }, + "RemoveWalletRequest": { + "type": "object", + "properties": { + "chain": { + "description": "Blockchain to remove wallet from", + "enum": [ + "ethereum", + "solana" + ], + "type": "string", + "example": "solana" + }, + "address": { + "description": "Wallet address to remove", + "type": "string" + } + }, + "required": [ + "chain", + "address" + ] + }, + "AddHardwareWalletRequest": { + "type": "object", + "properties": { + "chain": { + "description": "Blockchain for hardware wallet", + "enum": [ + "ethereum", + "solana" + ], + "default": "solana", + "type": "string", + "example": "solana" + }, + "address": { + "description": "Hardware wallet address to add (must exist on connected Ledger device)", + "type": "string" + }, + "setDefault": { + "description": "Set this wallet as the default for the chain", + "default": false, + "type": "boolean" + } + }, + "required": [ + "chain", + "address" + ] + } + } + }, + "paths": { + "/config/": { + "get": { + "tags": [ + "/config" + ], + "description": "Get configuration settings. Returns all configurations if no parameters are specified. Use namespace to get a specific config (e.g., server, ethereum-mainnet, solana-mainnet-beta, uniswap).", + "parameters": [ + { + "schema": { + "type": "string" + }, + "examples": { + "server": { + "value": "server" + }, + "ethereum-mainnet": { + "value": "ethereum-mainnet" + }, + "solana-mainnet-beta": { + "value": "solana-mainnet-beta" + }, + "uniswap": { + "value": "uniswap" + } + }, + "in": "query", + "name": "namespace", + "required": false, + "description": "Optional configuration namespace (e.g., \"server\", \"ethereum-mainnet\", \"solana-mainnet-beta\", \"uniswap\")" + } + ], + "responses": { + "200": { "description": "Default Response", "content": { "application/json": { @@ -3238,36 +4329,10 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "chain": { - "description": "Blockchain for hardware wallet", - "enum": [ - "ethereum", - "solana" - ], - "default": "solana", - "type": "string", - "example": "solana" - }, - "address": { - "description": "Hardware wallet address to add (must exist on connected Ledger device)", - "type": "string" - }, - "setDefault": { - "description": "Set this wallet as the default for the chain", - "default": false, - "type": "boolean" - } - }, - "required": [ - "chain", - "address" - ] + "$ref": "#/components/schemas/AddHardwareWalletRequest" } } - }, - "required": true + } }, "responses": { "200": { @@ -3317,30 +4382,10 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "chain": { - "description": "Blockchain to remove wallet from", - "enum": [ - "ethereum", - "solana" - ], - "type": "string", - "example": "solana" - }, - "address": { - "description": "Wallet address to remove", - "type": "string" - } - }, - "required": [ - "chain", - "address" - ] + "$ref": "#/components/schemas/RemoveWalletRequest" } } - }, - "required": true + } }, "responses": { "200": { @@ -5217,48 +6262,10 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "chainNetwork": { - "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", - "default": "solana-mainnet-beta", - "type": "string", - "example": "solana-mainnet-beta" - }, - "connector": { - "description": "Router connector. Defaults to the network's swapProvider", - "enum": [ - "jupiter", - "dflow", - "okx", - "titan", - "uniswap", - "pancakeswap", - "0x" - ], - "default": "jupiter", - "type": "string", - "example": "jupiter" - }, - "walletAddress": { - "description": "Wallet address that will execute the quote", - "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", - "type": "string" - }, - "quoteId": { - "description": "ID of a quote returned by /trading/router/quote-swap", - "type": "string" - } - }, - "required": [ - "chainNetwork", - "walletAddress", - "quoteId" - ] + "$ref": "#/components/schemas/RouterExecuteQuoteRequest" } } - }, - "required": true + } }, "responses": { "200": { @@ -5284,91 +6291,10 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "chainNetwork": { - "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", - "default": "solana-mainnet-beta", - "type": "string", - "example": "solana-mainnet-beta" - }, - "connector": { - "description": "Router connector. Defaults to the network's swapProvider", - "enum": [ - "jupiter", - "dflow", - "okx", - "titan", - "uniswap", - "pancakeswap", - "0x" - ], - "default": "jupiter", - "type": "string", - "example": "jupiter" - }, - "walletAddress": { - "description": "Wallet address that will execute the swap", - "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", - "type": "string" - }, - "baseToken": { - "description": "Symbol or address of the base token", - "default": "SOL", - "type": "string" - }, - "quoteToken": { - "description": "Symbol or address of the quote token", - "default": "USDC", - "type": "string" - }, - "amount": { - "format": "decimal", - "description": "Amount of base token to trade", - "default": 0.01, - "type": "number" - }, - "side": { - "description": "BUY means buying base token with quote token, SELL means selling base token for quote token", - "enum": [ - "BUY", - "SELL" - ], - "default": "SELL", - "type": "string" - }, - "slippagePct": { - "format": "decimal", - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", - "type": "number", - "example": 1 - }, - "approximateIfNoExactOut": { - "description": "For BUY orders when the router has no ExactOut route: approximate via a sell-leg ExactIn swap instead of failing.", - "default": true, - "x-connectors": [ - "jupiter", - "dflow", - "okx", - "titan" - ], - "type": "boolean" - } - }, - "required": [ - "chainNetwork", - "walletAddress", - "baseToken", - "quoteToken", - "amount", - "side" - ] + "$ref": "#/components/schemas/RouterExecuteSwapRequest" } } - }, - "required": true + } }, "responses": { "200": { @@ -5950,113 +6876,40 @@ "type": "number" }, "example": 1, - "in": "query", - "name": "slippagePct", - "required": false, - "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct." - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChainQuoteSwapResponse" - } - } - } - } - } - } - }, - "/trading/clmm/execute-swap": { - "post": { - "tags": [ - "/trading/clmm" - ], - "description": "Execute a swap against a single CLMM pool", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "chainNetwork": { - "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", - "default": "solana-mainnet-beta", - "type": "string", - "example": "solana-mainnet-beta" - }, - "connector": { - "description": "CLMM connector to execute the swap against", - "enum": [ - "meteora", - "raydium", - "orca", - "pancakeswap-sol", - "uniswap", - "pancakeswap" - ], - "default": "meteora", - "type": "string", - "example": "meteora" - }, - "walletAddress": { - "description": "Wallet address that will execute the swap", - "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", - "type": "string" - }, - "baseToken": { - "description": "Symbol or address of the base token", - "default": "SOL", - "type": "string" - }, - "quoteToken": { - "description": "Symbol or address of the quote token", - "default": "USDC", - "type": "string" - }, - "amount": { - "format": "decimal", - "description": "Amount of base token to trade", - "default": 0.01, - "type": "number" - }, - "side": { - "description": "BUY means buying base token with quote token, SELL means selling base token for quote token", - "enum": [ - "BUY", - "SELL" - ], - "default": "SELL", - "type": "string" - }, - "poolAddress": { - "description": "Pool to trade against. Omit to resolve it from Gateway's configured pool list by token pair; pass an address to pin a pool that is not in that list.", - "type": "string" - }, - "slippagePct": { - "format": "decimal", - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", - "type": "number", - "example": 1 - } - }, - "required": [ - "chainNetwork", - "walletAddress", - "baseToken", - "quoteToken", - "amount", - "side" - ] + "in": "query", + "name": "slippagePct", + "required": false, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct." + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChainQuoteSwapResponse" + } } } - }, - "required": true + } + } + } + }, + "/trading/clmm/execute-swap": { + "post": { + "tags": [ + "/trading/clmm" + ], + "description": "Execute a swap against a single CLMM pool", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClmmExecuteSwapRequest" + } + } + } }, "responses": { "200": { @@ -6082,91 +6935,10 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "connector": { - "description": "CLMM connector", - "enum": [ - "meteora", - "raydium", - "pancakeswap-sol", - "orca", - "uniswap", - "pancakeswap" - ], - "default": "meteora", - "type": "string", - "example": "meteora" - }, - "chainNetwork": { - "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", - "default": "solana-mainnet-beta", - "type": "string", - "example": "solana-mainnet-beta" - }, - "walletAddress": { - "description": "Wallet address", - "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", - "type": "string" - }, - "lowerPrice": { - "format": "decimal", - "description": "Lower price bound for the position", - "type": "number", - "example": 150 - }, - "upperPrice": { - "format": "decimal", - "description": "Upper price bound for the position", - "type": "number", - "example": 250 - }, - "poolAddress": { - "description": "Pool address", - "type": "string", - "example": "2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3" - }, - "baseTokenAmount": { - "format": "decimal", - "description": "Amount of base token to deposit", - "type": "number", - "example": 0.01 - }, - "quoteTokenAmount": { - "format": "decimal", - "description": "Amount of quote token to deposit", - "type": "number", - "example": 2 - }, - "slippagePct": { - "format": "decimal", - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", - "type": "number", - "example": 1 - }, - "strategyType": { - "x-connectors": [ - "meteora" - ], - "description": "Strategy type for Meteora positions (0=Spot, 1=Curve). Only applies to Meteora connector.", - "type": "number", - "example": 0 - } - }, - "required": [ - "connector", - "chainNetwork", - "walletAddress", - "lowerPrice", - "upperPrice", - "poolAddress" - ] + "$ref": "#/components/schemas/ClmmOpenRequest" } } - }, - "required": true + } }, "responses": { "200": { @@ -6192,77 +6964,10 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "connector": { - "description": "CLMM connector", - "enum": [ - "meteora", - "raydium", - "pancakeswap-sol", - "orca", - "uniswap", - "pancakeswap" - ], - "default": "meteora", - "type": "string", - "example": "meteora" - }, - "chainNetwork": { - "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", - "default": "solana-mainnet-beta", - "type": "string", - "example": "solana-mainnet-beta" - }, - "walletAddress": { - "description": "Wallet address", - "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", - "type": "string" - }, - "positionAddress": { - "description": "Position address", - "type": "string", - "example": "" - }, - "baseTokenAmount": { - "format": "decimal", - "description": "Amount of base token to deposit (omit for single-sided quote deposit)", - "type": "number", - "example": 0.01 - }, - "quoteTokenAmount": { - "format": "decimal", - "description": "Amount of quote token to deposit (omit for single-sided base deposit)", - "type": "number", - "example": 2 - }, - "slippagePct": { - "format": "decimal", - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", - "type": "number", - "example": 1 - }, - "strategyType": { - "x-connectors": [ - "meteora" - ], - "description": "Strategy type for Meteora positions (0=Spot, 1=Curve). Only applies to Meteora connector.", - "type": "number", - "example": 0 - } - }, - "required": [ - "connector", - "chainNetwork", - "walletAddress", - "positionAddress" - ] + "$ref": "#/components/schemas/ClmmAddRequest" } } - }, - "required": true + } }, "responses": { "200": { @@ -6288,67 +6993,10 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "connector": { - "description": "CLMM connector", - "enum": [ - "meteora", - "raydium", - "pancakeswap-sol", - "orca", - "uniswap", - "pancakeswap" - ], - "default": "meteora", - "type": "string", - "example": "meteora" - }, - "chainNetwork": { - "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", - "default": "solana-mainnet-beta", - "type": "string", - "example": "solana-mainnet-beta" - }, - "walletAddress": { - "description": "Wallet address", - "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", - "type": "string" - }, - "positionAddress": { - "description": "Position address", - "type": "string", - "example": "" - }, - "percentageToRemove": { - "format": "decimal", - "minimum": 0, - "maximum": 100, - "description": "Percentage of liquidity to remove", - "default": 100, - "type": "number", - "example": 100 - }, - "slippagePct": { - "format": "decimal", - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage. Only applies to the Orca connector; defaults to Orca's configured slippagePct.", - "type": "number", - "example": 1 - } - }, - "required": [ - "connector", - "chainNetwork", - "walletAddress", - "positionAddress", - "percentageToRemove" - ] + "$ref": "#/components/schemas/ClmmRemoveRequest" } } - }, - "required": true + } }, "responses": { "200": { @@ -6374,49 +7022,10 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "connector": { - "description": "CLMM connector", - "enum": [ - "meteora", - "raydium", - "pancakeswap-sol", - "orca", - "uniswap", - "pancakeswap" - ], - "default": "meteora", - "type": "string", - "example": "meteora" - }, - "chainNetwork": { - "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", - "default": "solana-mainnet-beta", - "type": "string", - "example": "solana-mainnet-beta" - }, - "walletAddress": { - "description": "Wallet address", - "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", - "type": "string" - }, - "positionAddress": { - "description": "Position address", - "type": "string", - "example": "" - } - }, - "required": [ - "connector", - "chainNetwork", - "walletAddress", - "positionAddress" - ] + "$ref": "#/components/schemas/ClmmCollectFeesRequest" } } - }, - "required": true + } }, "responses": { "200": { @@ -6442,49 +7051,10 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "connector": { - "description": "CLMM connector", - "enum": [ - "meteora", - "raydium", - "pancakeswap-sol", - "orca", - "uniswap", - "pancakeswap" - ], - "default": "meteora", - "type": "string", - "example": "meteora" - }, - "chainNetwork": { - "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", - "default": "solana-mainnet-beta", - "type": "string", - "example": "solana-mainnet-beta" - }, - "walletAddress": { - "description": "Wallet address", - "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", - "type": "string" - }, - "positionAddress": { - "description": "Position address", - "type": "string", - "example": "" - } - }, - "required": [ - "connector", - "chainNetwork", - "walletAddress", - "positionAddress" - ] + "$ref": "#/components/schemas/ClmmCloseRequest" } } - }, - "required": true + } }, "responses": { "200": { @@ -6510,81 +7080,10 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "connector": { - "description": "CLMM connector", - "enum": [ - "meteora", - "raydium", - "pancakeswap-sol", - "orca", - "uniswap", - "pancakeswap" - ], - "default": "meteora", - "type": "string", - "example": "meteora" - }, - "chainNetwork": { - "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", - "default": "solana-mainnet-beta", - "type": "string", - "example": "solana-mainnet-beta" - }, - "walletAddress": { - "description": "Wallet address (pool creator + payer)", - "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", - "type": "string" - }, - "baseToken": { - "type": "string" - }, - "quoteToken": { - "type": "string" - }, - "initialPrice": { - "format": "decimal", - "description": "Initial pool price as quote per base. If omitted, the current market price is fetched from the unified swap router so the pool opens on-market.", - "type": "number" - }, - "binStep": { - "x-connectors": [ - "meteora", - "orca" - ], - "description": "Bin/tick granularity: Meteora DLMM bin step (bps); Orca Whirlpool tick spacing.", - "type": "number" - }, - "feeBps": { - "x-connectors": [ - "meteora", - "uniswap", - "pancakeswap" - ], - "description": "Base fee in basis points: Meteora DLMM base fee; Uniswap/PancakeSwap V3 fee tier (1, 5, 30 or 100 bps; PancakeSwap also 25).", - "type": "number" - }, - "ammConfigIndex": { - "x-connectors": [ - "raydium", - "pancakeswap-sol" - ], - "description": "Fee-config index for the Raydium CLMM family: Raydium API config list index; pancakeswap-sol amm_config PDA index. Default 0.", - "type": "number" - } - }, - "required": [ - "connector", - "chainNetwork", - "walletAddress", - "baseToken", - "quoteToken" - ] + "$ref": "#/components/schemas/ClmmCreatePoolRequest" } } - }, - "required": true + } }, "responses": { "200": { @@ -6992,99 +7491,28 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ChainQuoteSwapResponse" - } - } - } - } - } - } - }, - "/trading/amm/execute-swap": { - "post": { - "tags": [ - "/trading/amm" - ], - "description": "Execute a swap against a single AMM pool", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "chainNetwork": { - "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", - "default": "solana-mainnet-beta", - "type": "string", - "example": "solana-mainnet-beta" - }, - "connector": { - "description": "AMM connector to execute the swap against", - "enum": [ - "meteora", - "raydium", - "uniswap", - "pancakeswap" - ], - "default": "meteora", - "type": "string", - "example": "meteora" - }, - "walletAddress": { - "description": "Wallet address that will execute the swap", - "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", - "type": "string" - }, - "baseToken": { - "description": "Symbol or address of the base token", - "default": "SOL", - "type": "string" - }, - "quoteToken": { - "description": "Symbol or address of the quote token", - "default": "USDC", - "type": "string" - }, - "amount": { - "format": "decimal", - "description": "Amount of base token to trade", - "default": 0.01, - "type": "number" - }, - "side": { - "description": "BUY means buying base token with quote token, SELL means selling base token for quote token", - "enum": [ - "BUY", - "SELL" - ], - "default": "SELL", - "type": "string" - }, - "poolAddress": { - "description": "Pool to trade against. Omit to resolve it from Gateway's configured pool list by token pair; pass an address to pin a pool that is not in that list.", - "type": "string" - }, - "slippagePct": { - "format": "decimal", - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", - "type": "number", - "example": 1 - } - }, - "required": [ - "chainNetwork", - "walletAddress", - "baseToken", - "quoteToken", - "amount", - "side" - ] + "$ref": "#/components/schemas/ChainQuoteSwapResponse" + } } } - }, - "required": true + } + } + } + }, + "/trading/amm/execute-swap": { + "post": { + "tags": [ + "/trading/amm" + ], + "description": "Execute a swap against a single AMM pool", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AmmExecuteSwapRequest" + } + } + } }, "responses": { "200": { @@ -7110,66 +7538,10 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "connector": { - "description": "AMM connector", - "enum": [ - "meteora", - "raydium", - "uniswap", - "pancakeswap" - ], - "default": "meteora", - "type": "string", - "example": "meteora" - }, - "chainNetwork": { - "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", - "default": "solana-mainnet-beta", - "type": "string", - "example": "solana-mainnet-beta" - }, - "walletAddress": { - "description": "Wallet that will own the position", - "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", - "type": "string" - }, - "poolAddress": { - "description": "Pool to open the position in", - "type": "string" - }, - "baseTokenAmount": { - "format": "decimal", - "description": "Amount of base token to deposit", - "type": "number" - }, - "quoteTokenAmount": { - "format": "decimal", - "description": "Amount of quote token to deposit", - "type": "number" - }, - "slippagePct": { - "format": "decimal", - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", - "type": "number", - "example": 1 - } - }, - "required": [ - "connector", - "chainNetwork", - "walletAddress", - "poolAddress", - "baseTokenAmount", - "quoteTokenAmount" - ] + "$ref": "#/components/schemas/AmmOpenRequest" } } - }, - "required": true + } }, "responses": { "200": { @@ -7195,73 +7567,10 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "connector": { - "description": "AMM connector", - "enum": [ - "meteora", - "raydium", - "uniswap", - "pancakeswap" - ], - "default": "meteora", - "type": "string", - "example": "meteora" - }, - "chainNetwork": { - "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", - "default": "solana-mainnet-beta", - "type": "string", - "example": "solana-mainnet-beta" - }, - "walletAddress": { - "description": "Wallet address", - "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", - "type": "string" - }, - "poolAddress": { - "description": "Pool contract address", - "type": "string" - }, - "baseTokenAmount": { - "format": "decimal", - "description": "Amount of base token to add", - "type": "number" - }, - "quoteTokenAmount": { - "format": "decimal", - "description": "Amount of quote token to add", - "type": "number" - }, - "positionAddress": { - "x-connectors": [ - "meteora" - ], - "description": "meteora only (DAMM v2 positions are NFTs): add to this specific position. Omit to open a new position. Ignored by fungible-LP AMMs.", - "type": "string" - }, - "slippagePct": { - "format": "decimal", - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", - "type": "number", - "example": 1 - } - }, - "required": [ - "connector", - "chainNetwork", - "walletAddress", - "poolAddress", - "baseTokenAmount", - "quoteTokenAmount" - ] + "$ref": "#/components/schemas/AmmAddRequest" } } - }, - "required": true + } }, "responses": { "200": { @@ -7287,71 +7596,10 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "connector": { - "description": "AMM connector", - "enum": [ - "meteora", - "raydium", - "uniswap", - "pancakeswap" - ], - "default": "meteora", - "type": "string", - "example": "meteora" - }, - "chainNetwork": { - "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", - "default": "solana-mainnet-beta", - "type": "string", - "example": "solana-mainnet-beta" - }, - "walletAddress": { - "description": "Wallet address", - "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", - "type": "string" - }, - "poolAddress": { - "description": "Pool contract address", - "type": "string" - }, - "positionAddress": { - "x-connectors": [ - "meteora" - ], - "description": "Required for meteora (DAMM v2 positions are NFTs): the specific position to remove from. List positions with position-info or positions-owned. Ignored by fungible-LP AMMs.", - "type": "string" - }, - "percentageToRemove": { - "format": "decimal", - "minimum": 0, - "maximum": 100, - "description": "Percentage of liquidity to remove", - "default": 100, - "type": "number", - "example": 100 - }, - "slippagePct": { - "format": "decimal", - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", - "type": "number", - "example": 1 - } - }, - "required": [ - "connector", - "chainNetwork", - "walletAddress", - "poolAddress", - "percentageToRemove" - ] + "$ref": "#/components/schemas/AmmRemoveRequest" } } - }, - "required": true + } }, "responses": { "200": { @@ -7377,61 +7625,10 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "connector": { - "description": "AMM connector", - "enum": [ - "meteora", - "raydium", - "uniswap", - "pancakeswap" - ], - "default": "meteora", - "type": "string", - "example": "meteora" - }, - "chainNetwork": { - "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", - "default": "solana-mainnet-beta", - "type": "string", - "example": "solana-mainnet-beta" - }, - "walletAddress": { - "description": "Wallet that owns the position", - "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", - "type": "string" - }, - "poolAddress": { - "description": "Pool the position belongs to", - "type": "string" - }, - "positionAddress": { - "description": "Position to close. Required on AMMs whose positions are discrete accounts (meteora DAMM v2), where a wallet may hold several per pool. Ignored by fungible-LP AMMs, which hold one LP balance per pool.", - "x-connectors": [ - "meteora" - ], - "type": "string" - }, - "slippagePct": { - "format": "decimal", - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage on the withdrawn amounts.", - "type": "number", - "example": 1 - } - }, - "required": [ - "connector", - "chainNetwork", - "walletAddress", - "poolAddress" - ] + "$ref": "#/components/schemas/AmmCloseRequest" } } - }, - "required": true + } }, "responses": { "200": { @@ -7457,89 +7654,10 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "connector": { - "description": "AMM connector", - "enum": [ - "meteora", - "raydium", - "uniswap", - "pancakeswap" - ], - "default": "meteora", - "type": "string", - "example": "meteora" - }, - "chainNetwork": { - "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", - "default": "solana-mainnet-beta", - "type": "string", - "example": "solana-mainnet-beta" - }, - "walletAddress": { - "description": "Wallet address (pool creator + payer)", - "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", - "type": "string" - }, - "baseToken": { - "description": "Base token symbol or address (becomes the pool base)", - "type": "string" - }, - "quoteToken": { - "description": "Quote token symbol or address (becomes the pool quote)", - "type": "string" - }, - "baseTokenAmount": { - "format": "decimal", - "description": "Amount of base token to seed the pool with", - "type": "number" - }, - "quoteTokenAmount": { - "format": "decimal", - "description": "Amount of quote token to seed with. If provided, the base:quote ratio sets the initial price. If omitted (and no initialPrice), the price is fetched from the market.", - "type": "number" - }, - "initialPrice": { - "format": "decimal", - "description": "Initial price as quote per base. Overrides quoteTokenAmount. If both are omitted, the current market price is fetched from the unified swap router so the pool opens on-market.", - "type": "number" - }, - "configAddress": { - "x-connectors": [ - "meteora" - ], - "description": "Meteora DAMM v2 config account address (required for the meteora connector — configs are permissionless accounts with no index derivation, so the address must be explicit).", - "type": "string" - }, - "ammConfigIndex": { - "x-connectors": [ - "raydium" - ], - "description": "Raydium CPMM fee-config index (optional; defaults to the first available config).", - "type": "number" - }, - "slippagePct": { - "format": "decimal", - "minimum": 0, - "maximum": 100, - "description": "Uniswap/PancakeSwap seeding slippage percentage. Defaults to the connector's configured slippagePct.", - "type": "number", - "example": 1 - } - }, - "required": [ - "connector", - "chainNetwork", - "walletAddress", - "baseToken", - "quoteToken", - "baseTokenAmount" - ] + "$ref": "#/components/schemas/AmmCreatePoolRequest" } } - }, - "required": true + } }, "responses": { "200": { @@ -7564,19 +7682,43 @@ "parameters": [ { "schema": { + "enum": [ + "devnet", + "mainnet-beta", + "arbitrum", + "avalanche", + "base", + "bsc", + "celo", + "mainnet", + "optimism", + "polygon", + "robinhoodchain-testnet", + "robinhoodchain", + "sepolia", + "unichain" + ], "type": "string" }, + "example": "mainnet-beta", "in": "query", "name": "network", - "required": false + "required": false, + "description": "Network to use. Defaults to the chain's configured default network." }, { "schema": { + "enum": [ + "solana", + "ethereum" + ], + "default": "solana", "type": "string" }, "in": "path", "name": "chain", - "required": true + "required": true, + "description": "Chain to operate on" } ], "responses": { @@ -7602,19 +7744,43 @@ "parameters": [ { "schema": { + "enum": [ + "devnet", + "mainnet-beta", + "arbitrum", + "avalanche", + "base", + "bsc", + "celo", + "mainnet", + "optimism", + "polygon", + "robinhoodchain-testnet", + "robinhoodchain", + "sepolia", + "unichain" + ], "type": "string" }, + "example": "mainnet-beta", "in": "query", "name": "network", - "required": false + "required": false, + "description": "Network to use. Defaults to the chain's configured default network." }, { "schema": { + "enum": [ + "solana", + "ethereum" + ], + "default": "solana", "type": "string" }, "in": "path", "name": "chain", - "required": true + "required": true, + "description": "Chain to operate on" } ], "responses": { @@ -7649,11 +7815,17 @@ "parameters": [ { "schema": { + "enum": [ + "solana", + "ethereum" + ], + "default": "solana", "type": "string" }, "in": "path", "name": "chain", - "required": true + "required": true, + "description": "Chain to operate on" } ], "responses": { @@ -7688,11 +7860,17 @@ "parameters": [ { "schema": { + "enum": [ + "solana", + "ethereum" + ], + "default": "solana", "type": "string" }, "in": "path", "name": "chain", - "required": true + "required": true, + "description": "Chain to operate on" } ], "responses": { @@ -7727,11 +7905,17 @@ "parameters": [ { "schema": { + "enum": [ + "solana", + "ethereum" + ], + "default": "solana", "type": "string" }, "in": "path", "name": "chain", - "required": true + "required": true, + "description": "Chain to operate on" } ], "responses": { @@ -7766,11 +7950,17 @@ "parameters": [ { "schema": { + "enum": [ + "solana", + "ethereum" + ], + "default": "solana", "type": "string" }, "in": "path", "name": "chain", - "required": true + "required": true, + "description": "Chain to operate on" } ], "responses": { @@ -7797,57 +7987,10 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "network": { - "description": "The Ethereum network to use", - "default": "mainnet", - "enum": [ - "arbitrum", - "avalanche", - "base", - "bsc", - "celo", - "mainnet", - "optimism", - "polygon", - "robinhoodchain-testnet", - "robinhoodchain", - "sepolia", - "unichain" - ], - "type": "string" - }, - "address": { - "description": "Ethereum wallet address", - "default": "0xDA50C69342216b538Daf06FfECDa7363E0B96684", - "type": "string" - }, - "spender": { - "description": "Connector name (e.g., uniswap/clmm, uniswap/amm, 0x/router) or contract address", - "type": "string", - "example": "uniswap/router" - }, - "tokens": { - "description": "Array of token symbols or addresses", - "type": "array", - "items": { - "type": "string" - }, - "example": [ - "USDC", - "WETH" - ] - } - }, - "required": [ - "spender", - "tokens" - ] + "$ref": "#/components/schemas/AllowancesRequest" } } - }, - "required": true + } }, "responses": { "200": { @@ -7888,56 +8031,10 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "network": { - "description": "The Ethereum network to use", - "default": "mainnet", - "enum": [ - "arbitrum", - "avalanche", - "base", - "bsc", - "celo", - "mainnet", - "optimism", - "polygon", - "robinhoodchain-testnet", - "robinhoodchain", - "sepolia", - "unichain" - ], - "type": "string" - }, - "address": { - "description": "Ethereum wallet address", - "default": "0xDA50C69342216b538Daf06FfECDa7363E0B96684", - "type": "string" - }, - "spender": { - "description": "Connector name (e.g., uniswap/clmm, uniswap/amm, 0x/router) contract address", - "type": "string", - "example": "uniswap/router" - }, - "token": { - "description": "Token symbol or address", - "type": "string", - "example": "USDC" - }, - "amount": { - "description": "The amount to approve. If not provided, defaults to maximum amount (unlimited approval).", - "default": "", - "type": "string" - } - }, - "required": [ - "spender", - "token" - ] + "$ref": "#/components/schemas/ApproveRequest" } } - }, - "required": true + } }, "responses": { "200": { diff --git a/models/gateway_generated.py b/models/gateway_generated.py index 8d6acd91..c0b6be45 100644 --- a/models/gateway_generated.py +++ b/models/gateway_generated.py @@ -162,17 +162,6 @@ class AmmQuoteSwapResponse(BaseModel): price_impact_pct: Decimal = Field(..., alias='priceImpactPct') -class AmmExecuteSwapRequest(BaseModel): - wallet_address: str | None = Field(None, alias='walletAddress') - network: str | None = None - pool_address: str | None = Field(None, alias='poolAddress', description='Pool address (optional - can be looked up from baseToken and quoteToken)') - base_token: str = Field(..., alias='baseToken') - quote_token: str | None = Field(None, alias='quoteToken', description='The other token in the pair (optional - required if poolAddress not provided)') - amount: Decimal - side: Side - slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct') - - class AmmExecuteSwapResponseData(BaseModel): token_in: str = Field(..., alias='tokenIn') token_out: str = Field(..., alias='tokenOut') @@ -184,8 +173,25 @@ class AmmExecuteSwapResponseData(BaseModel): slippage_pct: Decimal | None = Field(None, alias='slippagePct', description='Slippage tolerance percentage actually applied to the swap') +class Network(StrEnum): + devnet = 'devnet' + mainnet_beta = 'mainnet-beta' + arbitrum = 'arbitrum' + avalanche = 'avalanche' + base = 'base' + bsc = 'bsc' + celo = 'celo' + mainnet = 'mainnet' + optimism = 'optimism' + polygon = 'polygon' + robinhoodchain_testnet = 'robinhoodchain-testnet' + robinhoodchain = 'robinhoodchain' + sepolia = 'sepolia' + unichain = 'unichain' + + class EstimateGasRequest(BaseModel): - network: str | None = None + network: Network | None = Field(None, description="Network to use. Defaults to the chain's configured default network.", examples=['mainnet-beta']) class EstimateGasResponse(BaseModel): @@ -203,7 +209,7 @@ class EstimateGasResponse(BaseModel): class BalanceRequest(BaseModel): - network: str | None = None + network: Network | None = Field(None, description="Network to use. Defaults to the chain's configured default network.", examples=['mainnet-beta']) address: str | None = None tokens: list[str] | None = Field(None, description='a list of token symbols or addresses') fetch_all: bool | None = Field(None, alias='fetchAll', description='fetch all tokens in wallet, not just those in token list (default: false)') @@ -214,7 +220,7 @@ class BalanceResponse(BaseModel): class TokensRequest(BaseModel): - network: str | None = None + network: Network | None = Field(None, description="Network to use. Defaults to the chain's configured default network.", examples=['mainnet-beta']) token_symbols: str | list[str] | None = Field(None, alias='tokenSymbols') @@ -230,7 +236,7 @@ class TokensResponse(BaseModel): class PollRequest(BaseModel): - network: str | None = None + network: Network | None = Field(None, description="Network to use. Defaults to the chain's configured default network.", examples=['mainnet-beta']) signature: str = Field(..., description='Transaction signature/hash') @@ -245,7 +251,7 @@ class PollResponse(BaseModel): class StatusRequest(BaseModel): - network: str | None = None + network: Network | None = Field(None, description="Network to use. Defaults to the chain's configured default network.", examples=['mainnet-beta']) class StatusResponse(BaseModel): @@ -285,13 +291,13 @@ class ChainExecuteSwapResponseData(BaseModel): class WrapRequest(BaseModel): - network: str | None = None + network: Network | None = Field(None, description="Network to use. Defaults to the chain's configured default network.", examples=['mainnet-beta']) address: str = Field(..., description='Wallet address holding the native token') amount: str = Field(..., description='Amount of the native token to wrap, in whole units (not lamports/wei)', examples=['1.0']) class UnwrapRequest(BaseModel): - network: str | None = None + network: Network | None = Field(None, description="Network to use. Defaults to the chain's configured default network.", examples=['mainnet-beta']) address: str = Field(..., description='Wallet address holding the wrapped token') amount: str | None = Field(None, description='Amount of the wrapped token to unwrap, in whole units. Solana unwraps the full balance when omitted; EVM chains require it.', examples=['1.0']) @@ -504,17 +510,6 @@ class ClosePositionResponseData(BaseModel): quote_fee_amount_collected: Decimal = Field(..., alias='quoteFeeAmountCollected') -class ClmmCreatePoolRequest(BaseModel): - network: str | None = None - wallet_address: str | None = Field(None, alias='walletAddress') - base_token: str = Field(..., alias='baseToken') - quote_token: str = Field(..., alias='quoteToken') - initial_price: Decimal | None = Field(None, alias='initialPrice', description='Initial pool price as quote per base. If omitted, the current market price is fetched from the unified swap router so the pool opens on-market.') - bin_step: float | None = Field(None, alias='binStep', description='Bin/tick granularity: Meteora DLMM bin step (bps); Orca Whirlpool tick spacing.') - fee_bps: float | None = Field(None, alias='feeBps', description='Base fee in basis points: Meteora DLMM base fee; Uniswap/PancakeSwap V3 fee tier (1, 5, 30 or 100 bps; PancakeSwap also 25).') - amm_config_index: float | None = Field(None, alias='ammConfigIndex', description='Fee-config index for the Raydium CLMM family: Raydium API config list index; pancakeswap-sol amm_config PDA index. Default 0.') - - class ClmmCreatePoolResponseData(BaseModel): fee: Decimal @@ -562,17 +557,6 @@ class ClmmQuoteSwapResponse(BaseModel): price_impact_pct: Decimal = Field(..., alias='priceImpactPct') -class ClmmExecuteSwapRequest(BaseModel): - wallet_address: str | None = Field(None, alias='walletAddress') - network: str | None = None - pool_address: str | None = Field(None, alias='poolAddress', description='Pool address (optional - can be looked up from baseToken and quoteToken)') - base_token: str = Field(..., alias='baseToken') - quote_token: str | None = Field(None, alias='quoteToken', description='The other token in the pair (optional - required if poolAddress not provided)') - amount: Decimal - side: Side - slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct') - - class ClmmExecuteSwapResponseData(BaseModel): token_in: str = Field(..., alias='tokenIn') token_out: str = Field(..., alias='tokenOut') @@ -635,6 +619,250 @@ class SwapExecuteResponseData(BaseModel): slippage_pct: Decimal | None = Field(None, alias='slippagePct', description='Slippage tolerance percentage actually applied to the swap') +class Connector(StrEnum): + meteora = 'meteora' + raydium = 'raydium' + uniswap = 'uniswap' + pancakeswap = 'pancakeswap' + + +class AmmCreatePoolRequest(BaseModel): + connector: Connector = Field(..., description='AMM connector', examples=['meteora']) + chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) + wallet_address: str = Field(..., alias='walletAddress', description='Wallet address (pool creator + payer)') + base_token: str = Field(..., alias='baseToken', description='Base token symbol or address (becomes the pool base)') + quote_token: str = Field(..., alias='quoteToken', description='Quote token symbol or address (becomes the pool quote)') + base_token_amount: Decimal = Field(..., alias='baseTokenAmount', description='Amount of base token to seed the pool with') + quote_token_amount: Decimal | None = Field(None, alias='quoteTokenAmount', description='Amount of quote token to seed with. If provided, the base:quote ratio sets the initial price. If omitted (and no initialPrice), the price is fetched from the market.') + initial_price: Decimal | None = Field(None, alias='initialPrice', description='Initial price as quote per base. Overrides quoteTokenAmount. If both are omitted, the current market price is fetched from the unified swap router so the pool opens on-market.') + config_address: str | None = Field(None, alias='configAddress', description='Meteora DAMM v2 config account address (required for the meteora connector — configs are permissionless accounts with no index derivation, so the address must be explicit).') + amm_config_index: float | None = Field(None, alias='ammConfigIndex', description='Raydium CPMM fee-config index (optional; defaults to the first available config).') + slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct', description="Uniswap/PancakeSwap seeding slippage percentage. Defaults to the connector's configured slippagePct.", examples=[1]) + + +class AmmAddRequest(BaseModel): + connector: Connector = Field(..., description='AMM connector', examples=['meteora']) + chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) + wallet_address: str = Field(..., alias='walletAddress', description='Wallet address') + pool_address: str = Field(..., alias='poolAddress', description='Pool contract address') + base_token_amount: Decimal = Field(..., alias='baseTokenAmount', description='Amount of base token to add') + quote_token_amount: Decimal = Field(..., alias='quoteTokenAmount', description='Amount of quote token to add') + position_address: str | None = Field(None, alias='positionAddress', description='meteora only (DAMM v2 positions are NFTs): add to this specific position. Omit to open a new position. Ignored by fungible-LP AMMs.') + slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct', description="Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", examples=[1]) + + +class AmmRemoveRequest(BaseModel): + connector: Connector = Field(..., description='AMM connector', examples=['meteora']) + chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) + wallet_address: str = Field(..., alias='walletAddress', description='Wallet address') + pool_address: str = Field(..., alias='poolAddress', description='Pool contract address') + position_address: str | None = Field(None, alias='positionAddress', description='Required for meteora (DAMM v2 positions are NFTs): the specific position to remove from. List positions with position-info or positions-owned. Ignored by fungible-LP AMMs.') + percentage_to_remove: condecimal(ge=Decimal('0'), le=Decimal('100')) = Field(..., alias='percentageToRemove', description='Percentage of liquidity to remove', examples=[100]) + slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct', description="Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", examples=[1]) + + +class AmmOpenRequest(BaseModel): + connector: Connector = Field(..., description='AMM connector', examples=['meteora']) + chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) + wallet_address: str = Field(..., alias='walletAddress', description='Wallet that will own the position') + pool_address: str = Field(..., alias='poolAddress', description='Pool to open the position in') + base_token_amount: Decimal = Field(..., alias='baseTokenAmount', description='Amount of base token to deposit') + quote_token_amount: Decimal = Field(..., alias='quoteTokenAmount', description='Amount of quote token to deposit') + slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct', description="Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", examples=[1]) + + +class AmmCloseRequest(BaseModel): + connector: Connector = Field(..., description='AMM connector', examples=['meteora']) + chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) + wallet_address: str = Field(..., alias='walletAddress', description='Wallet that owns the position') + pool_address: str = Field(..., alias='poolAddress', description='Pool the position belongs to') + position_address: str | None = Field(None, alias='positionAddress', description='Position to close. Required on AMMs whose positions are discrete accounts (meteora DAMM v2), where a wallet may hold several per pool. Ignored by fungible-LP AMMs, which hold one LP balance per pool.') + slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct', description='Maximum acceptable slippage on the withdrawn amounts.', examples=[1]) + + +class Connector5(StrEnum): + meteora = 'meteora' + raydium = 'raydium' + pancakeswap_sol = 'pancakeswap-sol' + orca = 'orca' + uniswap = 'uniswap' + pancakeswap = 'pancakeswap' + + +class ClmmOpenRequest(BaseModel): + connector: Connector5 = Field(..., description='CLMM connector', examples=['meteora']) + chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) + wallet_address: str = Field(..., alias='walletAddress', description='Wallet address') + lower_price: Decimal = Field(..., alias='lowerPrice', description='Lower price bound for the position', examples=[150]) + upper_price: Decimal = Field(..., alias='upperPrice', description='Upper price bound for the position', examples=[250]) + pool_address: str = Field(..., alias='poolAddress', description='Pool address', examples=['2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3']) + base_token_amount: Decimal | None = Field(None, alias='baseTokenAmount', description='Amount of base token to deposit', examples=[0.01]) + quote_token_amount: Decimal | None = Field(None, alias='quoteTokenAmount', description='Amount of quote token to deposit', examples=[2]) + slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct', description="Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", examples=[1]) + strategy_type: float | None = Field(None, alias='strategyType', description='Strategy type for Meteora positions (0=Spot, 1=Curve). Only applies to Meteora connector.', examples=[0]) + + +class ClmmAddRequest(BaseModel): + connector: Connector5 = Field(..., description='CLMM connector', examples=['meteora']) + chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) + wallet_address: str = Field(..., alias='walletAddress', description='Wallet address') + position_address: str = Field(..., alias='positionAddress', description='Position address', examples=['']) + base_token_amount: Decimal | None = Field(None, alias='baseTokenAmount', description='Amount of base token to deposit (omit for single-sided quote deposit)', examples=[0.01]) + quote_token_amount: Decimal | None = Field(None, alias='quoteTokenAmount', description='Amount of quote token to deposit (omit for single-sided base deposit)', examples=[2]) + slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct', description="Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", examples=[1]) + strategy_type: float | None = Field(None, alias='strategyType', description='Strategy type for Meteora positions (0=Spot, 1=Curve). Only applies to Meteora connector.', examples=[0]) + + +class ClmmRemoveRequest(BaseModel): + connector: Connector5 = Field(..., description='CLMM connector', examples=['meteora']) + chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) + wallet_address: str = Field(..., alias='walletAddress', description='Wallet address') + position_address: str = Field(..., alias='positionAddress', description='Position address', examples=['']) + percentage_to_remove: condecimal(ge=Decimal('0'), le=Decimal('100')) = Field(..., alias='percentageToRemove', description='Percentage of liquidity to remove', examples=[100]) + slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct', description="Maximum acceptable slippage percentage. Only applies to the Orca connector; defaults to Orca's configured slippagePct.", examples=[1]) + + +class ClmmCollectFeesRequest(BaseModel): + connector: Connector5 = Field(..., description='CLMM connector', examples=['meteora']) + chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) + wallet_address: str = Field(..., alias='walletAddress', description='Wallet address') + position_address: str = Field(..., alias='positionAddress', description='Position address', examples=['']) + + +class ClmmCloseRequest(BaseModel): + connector: Connector5 = Field(..., description='CLMM connector', examples=['meteora']) + chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) + wallet_address: str = Field(..., alias='walletAddress', description='Wallet address') + position_address: str = Field(..., alias='positionAddress', description='Position address', examples=['']) + + +class ClmmCreatePoolRequest(BaseModel): + connector: Connector5 = Field(..., description='CLMM connector', examples=['meteora']) + chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) + wallet_address: str = Field(..., alias='walletAddress', description='Wallet address (pool creator + payer)') + base_token: str = Field(..., alias='baseToken') + quote_token: str = Field(..., alias='quoteToken') + initial_price: Decimal | None = Field(None, alias='initialPrice', description='Initial pool price as quote per base. If omitted, the current market price is fetched from the unified swap router so the pool opens on-market.') + bin_step: float | None = Field(None, alias='binStep', description='Bin/tick granularity: Meteora DLMM bin step (bps); Orca Whirlpool tick spacing.') + fee_bps: float | None = Field(None, alias='feeBps', description='Base fee in basis points: Meteora DLMM base fee; Uniswap/PancakeSwap V3 fee tier (1, 5, 30 or 100 bps; PancakeSwap also 25).') + amm_config_index: float | None = Field(None, alias='ammConfigIndex', description='Fee-config index for the Raydium CLMM family: Raydium API config list index; pancakeswap-sol amm_config PDA index. Default 0.') + + +class Connector11(StrEnum): + jupiter = 'jupiter' + dflow = 'dflow' + okx = 'okx' + titan = 'titan' + uniswap = 'uniswap' + pancakeswap = 'pancakeswap' + field_0x = '0x' + + +class RouterExecuteQuoteRequest(BaseModel): + chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) + connector: Connector11 | None = Field('jupiter', description="Router connector. Defaults to the network's swapProvider", examples=['jupiter']) + wallet_address: str = Field(..., alias='walletAddress', description='Wallet address that will execute the quote') + quote_id: str = Field(..., alias='quoteId', description='ID of a quote returned by /trading/router/quote-swap') + + +class RouterExecuteSwapRequest(BaseModel): + chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) + connector: Connector11 | None = Field('jupiter', description="Router connector. Defaults to the network's swapProvider", examples=['jupiter']) + wallet_address: str = Field(..., alias='walletAddress', description='Wallet address that will execute the swap') + base_token: str = Field(..., alias='baseToken', description='Symbol or address of the base token') + quote_token: str = Field(..., alias='quoteToken', description='Symbol or address of the quote token') + amount: Decimal = Field(..., description='Amount of base token to trade') + side: Side = Field(..., description='BUY means buying base token with quote token, SELL means selling base token for quote token') + slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct', description="Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", examples=[1]) + approximate_if_no_exact_out: bool | None = Field(True, alias='approximateIfNoExactOut', description='For BUY orders when the router has no ExactOut route: approximate via a sell-leg ExactIn swap instead of failing.') + + +class Connector13(StrEnum): + meteora = 'meteora' + raydium = 'raydium' + uniswap = 'uniswap' + pancakeswap = 'pancakeswap' + + +class AmmExecuteSwapRequest(BaseModel): + chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) + connector: Connector13 | None = Field('meteora', description='AMM connector to execute the swap against', examples=['meteora']) + wallet_address: str = Field(..., alias='walletAddress', description='Wallet address that will execute the swap') + base_token: str = Field(..., alias='baseToken', description='Symbol or address of the base token') + quote_token: str = Field(..., alias='quoteToken', description='Symbol or address of the quote token') + amount: Decimal = Field(..., description='Amount of base token to trade') + side: Side = Field(..., description='BUY means buying base token with quote token, SELL means selling base token for quote token') + pool_address: str | None = Field(None, alias='poolAddress', description="Pool to trade against. Omit to resolve it from Gateway's configured pool list by token pair; pass an address to pin a pool that is not in that list.") + slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct', description="Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", examples=[1]) + + +class Connector14(StrEnum): + meteora = 'meteora' + raydium = 'raydium' + orca = 'orca' + pancakeswap_sol = 'pancakeswap-sol' + uniswap = 'uniswap' + pancakeswap = 'pancakeswap' + + +class ClmmExecuteSwapRequest(BaseModel): + chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) + connector: Connector14 | None = Field('meteora', description='CLMM connector to execute the swap against', examples=['meteora']) + wallet_address: str = Field(..., alias='walletAddress', description='Wallet address that will execute the swap') + base_token: str = Field(..., alias='baseToken', description='Symbol or address of the base token') + quote_token: str = Field(..., alias='quoteToken', description='Symbol or address of the quote token') + amount: Decimal = Field(..., description='Amount of base token to trade') + side: Side = Field(..., description='BUY means buying base token with quote token, SELL means selling base token for quote token') + pool_address: str | None = Field(None, alias='poolAddress', description="Pool to trade against. Omit to resolve it from Gateway's configured pool list by token pair; pass an address to pin a pool that is not in that list.") + slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct', description="Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", examples=[1]) + + +class Network7(StrEnum): + arbitrum = 'arbitrum' + avalanche = 'avalanche' + base = 'base' + bsc = 'bsc' + celo = 'celo' + mainnet = 'mainnet' + optimism = 'optimism' + polygon = 'polygon' + robinhoodchain_testnet = 'robinhoodchain-testnet' + robinhoodchain = 'robinhoodchain' + sepolia = 'sepolia' + unichain = 'unichain' + + +class AllowancesRequest(BaseModel): + network: Network7 | None = Field('mainnet', description='The Ethereum network to use') + address: str | None = Field('0xDA50C69342216b538Daf06FfECDa7363E0B96684', description='Ethereum wallet address') + spender: str = Field(..., description='Connector name (e.g., uniswap/clmm, uniswap/amm, 0x/router) or contract address', examples=['uniswap/router']) + tokens: list[str] = Field(..., description='Array of token symbols or addresses', examples=[['USDC', 'WETH']]) + + +class ApproveRequest(BaseModel): + network: Network7 | None = Field('mainnet', description='The Ethereum network to use') + address: str | None = Field('0xDA50C69342216b538Daf06FfECDa7363E0B96684', description='Ethereum wallet address') + spender: str = Field(..., description='Connector name (e.g., uniswap/clmm, uniswap/amm, 0x/router) contract address', examples=['uniswap/router']) + token: str = Field(..., description='Token symbol or address', examples=['USDC']) + amount: str | None = Field('', description='The amount to approve. If not provided, defaults to maximum amount (unlimited approval).') + + +class Chain(StrEnum): + ethereum = 'ethereum' + solana = 'solana' + + +class RemoveWalletRequest(BaseModel): + chain: Chain = Field(..., description='Blockchain to remove wallet from', examples=['solana']) + address: str = Field(..., description='Wallet address to remove') + + +class AddHardwareWalletRequest(BaseModel): + chain: Chain = Field(..., description='Blockchain for hardware wallet', examples=['solana']) + address: str = Field(..., description='Hardware wallet address to add (must exist on connected Ledger device)') + set_default: bool | None = Field(False, alias='setDefault', description='Set this wallet as the default for the chain') + + class AmmAddLiquidityResponse(BaseModel): signature: str status: float = Field(..., description='TransactionStatus enum value') From 61f9afadd730257a13114f6560ccbee3eeb7571d Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Wed, 19 Aug 2026 17:35:20 -0700 Subject: [PATCH 22/54] chore(gateway): adopt the read request components (GW-10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gateway's GET routes now publish a component matching their query, and 28 pre-refactor bases that were holding those names lost their $id. The generated models drop from 110 classes to 93, and the ones a read would reach for — ClmmQuoteSwapRequest, ClmmFetchPoolsRequest — now carry connector and chainNetwork rather than the per-connector network they had before. Contract checks pass unchanged. --- gateway-openapi.json | 2530 +++++++++++++---------------------- models/gateway_generated.py | 479 +++---- 2 files changed, 1127 insertions(+), 1882 deletions(-) diff --git a/gateway-openapi.json b/gateway-openapi.json index f674df0b..59d3592d 100644 --- a/gateway-openapi.json +++ b/gateway-openapi.json @@ -55,53 +55,6 @@ "quoteTokenAmount" ] }, - "AmmGetPoolInfoRequest": { - "type": "object", - "properties": { - "network": { - "type": "string" - }, - "poolAddress": { - "type": "string" - } - }, - "required": [ - "poolAddress" - ] - }, - "AmmAddLiquidityRequest": { - "type": "object", - "properties": { - "network": { - "type": "string" - }, - "walletAddress": { - "type": "string" - }, - "poolAddress": { - "type": "string" - }, - "baseTokenAmount": { - "format": "decimal", - "type": "number" - }, - "quoteTokenAmount": { - "format": "decimal", - "type": "number" - }, - "slippagePct": { - "format": "decimal", - "minimum": 0, - "maximum": 100, - "type": "number" - } - }, - "required": [ - "poolAddress", - "baseTokenAmount", - "quoteTokenAmount" - ] - }, "AmmAddLiquidityResponse": { "type": "object", "properties": { @@ -275,36 +228,6 @@ "quoteTokenAmountRemoved" ] }, - "QuoteLiquidityRequest": { - "type": "object", - "properties": { - "network": { - "type": "string" - }, - "poolAddress": { - "type": "string" - }, - "baseTokenAmount": { - "format": "decimal", - "type": "number" - }, - "quoteTokenAmount": { - "format": "decimal", - "type": "number" - }, - "slippagePct": { - "format": "decimal", - "minimum": 0, - "maximum": 100, - "type": "number" - } - }, - "required": [ - "poolAddress", - "baseTokenAmount", - "quoteTokenAmount" - ] - }, "QuoteLiquidityResponse": { "type": "object", "properties": { @@ -340,30 +263,6 @@ "quoteTokenAmountMax" ] }, - "AmmRemoveLiquidityRequest": { - "type": "object", - "properties": { - "network": { - "type": "string" - }, - "walletAddress": { - "type": "string" - }, - "poolAddress": { - "type": "string" - }, - "percentageToRemove": { - "format": "decimal", - "minimum": 0, - "maximum": 100, - "type": "number" - } - }, - "required": [ - "poolAddress", - "percentageToRemove" - ] - }, "AmmRemoveLiquidityResponse": { "type": "object", "properties": { @@ -416,45 +315,6 @@ "quoteTokenAmountRemoved" ] }, - "CreatePoolRequest": { - "type": "object", - "properties": { - "network": { - "type": "string" - }, - "walletAddress": { - "type": "string" - }, - "baseToken": { - "description": "Base token symbol or address (becomes the pool base)", - "type": "string" - }, - "quoteToken": { - "description": "Quote token symbol or address (becomes the pool quote)", - "type": "string" - }, - "baseTokenAmount": { - "format": "decimal", - "description": "Amount of base token to seed the pool with", - "type": "number" - }, - "quoteTokenAmount": { - "format": "decimal", - "description": "Amount of quote token to seed with. If provided, the base:quote ratio sets the initial price. If omitted (and no initialPrice), the price is fetched from the market.", - "type": "number" - }, - "initialPrice": { - "format": "decimal", - "description": "Initial price as quote per base. Overrides quoteTokenAmount. If both are omitted, the current market price is fetched from the unified swap router so the pool opens on-market.", - "type": "number" - } - }, - "required": [ - "baseToken", - "quoteToken", - "baseTokenAmount" - ] - }, "CreatePoolResponse": { "type": "object", "properties": { @@ -583,261 +443,84 @@ "price" ] }, - "AmmGetPositionInfoRequest": { + "EstimateGasRequest": { "type": "object", "properties": { "network": { - "type": "string" - }, - "poolAddress": { - "type": "string" - }, - "walletAddress": { - "type": "string" + "description": "Network to use. Defaults to the chain's configured default network.", + "enum": [ + "devnet", + "mainnet-beta", + "arbitrum", + "avalanche", + "base", + "bsc", + "celo", + "mainnet", + "optimism", + "polygon", + "robinhoodchain-testnet", + "robinhoodchain", + "sepolia", + "unichain" + ], + "type": "string", + "example": "mainnet-beta" } - }, - "required": [ - "poolAddress" - ] + } }, - "AmmQuoteSwapRequest": { + "EstimateGasResponse": { "type": "object", "properties": { - "network": { - "type": "string" - }, - "poolAddress": { - "description": "Pool address (optional - can be looked up from baseToken and quoteToken)", - "type": "string" - }, - "baseToken": { - "description": "Token to determine swap direction", - "type": "string" - }, - "quoteToken": { - "description": "The other token in the pair (optional - required if poolAddress not provided)", - "type": "string" - }, - "amount": { + "feePerComputeUnit": { "format": "decimal", "type": "number" }, - "side": { - "description": "Trade direction", - "enum": [ - "BUY", - "SELL" - ], + "denomination": { "type": "string" }, - "slippagePct": { - "format": "decimal", - "minimum": 0, - "maximum": 100, + "computeUnits": { "type": "number" - } - }, - "required": [ - "baseToken", - "amount", - "side" - ] - }, - "AmmQuoteSwapResponse": { - "type": "object", - "properties": { - "poolAddress": { - "type": "string" - }, - "tokenIn": { - "type": "string" }, - "tokenOut": { + "feeAsset": { "type": "string" }, - "amountIn": { + "fee": { "format": "decimal", "type": "number" }, - "amountOut": { - "format": "decimal", + "timestamp": { "type": "number" }, - "price": { - "format": "decimal", - "type": "number" + "gasType": { + "type": "string" }, - "slippagePct": { + "maxFeePerGas": { "format": "decimal", "type": "number" }, - "minAmountOut": { + "maxPriorityFeePerGas": { "format": "decimal", "type": "number" }, - "maxAmountIn": { - "format": "decimal", - "type": "number" + "priorityFeeLevel": { + "type": "string" }, - "priceImpactPct": { + "priorityFeePerCUEstimate": { "format": "decimal", "type": "number" } }, "required": [ - "poolAddress", - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "price", - "minAmountOut", - "maxAmountIn", - "priceImpactPct" + "feePerComputeUnit", + "denomination", + "computeUnits", + "feeAsset", + "fee", + "timestamp" ] }, - "AmmExecuteSwapResponse": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "$ref": "#/components/schemas/AmmExecuteSwapResponseData" - } - }, - "required": [ - "signature", - "status" - ] - }, - "AmmExecuteSwapResponseData": { - "type": "object", - "properties": { - "tokenIn": { - "type": "string" - }, - "tokenOut": { - "type": "string" - }, - "amountIn": { - "format": "decimal", - "type": "number" - }, - "amountOut": { - "format": "decimal", - "type": "number" - }, - "fee": { - "format": "decimal", - "type": "number" - }, - "baseTokenBalanceChange": { - "format": "decimal", - "type": "number" - }, - "quoteTokenBalanceChange": { - "format": "decimal", - "type": "number" - }, - "slippagePct": { - "format": "decimal", - "description": "Slippage tolerance percentage actually applied to the swap", - "type": "number" - } - }, - "required": [ - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "fee", - "baseTokenBalanceChange", - "quoteTokenBalanceChange" - ] - }, - "EstimateGasRequest": { - "type": "object", - "properties": { - "network": { - "description": "Network to use. Defaults to the chain's configured default network.", - "enum": [ - "devnet", - "mainnet-beta", - "arbitrum", - "avalanche", - "base", - "bsc", - "celo", - "mainnet", - "optimism", - "polygon", - "robinhoodchain-testnet", - "robinhoodchain", - "sepolia", - "unichain" - ], - "type": "string", - "example": "mainnet-beta" - } - } - }, - "EstimateGasResponse": { - "type": "object", - "properties": { - "feePerComputeUnit": { - "format": "decimal", - "type": "number" - }, - "denomination": { - "type": "string" - }, - "computeUnits": { - "type": "number" - }, - "feeAsset": { - "type": "string" - }, - "fee": { - "format": "decimal", - "type": "number" - }, - "timestamp": { - "type": "number" - }, - "gasType": { - "type": "string" - }, - "maxFeePerGas": { - "format": "decimal", - "type": "number" - }, - "maxPriorityFeePerGas": { - "format": "decimal", - "type": "number" - }, - "priorityFeeLevel": { - "type": "string" - }, - "priorityFeePerCUEstimate": { - "format": "decimal", - "type": "number" - } - }, - "required": [ - "feePerComputeUnit", - "denomination", - "computeUnits", - "feeAsset", - "fee", - "timestamp" - ] - }, - "BalanceRequest": { + "BalanceRequest": { "type": "object", "properties": { "network": { @@ -891,79 +574,6 @@ "balances" ] }, - "TokensRequest": { - "type": "object", - "properties": { - "network": { - "description": "Network to use. Defaults to the chain's configured default network.", - "enum": [ - "devnet", - "mainnet-beta", - "arbitrum", - "avalanche", - "base", - "bsc", - "celo", - "mainnet", - "optimism", - "polygon", - "robinhoodchain-testnet", - "robinhoodchain", - "sepolia", - "unichain" - ], - "type": "string", - "example": "mainnet-beta" - }, - "tokenSymbols": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - } - } - }, - "TokensResponse": { - "type": "object", - "properties": { - "tokens": { - "type": "array", - "items": { - "type": "object", - "properties": { - "symbol": { - "type": "string" - }, - "address": { - "type": "string" - }, - "decimals": { - "type": "number" - }, - "name": { - "type": "string" - } - }, - "required": [ - "symbol", - "address", - "decimals", - "name" - ] - } - } - }, - "required": [ - "tokens" - ] - }, "PollRequest": { "type": "object", "properties": { @@ -1468,30 +1078,6 @@ "quoteId" ] }, - "FetchPoolsRequest": { - "type": "object", - "properties": { - "network": { - "description": "Network to use", - "type": "string" - }, - "limit": { - "minimum": 1, - "maximum": 100, - "default": 50, - "description": "Maximum number of pools to return", - "type": "number" - }, - "query": { - "description": "Search query to match pools by name, tokens, or address", - "type": "string" - }, - "sortBy": { - "description": "Sort by field (connector-specific)", - "type": "string" - } - } - }, "PoolListItem": { "type": "object", "properties": { @@ -1601,20 +1187,6 @@ "pageSize" ] }, - "GetPositionsOwnedRequest": { - "type": "object", - "properties": { - "network": { - "type": "string" - }, - "walletAddress": { - "type": "string" - } - }, - "required": [ - "walletAddress" - ] - }, "BinLiquidity": { "type": "object", "properties": { @@ -1693,216 +1265,73 @@ "activeBinId" ] }, - "MeteoraPoolInfo": { + "PositionInfo": { "type": "object", "properties": { "address": { "type": "string" }, + "poolAddress": { + "type": "string" + }, "baseTokenAddress": { "type": "string" }, "quoteTokenAddress": { "type": "string" }, - "binStep": { - "type": "number" - }, - "feePct": { + "baseTokenAmount": { "format": "decimal", "type": "number" }, - "price": { + "quoteTokenAmount": { "format": "decimal", "type": "number" }, - "baseTokenAmount": { + "baseFeeAmount": { "format": "decimal", "type": "number" }, - "quoteTokenAmount": { + "quoteFeeAmount": { "format": "decimal", "type": "number" }, - "activeBinId": { + "lowerBinId": { "type": "number" }, - "bins": { - "type": "array", - "items": { - "$ref": "#/components/schemas/BinLiquidity" - } + "upperBinId": { + "type": "number" }, - "dynamicFeePct": { + "lowerPrice": { + "format": "decimal", "type": "number" }, - "minBinId": { + "upperPrice": { + "format": "decimal", "type": "number" }, - "maxBinId": { + "price": { + "format": "decimal", "type": "number" } }, "required": [ "address", + "poolAddress", "baseTokenAddress", "quoteTokenAddress", - "feePct", - "price", "baseTokenAmount", "quoteTokenAmount", - "activeBinId", - "dynamicFeePct", - "minBinId", - "maxBinId" + "baseFeeAmount", + "quoteFeeAmount", + "lowerBinId", + "upperBinId", + "lowerPrice", + "upperPrice", + "price" ] }, - "GetPoolInfoRequest": { - "type": "object", - "properties": { - "network": { - "type": "string" - }, - "poolAddress": { - "type": "string" - }, - "binCount": { - "description": "If > 0, include a `bins` array in the response (per-tickSpacing token amounts around the active tick, mirroring Meteora pool-info.bins[]). Default 0 = skip the bin fetch.", - "default": 0, - "minimum": 0, - "maximum": 401, - "type": "integer" - } - }, - "required": [ - "poolAddress" - ] - }, - "PositionInfo": { - "type": "object", - "properties": { - "address": { - "type": "string" - }, - "poolAddress": { - "type": "string" - }, - "baseTokenAddress": { - "type": "string" - }, - "quoteTokenAddress": { - "type": "string" - }, - "baseTokenAmount": { - "format": "decimal", - "type": "number" - }, - "quoteTokenAmount": { - "format": "decimal", - "type": "number" - }, - "baseFeeAmount": { - "format": "decimal", - "type": "number" - }, - "quoteFeeAmount": { - "format": "decimal", - "type": "number" - }, - "lowerBinId": { - "type": "number" - }, - "upperBinId": { - "type": "number" - }, - "lowerPrice": { - "format": "decimal", - "type": "number" - }, - "upperPrice": { - "format": "decimal", - "type": "number" - }, - "price": { - "format": "decimal", - "type": "number" - } - }, - "required": [ - "address", - "poolAddress", - "baseTokenAddress", - "quoteTokenAddress", - "baseTokenAmount", - "quoteTokenAmount", - "baseFeeAmount", - "quoteFeeAmount", - "lowerBinId", - "upperBinId", - "lowerPrice", - "upperPrice", - "price" - ] - }, - "GetPositionInfoRequest": { - "type": "object", - "properties": { - "network": { - "type": "string" - }, - "positionAddress": { - "type": "string" - }, - "walletAddress": { - "type": "string" - } - }, - "required": [ - "positionAddress" - ] - }, - "OpenPositionRequest": { - "type": "object", - "properties": { - "network": { - "type": "string" - }, - "walletAddress": { - "type": "string" - }, - "lowerPrice": { - "format": "decimal", - "type": "number" - }, - "upperPrice": { - "format": "decimal", - "type": "number" - }, - "poolAddress": { - "type": "string" - }, - "baseTokenAmount": { - "format": "decimal", - "type": "number" - }, - "quoteTokenAmount": { - "format": "decimal", - "type": "number" - }, - "slippagePct": { - "format": "decimal", - "minimum": 0, - "maximum": 100, - "type": "number" - } - }, - "required": [ - "lowerPrice", - "upperPrice", - "poolAddress" - ] - }, - "OpenPositionResponse": { + "OpenPositionResponse": { "type": "object", "properties": { "signature": { @@ -1956,39 +1385,6 @@ "quoteTokenAmountAdded" ] }, - "AddLiquidityRequest": { - "type": "object", - "properties": { - "network": { - "type": "string" - }, - "walletAddress": { - "type": "string" - }, - "positionAddress": { - "type": "string" - }, - "baseTokenAmount": { - "format": "decimal", - "type": "number" - }, - "quoteTokenAmount": { - "format": "decimal", - "type": "number" - }, - "slippagePct": { - "format": "decimal", - "minimum": 0, - "maximum": 100, - "type": "number" - } - }, - "required": [ - "positionAddress", - "baseTokenAmount", - "quoteTokenAmount" - ] - }, "AddLiquidityResponse": { "type": "object", "properties": { @@ -2038,30 +1434,6 @@ "quoteTokenAmountAdded" ] }, - "RemoveLiquidityRequest": { - "type": "object", - "properties": { - "network": { - "type": "string" - }, - "walletAddress": { - "type": "string" - }, - "positionAddress": { - "type": "string" - }, - "percentageToRemove": { - "format": "decimal", - "minimum": 0, - "maximum": 100, - "type": "number" - } - }, - "required": [ - "positionAddress", - "percentageToRemove" - ] - }, "RemoveLiquidityResponse": { "type": "object", "properties": { @@ -2111,23 +1483,6 @@ "quoteTokenAmountRemoved" ] }, - "CollectFeesRequest": { - "type": "object", - "properties": { - "network": { - "type": "string" - }, - "walletAddress": { - "type": "string" - }, - "positionAddress": { - "type": "string" - } - }, - "required": [ - "positionAddress" - ] - }, "CollectFeesResponse": { "type": "object", "properties": { @@ -2177,23 +1532,6 @@ "quoteFeeAmountCollected" ] }, - "ClosePositionRequest": { - "type": "object", - "properties": { - "network": { - "type": "string" - }, - "walletAddress": { - "type": "string" - }, - "positionAddress": { - "type": "string" - } - }, - "required": [ - "positionAddress" - ] - }, "ClosePositionResponse": { "type": "object", "properties": { @@ -2299,265 +1637,343 @@ "fee" ] }, - "QuotePositionRequest": { + "QuotePositionResponse": { "type": "object", "properties": { - "network": { + "poolAddress": { + "description": "Pool the quote was computed against", "type": "string" }, - "lowerPrice": { - "format": "decimal", - "type": "number" + "baseLimited": { + "type": "boolean" }, - "upperPrice": { + "baseTokenAmount": { "format": "decimal", "type": "number" }, - "poolAddress": { - "type": "string" - }, - "baseTokenAmount": { + "quoteTokenAmount": { "format": "decimal", "type": "number" }, - "quoteTokenAmount": { + "baseTokenAmountMax": { "format": "decimal", "type": "number" }, - "slippagePct": { + "quoteTokenAmountMax": { "format": "decimal", - "minimum": 0, - "maximum": 100, "type": "number" - } + }, + "liquidity": {} }, "required": [ - "lowerPrice", - "upperPrice", - "poolAddress" + "baseLimited", + "baseTokenAmount", + "quoteTokenAmount", + "baseTokenAmountMax", + "quoteTokenAmountMax" ] }, - "QuotePositionResponse": { + "AmmCreatePoolRequest": { "type": "object", "properties": { - "poolAddress": { - "description": "Pool the quote was computed against", + "connector": { + "description": "AMM connector", + "enum": [ + "meteora", + "raydium", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" + }, + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "walletAddress": { + "description": "Wallet address (pool creator + payer)", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string" }, - "baseLimited": { - "type": "boolean" + "baseToken": { + "description": "Base token symbol or address (becomes the pool base)", + "type": "string" + }, + "quoteToken": { + "description": "Quote token symbol or address (becomes the pool quote)", + "type": "string" }, "baseTokenAmount": { "format": "decimal", + "description": "Amount of base token to seed the pool with", "type": "number" }, "quoteTokenAmount": { "format": "decimal", + "description": "Amount of quote token to seed with. If provided, the base:quote ratio sets the initial price. If omitted (and no initialPrice), the price is fetched from the market.", "type": "number" }, - "baseTokenAmountMax": { + "initialPrice": { "format": "decimal", + "description": "Initial price as quote per base. Overrides quoteTokenAmount. If both are omitted, the current market price is fetched from the unified swap router so the pool opens on-market.", "type": "number" }, - "quoteTokenAmountMax": { - "format": "decimal", + "configAddress": { + "x-connectors": [ + "meteora" + ], + "description": "Meteora DAMM v2 config account address (required for the meteora connector — configs are permissionless accounts with no index derivation, so the address must be explicit).", + "type": "string" + }, + "ammConfigIndex": { + "x-connectors": [ + "raydium" + ], + "description": "Raydium CPMM fee-config index (optional; defaults to the first available config).", "type": "number" }, - "liquidity": {} + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Uniswap/PancakeSwap seeding slippage percentage. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 + } }, "required": [ - "baseLimited", - "baseTokenAmount", - "quoteTokenAmount", - "baseTokenAmountMax", - "quoteTokenAmountMax" + "connector", + "chainNetwork", + "walletAddress", + "baseToken", + "quoteToken", + "baseTokenAmount" ] }, - "ClmmQuoteSwapRequest": { + "AmmAddRequest": { "type": "object", "properties": { - "network": { - "type": "string" + "connector": { + "description": "AMM connector", + "enum": [ + "meteora", + "raydium", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" }, - "poolAddress": { - "description": "Pool address (optional - can be looked up from baseToken and quoteToken)", - "type": "string" + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" }, - "baseToken": { - "description": "Token to determine swap direction", + "walletAddress": { + "description": "Wallet address", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string" }, - "quoteToken": { - "description": "The other token in the pair (optional - required if poolAddress not provided)", + "poolAddress": { + "description": "Pool contract address", "type": "string" }, - "amount": { + "baseTokenAmount": { "format": "decimal", + "description": "Amount of base token to add", "type": "number" }, - "side": { - "description": "Trade direction", - "enum": [ - "BUY", - "SELL" + "quoteTokenAmount": { + "format": "decimal", + "description": "Amount of quote token to add", + "type": "number" + }, + "positionAddress": { + "x-connectors": [ + "meteora" ], + "description": "meteora only (DAMM v2 positions are NFTs): add to this specific position. Omit to open a new position. Ignored by fungible-LP AMMs.", "type": "string" }, "slippagePct": { "format": "decimal", "minimum": 0, "maximum": 100, - "type": "number" + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 } }, "required": [ - "baseToken", - "amount", - "side" + "connector", + "chainNetwork", + "walletAddress", + "poolAddress", + "baseTokenAmount", + "quoteTokenAmount" ] }, - "ClmmQuoteSwapResponse": { + "AmmRemoveRequest": { "type": "object", "properties": { - "poolAddress": { - "type": "string" + "connector": { + "description": "AMM connector", + "enum": [ + "meteora", + "raydium", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" }, - "tokenIn": { - "type": "string" + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" }, - "tokenOut": { + "walletAddress": { + "description": "Wallet address", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string" }, - "amountIn": { - "format": "decimal", - "type": "number" + "poolAddress": { + "description": "Pool contract address", + "type": "string" }, - "amountOut": { - "format": "decimal", - "type": "number" + "positionAddress": { + "x-connectors": [ + "meteora" + ], + "description": "Required for meteora (DAMM v2 positions are NFTs): the specific position to remove from. List positions with position-info or positions-owned. Ignored by fungible-LP AMMs.", + "type": "string" }, - "price": { + "percentageToRemove": { "format": "decimal", - "type": "number" + "minimum": 0, + "maximum": 100, + "description": "Percentage of liquidity to remove", + "default": 100, + "type": "number", + "example": 100 }, "slippagePct": { "format": "decimal", - "type": "number" - }, - "minAmountOut": { - "format": "decimal", - "type": "number" - }, - "maxAmountIn": { - "format": "decimal", - "type": "number" - }, - "priceImpactPct": { - "format": "decimal", - "type": "number" + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 } }, "required": [ + "connector", + "chainNetwork", + "walletAddress", "poolAddress", - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "price", - "minAmountOut", - "maxAmountIn", - "priceImpactPct" + "percentageToRemove" ] }, - "ClmmExecuteSwapResponse": { + "AmmOpenRequest": { "type": "object", "properties": { - "signature": { - "type": "string" + "connector": { + "description": "AMM connector", + "enum": [ + "meteora", + "raydium", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" }, - "data": { - "$ref": "#/components/schemas/ClmmExecuteSwapResponseData" - } - }, - "required": [ - "signature", - "status" - ] - }, - "ClmmExecuteSwapResponseData": { - "type": "object", - "properties": { - "tokenIn": { + "walletAddress": { + "description": "Wallet that will own the position", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string" }, - "tokenOut": { + "poolAddress": { + "description": "Pool to open the position in", "type": "string" }, - "amountIn": { - "format": "decimal", - "type": "number" - }, - "amountOut": { - "format": "decimal", - "type": "number" - }, - "fee": { - "format": "decimal", - "type": "number" - }, - "baseTokenBalanceChange": { + "baseTokenAmount": { "format": "decimal", + "description": "Amount of base token to deposit", "type": "number" }, - "quoteTokenBalanceChange": { + "quoteTokenAmount": { "format": "decimal", + "description": "Amount of quote token to deposit", "type": "number" }, "slippagePct": { "format": "decimal", - "description": "Slippage tolerance percentage actually applied to the swap", - "type": "number" + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 } }, "required": [ - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "fee", - "baseTokenBalanceChange", - "quoteTokenBalanceChange" + "connector", + "chainNetwork", + "walletAddress", + "poolAddress", + "baseTokenAmount", + "quoteTokenAmount" ] }, - "QuoteSwapRequest": { + "AmmCloseRequest": { "type": "object", "properties": { - "network": { - "description": "The blockchain network to use", - "type": "string" + "connector": { + "description": "AMM connector", + "enum": [ + "meteora", + "raydium", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" }, - "baseToken": { - "description": "Token to determine swap direction", - "type": "string" + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" }, - "quoteToken": { - "description": "The other token in the pair", + "walletAddress": { + "description": "Wallet that owns the position", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string" }, - "amount": { - "format": "decimal", - "description": "Amount of base token to trade", - "type": "number" + "poolAddress": { + "description": "Pool the position belongs to", + "type": "string" }, - "side": { - "description": "Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token", - "enum": [ - "BUY", - "SELL" + "positionAddress": { + "description": "Position to close. Required on AMMs whose positions are discrete accounts (meteora DAMM v2), where a wallet may hold several per pool. Ignored by fungible-LP AMMs, which hold one LP balance per pool.", + "x-connectors": [ + "meteora" ], "type": "string" }, @@ -2565,236 +1981,266 @@ "format": "decimal", "minimum": 0, "maximum": 100, - "description": "Maximum acceptable slippage percentage", - "type": "number" - }, - "approximateIfNoExactOut": { - "description": "For BUY orders on routers without ExactOut support: approximate the required input via a sell-leg quote and return an ExactIn quote flagged as an approximation. If false, such BUY requests fail with a clear error.", - "default": true, - "type": "boolean" + "description": "Maximum acceptable slippage on the withdrawn amounts.", + "type": "number", + "example": 1 } }, "required": [ - "baseToken", - "quoteToken", - "amount", - "side" + "connector", + "chainNetwork", + "walletAddress", + "poolAddress" ] }, - "QuoteSwapResponse": { + "AmmPoolInfoRequest": { "type": "object", "properties": { - "quoteId": { - "description": "Unique identifier for this quote", - "type": "string" + "connector": { + "description": "AMM connector", + "enum": [ + "meteora", + "raydium", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" }, - "tokenIn": { - "description": "Address of the token being swapped from", - "type": "string" + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" }, - "tokenOut": { - "description": "Address of the token being swapped to", + "poolAddress": { + "description": "Pool contract address", "type": "string" - }, - "amountIn": { - "format": "decimal", - "description": "Amount of tokenIn to be swapped", - "type": "number" - }, - "amountOut": { - "format": "decimal", - "description": "Expected amount of tokenOut to receive", - "type": "number" - }, - "price": { - "format": "decimal", - "description": "Exchange rate between tokenIn and tokenOut", - "type": "number" - }, - "priceImpactPct": { - "format": "decimal", - "description": "Estimated price impact percentage (0-100)", - "type": "number" - }, - "minAmountOut": { - "format": "decimal", - "description": "Minimum amount of tokenOut that will be accepted", - "type": "number" - }, - "maxAmountIn": { - "format": "decimal", - "description": "Maximum amount of tokenIn that will be spent", - "type": "number" - }, - "approximation": { - "description": "True when a BUY was approximated via a sell-leg ExactIn quote because the router does not support ExactOut; amountOut is an estimate rather than exact", - "type": "boolean" } }, "required": [ - "quoteId", - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "price", - "priceImpactPct", - "minAmountOut", - "maxAmountIn" + "connector", + "chainNetwork", + "poolAddress" ] }, - "ExecuteQuoteRequest": { + "AmmPositionInfoRequest": { "type": "object", "properties": { - "walletAddress": { - "description": "Wallet address that will execute the swap", - "type": "string" + "connector": { + "description": "AMM connector", + "enum": [ + "meteora", + "raydium", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" }, - "network": { - "description": "The blockchain network to use", + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "poolAddress": { + "description": "Pool contract address", "type": "string" }, - "quoteId": { - "description": "ID of the quote to execute", + "walletAddress": { + "description": "Wallet address", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string" } }, "required": [ - "quoteId" + "connector", + "chainNetwork", + "poolAddress", + "walletAddress" ] }, - "ExecuteSwapRequest": { + "AmmPositionsOwnedRequest": { "type": "object", "properties": { - "walletAddress": { - "description": "Wallet address that will execute the swap", - "type": "string" - }, - "network": { - "description": "The blockchain network to use", - "type": "string" - }, - "baseToken": { - "description": "Token to determine swap direction", - "type": "string" - }, - "quoteToken": { - "description": "The other token in the pair", - "type": "string" - }, - "amount": { - "format": "decimal", - "description": "Amount of base token to trade", - "type": "number" - }, - "side": { - "description": "Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token", + "connector": { + "description": "AMM connector (only non-fungible-LP AMMs supported: meteora)", "enum": [ - "BUY", - "SELL" + "meteora", + "raydium", + "uniswap", + "pancakeswap" ], - "type": "string" + "default": "meteora", + "type": "string", + "example": "meteora" }, - "slippagePct": { - "format": "decimal", - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage", - "type": "number" + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" }, - "approximateIfNoExactOut": { - "description": "For BUY orders on routers without ExactOut support: approximate the required input via a sell-leg quote and execute an ExactIn swap. If false, such BUY requests fail with a clear error.", - "default": true, - "type": "boolean" + "walletAddress": { + "description": "Wallet address to list positions for", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" } }, "required": [ - "baseToken", - "quoteToken", - "amount", - "side" + "connector", + "chainNetwork", + "walletAddress" ] }, - "SwapExecuteResponse": { + "AmmQuoteLiquidityRequest": { "type": "object", "properties": { - "signature": { - "description": "Transaction signature/hash", + "connector": { + "description": "AMM connector", + "enum": [ + "meteora", + "raydium", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" + }, + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "poolAddress": { + "description": "Pool contract address", "type": "string" }, - "status": { - "description": "Transaction status: 0 = PENDING, 1 = CONFIRMED, -1 = FAILED", + "baseTokenAmount": { + "format": "decimal", + "description": "Amount of base token to deposit", "type": "number" }, - "data": { - "$ref": "#/components/schemas/SwapExecuteResponseData" + "quoteTokenAmount": { + "format": "decimal", + "description": "Amount of quote token to deposit", + "type": "number" + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 } }, "required": [ - "signature", - "status" + "connector", + "chainNetwork", + "poolAddress", + "baseTokenAmount", + "quoteTokenAmount" ] }, - "SwapExecuteResponseData": { + "ClmmOpenRequest": { "type": "object", "properties": { - "tokenIn": { - "description": "Address of the token swapped from", - "type": "string" + "connector": { + "description": "CLMM connector", + "enum": [ + "meteora", + "raydium", + "pancakeswap-sol", + "orca", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" }, - "tokenOut": { - "description": "Address of the token swapped to", + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "walletAddress": { + "description": "Wallet address", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string" }, - "amountIn": { + "lowerPrice": { "format": "decimal", - "description": "Actual amount of tokenIn swapped", - "type": "number" + "description": "Lower price bound for the position", + "type": "number", + "example": 150 }, - "amountOut": { + "upperPrice": { "format": "decimal", - "description": "Actual amount of tokenOut received", - "type": "number" + "description": "Upper price bound for the position", + "type": "number", + "example": 250 }, - "fee": { - "format": "decimal", - "description": "Transaction fee paid", - "type": "number" + "poolAddress": { + "description": "Pool address", + "type": "string", + "example": "2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3" }, - "baseTokenBalanceChange": { + "baseTokenAmount": { "format": "decimal", - "description": "Change in base token balance (negative for decrease)", - "type": "number" + "description": "Amount of base token to deposit", + "type": "number", + "example": 0.01 }, - "quoteTokenBalanceChange": { + "quoteTokenAmount": { "format": "decimal", - "description": "Change in quote token balance (negative for decrease)", - "type": "number" + "description": "Amount of quote token to deposit", + "type": "number", + "example": 2 }, "slippagePct": { "format": "decimal", - "description": "Slippage tolerance percentage actually applied to the swap", - "type": "number" + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 + }, + "strategyType": { + "x-connectors": [ + "meteora" + ], + "description": "Strategy type for Meteora positions (0=Spot, 1=Curve). Only applies to Meteora connector.", + "type": "number", + "example": 0 } }, "required": [ - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "fee", - "baseTokenBalanceChange", - "quoteTokenBalanceChange" + "connector", + "chainNetwork", + "walletAddress", + "lowerPrice", + "upperPrice", + "poolAddress" ] }, - "AmmCreatePoolRequest": { + "ClmmAddRequest": { "type": "object", "properties": { "connector": { - "description": "AMM connector", + "description": "CLMM connector", "enum": [ "meteora", "raydium", + "pancakeswap-sol", + "orca", "uniswap", "pancakeswap" ], @@ -2809,52 +2255,98 @@ "example": "solana-mainnet-beta" }, "walletAddress": { - "description": "Wallet address (pool creator + payer)", + "description": "Wallet address", "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string" }, - "baseToken": { - "description": "Base token symbol or address (becomes the pool base)", - "type": "string" - }, - "quoteToken": { - "description": "Quote token symbol or address (becomes the pool quote)", - "type": "string" + "positionAddress": { + "description": "Position address", + "type": "string", + "example": "" }, "baseTokenAmount": { "format": "decimal", - "description": "Amount of base token to seed the pool with", - "type": "number" + "description": "Amount of base token to deposit (omit for single-sided quote deposit)", + "type": "number", + "example": 0.01 }, "quoteTokenAmount": { "format": "decimal", - "description": "Amount of quote token to seed with. If provided, the base:quote ratio sets the initial price. If omitted (and no initialPrice), the price is fetched from the market.", - "type": "number" + "description": "Amount of quote token to deposit (omit for single-sided base deposit)", + "type": "number", + "example": 2 }, - "initialPrice": { + "slippagePct": { "format": "decimal", - "description": "Initial price as quote per base. Overrides quoteTokenAmount. If both are omitted, the current market price is fetched from the unified swap router so the pool opens on-market.", - "type": "number" + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 }, - "configAddress": { + "strategyType": { "x-connectors": [ "meteora" ], - "description": "Meteora DAMM v2 config account address (required for the meteora connector — configs are permissionless accounts with no index derivation, so the address must be explicit).", + "description": "Strategy type for Meteora positions (0=Spot, 1=Curve). Only applies to Meteora connector.", + "type": "number", + "example": 0 + } + }, + "required": [ + "connector", + "chainNetwork", + "walletAddress", + "positionAddress" + ] + }, + "ClmmRemoveRequest": { + "type": "object", + "properties": { + "connector": { + "description": "CLMM connector", + "enum": [ + "meteora", + "raydium", + "pancakeswap-sol", + "orca", + "uniswap", + "pancakeswap" + ], + "default": "meteora", + "type": "string", + "example": "meteora" + }, + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, + "walletAddress": { + "description": "Wallet address", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string" }, - "ammConfigIndex": { - "x-connectors": [ - "raydium" - ], - "description": "Raydium CPMM fee-config index (optional; defaults to the first available config).", - "type": "number" + "positionAddress": { + "description": "Position address", + "type": "string", + "example": "" + }, + "percentageToRemove": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Percentage of liquidity to remove", + "default": 100, + "type": "number", + "example": 100 }, "slippagePct": { "format": "decimal", "minimum": 0, "maximum": 100, - "description": "Uniswap/PancakeSwap seeding slippage percentage. Defaults to the connector's configured slippagePct.", + "description": "Maximum acceptable slippage percentage. Only applies to the Orca connector; defaults to Orca's configured slippagePct.", "type": "number", "example": 1 } @@ -2863,19 +2355,20 @@ "connector", "chainNetwork", "walletAddress", - "baseToken", - "quoteToken", - "baseTokenAmount" + "positionAddress", + "percentageToRemove" ] }, - "AmmAddRequest": { + "ClmmCollectFeesRequest": { "type": "object", "properties": { "connector": { - "description": "AMM connector", + "description": "CLMM connector", "enum": [ "meteora", "raydium", + "pancakeswap-sol", + "orca", "uniswap", "pancakeswap" ], @@ -2894,53 +2387,29 @@ "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string" }, - "poolAddress": { - "description": "Pool contract address", - "type": "string" - }, - "baseTokenAmount": { - "format": "decimal", - "description": "Amount of base token to add", - "type": "number" - }, - "quoteTokenAmount": { - "format": "decimal", - "description": "Amount of quote token to add", - "type": "number" - }, "positionAddress": { - "x-connectors": [ - "meteora" - ], - "description": "meteora only (DAMM v2 positions are NFTs): add to this specific position. Omit to open a new position. Ignored by fungible-LP AMMs.", - "type": "string" - }, - "slippagePct": { - "format": "decimal", - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", - "type": "number", - "example": 1 + "description": "Position address", + "type": "string", + "example": "" } }, "required": [ "connector", "chainNetwork", "walletAddress", - "poolAddress", - "baseTokenAmount", - "quoteTokenAmount" + "positionAddress" ] }, - "AmmRemoveRequest": { + "ClmmCloseRequest": { "type": "object", "properties": { "connector": { - "description": "AMM connector", + "description": "CLMM connector", "enum": [ "meteora", "raydium", + "pancakeswap-sol", + "orca", "uniswap", "pancakeswap" ], @@ -2959,51 +2428,29 @@ "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string" }, - "poolAddress": { - "description": "Pool contract address", - "type": "string" - }, "positionAddress": { - "x-connectors": [ - "meteora" - ], - "description": "Required for meteora (DAMM v2 positions are NFTs): the specific position to remove from. List positions with position-info or positions-owned. Ignored by fungible-LP AMMs.", - "type": "string" - }, - "percentageToRemove": { - "format": "decimal", - "minimum": 0, - "maximum": 100, - "description": "Percentage of liquidity to remove", - "default": 100, - "type": "number", - "example": 100 - }, - "slippagePct": { - "format": "decimal", - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", - "type": "number", - "example": 1 + "description": "Position address", + "type": "string", + "example": "" } }, "required": [ "connector", "chainNetwork", "walletAddress", - "poolAddress", - "percentageToRemove" + "positionAddress" ] }, - "AmmOpenRequest": { + "ClmmCreatePoolRequest": { "type": "object", "properties": { "connector": { - "description": "AMM connector", + "description": "CLMM connector", "enum": [ "meteora", "raydium", + "pancakeswap-sol", + "orca", "uniswap", "pancakeswap" ], @@ -3018,96 +2465,131 @@ "example": "solana-mainnet-beta" }, "walletAddress": { - "description": "Wallet that will own the position", + "description": "Wallet address (pool creator + payer)", "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string" }, - "poolAddress": { - "description": "Pool to open the position in", + "baseToken": { "type": "string" }, - "baseTokenAmount": { + "quoteToken": { + "type": "string" + }, + "initialPrice": { "format": "decimal", - "description": "Amount of base token to deposit", + "description": "Initial pool price as quote per base. If omitted, the current market price is fetched from the unified swap router so the pool opens on-market.", "type": "number" }, - "quoteTokenAmount": { - "format": "decimal", - "description": "Amount of quote token to deposit", + "binStep": { + "x-connectors": [ + "meteora", + "orca" + ], + "description": "Bin/tick granularity: Meteora DLMM bin step (bps); Orca Whirlpool tick spacing.", "type": "number" }, - "slippagePct": { - "format": "decimal", - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", - "type": "number", - "example": 1 + "feeBps": { + "x-connectors": [ + "meteora", + "uniswap", + "pancakeswap" + ], + "description": "Base fee in basis points: Meteora DLMM base fee; Uniswap/PancakeSwap V3 fee tier (1, 5, 30 or 100 bps; PancakeSwap also 25).", + "type": "number" + }, + "ammConfigIndex": { + "x-connectors": [ + "raydium", + "pancakeswap-sol" + ], + "description": "Fee-config index for the Raydium CLMM family: Raydium API config list index; pancakeswap-sol amm_config PDA index. Default 0.", + "type": "number" } }, "required": [ "connector", "chainNetwork", "walletAddress", - "poolAddress", - "baseTokenAmount", - "quoteTokenAmount" + "baseToken", + "quoteToken" ] }, - "AmmCloseRequest": { + "ClmmFetchPoolsRequest": { "type": "object", "properties": { + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, "connector": { - "description": "AMM connector", + "description": "CLMM connector whose pool-discovery API to query", "enum": [ "meteora", - "raydium", - "uniswap", - "pancakeswap" + "orca" ], "default": "meteora", "type": "string", "example": "meteora" }, - "chainNetwork": { - "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", - "default": "solana-mainnet-beta", + "limit": { + "minimum": 1, + "maximum": 1000, + "default": 50, + "description": "Maximum number of pools to return", + "type": "number" + }, + "query": { + "description": "Search pools by name, token, or address", "type": "string", - "example": "solana-mainnet-beta" + "example": "SOL" }, - "walletAddress": { - "description": "Wallet that owns the position", - "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", - "type": "string" + "sortBy": { + "description": "Sort field. Meteora takes a \"field:direction\" pair; Orca takes the field alone with sortDirection.", + "type": "string", + "example": "tvl" }, - "poolAddress": { - "description": "Pool the position belongs to", - "type": "string" + "page": { + "minimum": 0, + "description": "0-based page index. Only connectors whose API paginates honor this.", + "x-connectors": [ + "meteora" + ], + "type": "number" }, - "positionAddress": { - "description": "Position to close. Required on AMMs whose positions are discrete accounts (meteora DAMM v2), where a wallet may hold several per pool. Ignored by fungible-LP AMMs, which hold one LP balance per pool.", + "includeUnverified": { + "description": "Include unverified pools", "x-connectors": [ "meteora" ], + "type": "boolean" + }, + "sortDirection": { + "description": "Sort direction", + "enum": [ + "asc", + "desc" + ], + "x-connectors": [ + "orca" + ], "type": "string" }, - "slippagePct": { - "format": "decimal", - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage on the withdrawn amounts.", - "type": "number", - "example": 1 + "verifiedOnly": { + "description": "Return only verified pools", + "x-connectors": [ + "orca" + ], + "type": "boolean" } }, "required": [ - "connector", "chainNetwork", - "walletAddress", - "poolAddress" + "connector" ] }, - "ClmmOpenRequest": { + "ClmmPoolInfoRequest": { "type": "object", "properties": { "connector": { @@ -3130,67 +2612,26 @@ "type": "string", "example": "solana-mainnet-beta" }, - "walletAddress": { - "description": "Wallet address", - "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", - "type": "string" - }, - "lowerPrice": { - "format": "decimal", - "description": "Lower price bound for the position", - "type": "number", - "example": 150 - }, - "upperPrice": { - "format": "decimal", - "description": "Upper price bound for the position", - "type": "number", - "example": 250 - }, "poolAddress": { - "description": "Pool address", + "description": "Pool contract address", "type": "string", "example": "2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3" }, - "baseTokenAmount": { - "format": "decimal", - "description": "Amount of base token to deposit", - "type": "number", - "example": 0.01 - }, - "quoteTokenAmount": { - "format": "decimal", - "description": "Amount of quote token to deposit", - "type": "number", - "example": 2 - }, - "slippagePct": { - "format": "decimal", + "binCount": { + "description": "If > 0, include a `bins` array of per-tick liquidity around the active tick. Supported by every connector except Meteora, which always returns its bins and ignores this. Default 0 = skip the bin fetch.", + "default": 0, "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", - "type": "number", - "example": 1 - }, - "strategyType": { - "x-connectors": [ - "meteora" - ], - "description": "Strategy type for Meteora positions (0=Spot, 1=Curve). Only applies to Meteora connector.", - "type": "number", - "example": 0 + "maximum": 401, + "type": "integer" } }, "required": [ "connector", "chainNetwork", - "walletAddress", - "lowerPrice", - "upperPrice", "poolAddress" ] }, - "ClmmAddRequest": { + "ClmmPositionInfoRequest": { "type": "object", "properties": { "connector": { @@ -3213,53 +2654,19 @@ "type": "string", "example": "solana-mainnet-beta" }, - "walletAddress": { - "description": "Wallet address", - "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", - "type": "string" - }, "positionAddress": { - "description": "Position address", + "description": "Position address or NFT token ID", "type": "string", "example": "" - }, - "baseTokenAmount": { - "format": "decimal", - "description": "Amount of base token to deposit (omit for single-sided quote deposit)", - "type": "number", - "example": 0.01 - }, - "quoteTokenAmount": { - "format": "decimal", - "description": "Amount of quote token to deposit (omit for single-sided base deposit)", - "type": "number", - "example": 2 - }, - "slippagePct": { - "format": "decimal", - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", - "type": "number", - "example": 1 - }, - "strategyType": { - "x-connectors": [ - "meteora" - ], - "description": "Strategy type for Meteora positions (0=Spot, 1=Curve). Only applies to Meteora connector.", - "type": "number", - "example": 0 } }, "required": [ "connector", "chainNetwork", - "walletAddress", "positionAddress" ] }, - "ClmmRemoveRequest": { + "ClmmPositionsOwnedRequest": { "type": "object", "properties": { "connector": { @@ -3286,39 +2693,15 @@ "description": "Wallet address", "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string" - }, - "positionAddress": { - "description": "Position address", - "type": "string", - "example": "" - }, - "percentageToRemove": { - "format": "decimal", - "minimum": 0, - "maximum": 100, - "description": "Percentage of liquidity to remove", - "default": 100, - "type": "number", - "example": 100 - }, - "slippagePct": { - "format": "decimal", - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage. Only applies to the Orca connector; defaults to Orca's configured slippagePct.", - "type": "number", - "example": 1 } }, "required": [ "connector", "chainNetwork", - "walletAddress", - "positionAddress", - "percentageToRemove" + "walletAddress" ] }, - "ClmmCollectFeesRequest": { + "ClmmQuoteLiquidityRequest": { "type": "object", "properties": { "connector": { @@ -3341,139 +2724,264 @@ "type": "string", "example": "solana-mainnet-beta" }, - "walletAddress": { - "description": "Wallet address", - "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", - "type": "string" + "lowerPrice": { + "format": "decimal", + "description": "Lower price bound for the position", + "type": "number", + "example": 150 }, - "positionAddress": { - "description": "Position address", + "upperPrice": { + "format": "decimal", + "description": "Upper price bound for the position", + "type": "number", + "example": 250 + }, + "poolAddress": { + "description": "Pool contract address", "type": "string", - "example": "" + "example": "2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3" + }, + "baseTokenAmount": { + "format": "decimal", + "description": "Amount of base token to deposit", + "type": "number", + "example": 0.01 + }, + "quoteTokenAmount": { + "format": "decimal", + "description": "Amount of quote token to deposit", + "type": "number", + "example": 2 + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 } }, "required": [ "connector", "chainNetwork", - "walletAddress", - "positionAddress" + "lowerPrice", + "upperPrice", + "poolAddress" ] }, - "ClmmCloseRequest": { + "RouterExecuteQuoteRequest": { "type": "object", "properties": { + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, "connector": { - "description": "CLMM connector", + "description": "Router connector. Defaults to the network's swapProvider", "enum": [ - "meteora", - "raydium", - "pancakeswap-sol", - "orca", + "jupiter", + "dflow", + "okx", + "titan", "uniswap", - "pancakeswap" + "pancakeswap", + "0x" ], - "default": "meteora", + "default": "jupiter", "type": "string", - "example": "meteora" + "example": "jupiter" + }, + "walletAddress": { + "description": "Wallet address that will execute the quote", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" }, + "quoteId": { + "description": "ID of a quote returned by /trading/router/quote-swap", + "type": "string" + } + }, + "required": [ + "chainNetwork", + "walletAddress", + "quoteId" + ] + }, + "RouterExecuteSwapRequest": { + "type": "object", + "properties": { "chainNetwork": { "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", "default": "solana-mainnet-beta", "type": "string", "example": "solana-mainnet-beta" }, + "connector": { + "description": "Router connector. Defaults to the network's swapProvider", + "enum": [ + "jupiter", + "dflow", + "okx", + "titan", + "uniswap", + "pancakeswap", + "0x" + ], + "default": "jupiter", + "type": "string", + "example": "jupiter" + }, "walletAddress": { - "description": "Wallet address", + "description": "Wallet address that will execute the swap", "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string" }, - "positionAddress": { - "description": "Position address", - "type": "string", - "example": "" + "baseToken": { + "description": "Symbol or address of the base token", + "default": "SOL", + "type": "string" + }, + "quoteToken": { + "description": "Symbol or address of the quote token", + "default": "USDC", + "type": "string" + }, + "amount": { + "format": "decimal", + "description": "Amount of base token to trade", + "default": 0.01, + "type": "number" + }, + "side": { + "description": "BUY means buying base token with quote token, SELL means selling base token for quote token", + "enum": [ + "BUY", + "SELL" + ], + "default": "SELL", + "type": "string" + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 + }, + "approximateIfNoExactOut": { + "description": "For BUY orders when the router has no ExactOut route: approximate via a sell-leg ExactIn swap instead of failing.", + "default": true, + "x-connectors": [ + "jupiter", + "dflow", + "okx", + "titan" + ], + "type": "boolean" } }, "required": [ - "connector", "chainNetwork", "walletAddress", - "positionAddress" + "baseToken", + "quoteToken", + "amount", + "side" ] }, - "ClmmCreatePoolRequest": { + "RouterQuoteSwapRequest": { "type": "object", "properties": { + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "default": "solana-mainnet-beta", + "type": "string", + "example": "solana-mainnet-beta" + }, "connector": { - "description": "CLMM connector", + "description": "Router connector. Defaults to the network's swapProvider", "enum": [ - "meteora", - "raydium", - "pancakeswap-sol", - "orca", + "jupiter", + "dflow", + "okx", + "titan", "uniswap", - "pancakeswap" + "pancakeswap", + "0x" ], - "default": "meteora", - "type": "string", - "example": "meteora" - }, - "chainNetwork": { - "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", - "default": "solana-mainnet-beta", + "default": "jupiter", "type": "string", - "example": "solana-mainnet-beta" - }, - "walletAddress": { - "description": "Wallet address (pool creator + payer)", - "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", - "type": "string" + "example": "jupiter" }, "baseToken": { + "description": "Symbol or address of the base token", + "default": "SOL", "type": "string" }, "quoteToken": { + "description": "Symbol or address of the quote token", + "default": "USDC", "type": "string" }, - "initialPrice": { + "amount": { "format": "decimal", - "description": "Initial pool price as quote per base. If omitted, the current market price is fetched from the unified swap router so the pool opens on-market.", + "description": "Amount of base token to trade", + "default": 1, "type": "number" }, - "binStep": { - "x-connectors": [ - "meteora", - "orca" + "side": { + "description": "BUY means buying base token with quote token, SELL means selling base token for quote token", + "enum": [ + "BUY", + "SELL" ], - "description": "Bin/tick granularity: Meteora DLMM bin step (bps); Orca Whirlpool tick spacing.", - "type": "number" + "default": "SELL", + "type": "string" }, - "feeBps": { + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 + }, + "walletAddress": { + "description": "Taker the quote is priced for. Required by routers that quote per-wallet or return wallet-specific calldata.", + "type": "string" + }, + "approximateIfNoExactOut": { + "description": "For BUY orders when the router has no ExactOut route: approximate via a sell-leg ExactIn quote instead of failing.", + "default": true, "x-connectors": [ - "meteora", - "uniswap", - "pancakeswap" + "jupiter", + "dflow", + "okx", + "titan" ], - "description": "Base fee in basis points: Meteora DLMM base fee; Uniswap/PancakeSwap V3 fee tier (1, 5, 30 or 100 bps; PancakeSwap also 25).", - "type": "number" + "type": "boolean" }, - "ammConfigIndex": { + "indicativePrice": { + "description": "Return an indicative price instead of a firm, executable quote. An indicative quote cannot be executed with /trading/router/execute-quote.", "x-connectors": [ - "raydium", - "pancakeswap-sol" + "0x" ], - "description": "Fee-config index for the Raydium CLMM family: Raydium API config list index; pancakeswap-sol amm_config PDA index. Default 0.", - "type": "number" + "type": "boolean" } }, "required": [ - "connector", "chainNetwork", - "walletAddress", "baseToken", - "quoteToken" + "quoteToken", + "amount", + "side" ] }, - "RouterExecuteQuoteRequest": { + "AmmQuoteSwapRequest": { "type": "object", "properties": { "chainNetwork": { @@ -3483,37 +2991,64 @@ "example": "solana-mainnet-beta" }, "connector": { - "description": "Router connector. Defaults to the network's swapProvider", + "description": "AMM connector to price the swap against", "enum": [ - "jupiter", - "dflow", - "okx", - "titan", + "meteora", + "raydium", "uniswap", - "pancakeswap", - "0x" + "pancakeswap" ], - "default": "jupiter", + "default": "meteora", "type": "string", - "example": "jupiter" + "example": "meteora" }, - "walletAddress": { - "description": "Wallet address that will execute the quote", - "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "baseToken": { + "description": "Symbol or address of the base token", + "default": "SOL", "type": "string" }, - "quoteId": { - "description": "ID of a quote returned by /trading/router/quote-swap", + "quoteToken": { + "description": "Symbol or address of the quote token", + "default": "USDC", + "type": "string" + }, + "amount": { + "format": "decimal", + "description": "Amount of base token to trade", + "default": 1, + "type": "number" + }, + "side": { + "description": "BUY means buying base token with quote token, SELL means selling base token for quote token", + "enum": [ + "BUY", + "SELL" + ], + "default": "SELL", + "type": "string" + }, + "poolAddress": { + "description": "Pool to trade against. Omit to resolve it from Gateway's configured pool list by token pair; pass an address to pin a pool that is not in that list.", "type": "string" + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 } }, "required": [ "chainNetwork", - "walletAddress", - "quoteId" + "baseToken", + "quoteToken", + "amount", + "side" ] }, - "RouterExecuteSwapRequest": { + "ClmmQuoteSwapRequest": { "type": "object", "properties": { "chainNetwork": { @@ -3523,24 +3058,18 @@ "example": "solana-mainnet-beta" }, "connector": { - "description": "Router connector. Defaults to the network's swapProvider", + "description": "CLMM connector to price the swap against", "enum": [ - "jupiter", - "dflow", - "okx", - "titan", + "meteora", + "raydium", + "orca", + "pancakeswap-sol", "uniswap", - "pancakeswap", - "0x" + "pancakeswap" ], - "default": "jupiter", + "default": "meteora", "type": "string", - "example": "jupiter" - }, - "walletAddress": { - "description": "Wallet address that will execute the swap", - "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", - "type": "string" + "example": "meteora" }, "baseToken": { "description": "Symbol or address of the base token", @@ -3555,7 +3084,7 @@ "amount": { "format": "decimal", "description": "Amount of base token to trade", - "default": 0.01, + "default": 1, "type": "number" }, "side": { @@ -3567,6 +3096,10 @@ "default": "SELL", "type": "string" }, + "poolAddress": { + "description": "Pool to trade against. Omit to resolve it from Gateway's configured pool list by token pair; pass an address to pin a pool that is not in that list.", + "type": "string" + }, "slippagePct": { "format": "decimal", "minimum": 0, @@ -3574,22 +3107,10 @@ "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", "type": "number", "example": 1 - }, - "approximateIfNoExactOut": { - "description": "For BUY orders when the router has no ExactOut route: approximate via a sell-leg ExactIn swap instead of failing.", - "default": true, - "x-connectors": [ - "jupiter", - "dflow", - "okx", - "titan" - ], - "type": "boolean" } }, "required": [ "chainNetwork", - "walletAddress", "baseToken", "quoteToken", "amount", @@ -3890,6 +3411,44 @@ "chain", "address" ] + }, + "Token": { + "type": "object", + "properties": { + "chainId": { + "description": "The chain ID", + "type": "number", + "example": 1 + }, + "name": { + "description": "The full name of the token", + "type": "string", + "example": "USD Coin" + }, + "symbol": { + "description": "The token symbol", + "type": "string", + "example": "USDC" + }, + "address": { + "description": "The token contract address", + "type": "string", + "example": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + }, + "decimals": { + "description": "The number of decimals the token uses", + "minimum": 0, + "maximum": 255, + "type": "number", + "example": 6 + } + }, + "required": [ + "name", + "symbol", + "address", + "decimals" + ] } } }, @@ -4555,42 +4114,7 @@ "type": "object", "properties": { "token": { - "type": "object", - "properties": { - "chainId": { - "description": "The chain ID", - "type": "number", - "example": 1 - }, - "name": { - "description": "The full name of the token", - "type": "string", - "example": "USD Coin" - }, - "symbol": { - "description": "The token symbol", - "type": "string", - "example": "USDC" - }, - "address": { - "description": "The token contract address", - "type": "string", - "example": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" - }, - "decimals": { - "description": "The number of decimals the token uses", - "minimum": 0, - "maximum": 255, - "type": "number", - "example": 6 - } - }, - "required": [ - "name", - "symbol", - "address", - "decimals" - ] + "$ref": "#/components/schemas/Token" }, "chain": { "type": "string" @@ -4665,42 +4189,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "chainId": { - "description": "The chain ID", - "type": "number", - "example": 1 - }, - "name": { - "description": "The full name of the token", - "type": "string", - "example": "USD Coin" - }, - "symbol": { - "description": "The token symbol", - "type": "string", - "example": "USDC" - }, - "address": { - "description": "The token contract address", - "type": "string", - "example": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" - }, - "decimals": { - "description": "The number of decimals the token uses", - "minimum": 0, - "maximum": 255, - "type": "number", - "example": 6 - } - }, - "required": [ - "name", - "symbol", - "address", - "decimals" - ] + "$ref": "#/components/schemas/Token" } } } @@ -4781,42 +4270,7 @@ "tokens": { "type": "array", "items": { - "type": "object", - "properties": { - "chainId": { - "description": "The chain ID", - "type": "number", - "example": 1 - }, - "name": { - "description": "The full name of the token", - "type": "string", - "example": "USD Coin" - }, - "symbol": { - "description": "The token symbol", - "type": "string", - "example": "USDC" - }, - "address": { - "description": "The token contract address", - "type": "string", - "example": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" - }, - "decimals": { - "description": "The number of decimals the token uses", - "minimum": 0, - "maximum": 255, - "type": "number", - "example": 6 - } - }, - "required": [ - "name", - "symbol", - "address", - "decimals" - ] + "$ref": "#/components/schemas/Token" } } }, @@ -4851,42 +4305,7 @@ "example": "mainnet" }, "token": { - "type": "object", - "properties": { - "chainId": { - "description": "The chain ID", - "type": "number", - "example": 1 - }, - "name": { - "description": "The full name of the token", - "type": "string", - "example": "USD Coin" - }, - "symbol": { - "description": "The token symbol", - "type": "string", - "example": "USDC" - }, - "address": { - "description": "The token contract address", - "type": "string", - "example": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" - }, - "decimals": { - "description": "The number of decimals the token uses", - "minimum": 0, - "maximum": 255, - "type": "number", - "example": 6 - } - }, - "required": [ - "name", - "symbol", - "address", - "decimals" - ] + "$ref": "#/components/schemas/Token" } }, "required": [ @@ -4982,42 +4401,7 @@ "type": "string" }, "token": { - "type": "object", - "properties": { - "chainId": { - "description": "The chain ID", - "type": "number", - "example": 1 - }, - "name": { - "description": "The full name of the token", - "type": "string", - "example": "USD Coin" - }, - "symbol": { - "description": "The token symbol", - "type": "string", - "example": "USDC" - }, - "address": { - "description": "The token contract address", - "type": "string", - "example": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" - }, - "decimals": { - "description": "The number of decimals the token uses", - "minimum": 0, - "maximum": 255, - "type": "number", - "example": 6 - } - }, - "required": [ - "name", - "symbol", - "address", - "decimals" - ] + "$ref": "#/components/schemas/Token" } }, "required": [ diff --git a/models/gateway_generated.py b/models/gateway_generated.py index c0b6be45..12358e29 100644 --- a/models/gateway_generated.py +++ b/models/gateway_generated.py @@ -20,20 +20,6 @@ class AmmPoolInfo(BaseModel): quote_token_amount: Decimal = Field(..., alias='quoteTokenAmount') -class AmmGetPoolInfoRequest(BaseModel): - network: str | None = None - pool_address: str = Field(..., alias='poolAddress') - - -class AmmAddLiquidityRequest(BaseModel): - network: str | None = None - wallet_address: str | None = Field(None, alias='walletAddress') - pool_address: str = Field(..., alias='poolAddress') - base_token_amount: Decimal = Field(..., alias='baseTokenAmount') - quote_token_amount: Decimal = Field(..., alias='quoteTokenAmount') - slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct') - - class AmmAddLiquidityResponseData(BaseModel): fee: Decimal pool_address: str | None = Field(None, alias='poolAddress', description='Pool this operation acted on') @@ -61,14 +47,6 @@ class AmmClosePositionResponseData(BaseModel): quote_token_amount_removed: Decimal = Field(..., alias='quoteTokenAmountRemoved') -class QuoteLiquidityRequest(BaseModel): - network: str | None = None - pool_address: str = Field(..., alias='poolAddress') - base_token_amount: Decimal = Field(..., alias='baseTokenAmount') - quote_token_amount: Decimal = Field(..., alias='quoteTokenAmount') - slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct') - - class QuoteLiquidityResponse(BaseModel): pool_address: str | None = Field(None, alias='poolAddress', description='Pool the quote was computed against') base_limited: bool = Field(..., alias='baseLimited') @@ -78,13 +56,6 @@ class QuoteLiquidityResponse(BaseModel): quote_token_amount_max: Decimal = Field(..., alias='quoteTokenAmountMax') -class AmmRemoveLiquidityRequest(BaseModel): - network: str | None = None - wallet_address: str | None = Field(None, alias='walletAddress') - pool_address: str = Field(..., alias='poolAddress') - percentage_to_remove: condecimal(ge=Decimal('0'), le=Decimal('100')) = Field(..., alias='percentageToRemove') - - class AmmRemoveLiquidityResponseData(BaseModel): fee: Decimal pool_address: str | None = Field(None, alias='poolAddress', description='Pool this operation acted on') @@ -93,16 +64,6 @@ class AmmRemoveLiquidityResponseData(BaseModel): quote_token_amount_removed: Decimal = Field(..., alias='quoteTokenAmountRemoved') -class CreatePoolRequest(BaseModel): - network: str | None = None - wallet_address: str | None = Field(None, alias='walletAddress') - base_token: str = Field(..., alias='baseToken', description='Base token symbol or address (becomes the pool base)') - quote_token: str = Field(..., alias='quoteToken', description='Quote token symbol or address (becomes the pool quote)') - base_token_amount: Decimal = Field(..., alias='baseTokenAmount', description='Amount of base token to seed the pool with') - quote_token_amount: Decimal | None = Field(None, alias='quoteTokenAmount', description='Amount of quote token to seed with. If provided, the base:quote ratio sets the initial price. If omitted (and no initialPrice), the price is fetched from the market.') - initial_price: Decimal | None = Field(None, alias='initialPrice', description='Initial price as quote per base. Overrides quoteTokenAmount. If both are omitted, the current market price is fetched from the unified swap router so the pool opens on-market.') - - class CreatePoolResponseData(BaseModel): fee: Decimal base_token_amount_added: Decimal = Field(..., alias='baseTokenAmountAdded') @@ -128,51 +89,6 @@ class AmmPositionInfo(BaseModel): positions: list[PositionDetail] | None = None -class AmmGetPositionInfoRequest(BaseModel): - network: str | None = None - pool_address: str = Field(..., alias='poolAddress') - wallet_address: str | None = Field(None, alias='walletAddress') - - -class Side(StrEnum): - buy = 'BUY' - sell = 'SELL' - - -class AmmQuoteSwapRequest(BaseModel): - network: str | None = None - pool_address: str | None = Field(None, alias='poolAddress', description='Pool address (optional - can be looked up from baseToken and quoteToken)') - base_token: str = Field(..., alias='baseToken', description='Token to determine swap direction') - quote_token: str | None = Field(None, alias='quoteToken', description='The other token in the pair (optional - required if poolAddress not provided)') - amount: Decimal - side: Side = Field(..., description='Trade direction') - slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct') - - -class AmmQuoteSwapResponse(BaseModel): - pool_address: str = Field(..., alias='poolAddress') - token_in: str = Field(..., alias='tokenIn') - token_out: str = Field(..., alias='tokenOut') - amount_in: Decimal = Field(..., alias='amountIn') - amount_out: Decimal = Field(..., alias='amountOut') - price: Decimal - slippage_pct: Decimal | None = Field(None, alias='slippagePct') - min_amount_out: Decimal = Field(..., alias='minAmountOut') - max_amount_in: Decimal = Field(..., alias='maxAmountIn') - price_impact_pct: Decimal = Field(..., alias='priceImpactPct') - - -class AmmExecuteSwapResponseData(BaseModel): - token_in: str = Field(..., alias='tokenIn') - token_out: str = Field(..., alias='tokenOut') - amount_in: Decimal = Field(..., alias='amountIn') - amount_out: Decimal = Field(..., alias='amountOut') - fee: Decimal - base_token_balance_change: Decimal = Field(..., alias='baseTokenBalanceChange') - quote_token_balance_change: Decimal = Field(..., alias='quoteTokenBalanceChange') - slippage_pct: Decimal | None = Field(None, alias='slippagePct', description='Slippage tolerance percentage actually applied to the swap') - - class Network(StrEnum): devnet = 'devnet' mainnet_beta = 'mainnet-beta' @@ -219,22 +135,6 @@ class BalanceResponse(BaseModel): balances: dict[str, float] -class TokensRequest(BaseModel): - network: Network | None = Field(None, description="Network to use. Defaults to the chain's configured default network.", examples=['mainnet-beta']) - token_symbols: str | list[str] | None = Field(None, alias='tokenSymbols') - - -class Token(BaseModel): - symbol: str - address: str - decimals: float - name: str - - -class TokensResponse(BaseModel): - tokens: list[Token] - - class PollRequest(BaseModel): network: Network | None = Field(None, description="Network to use. Defaults to the chain's configured default network.", examples=['mainnet-beta']) signature: str = Field(..., description='Transaction signature/hash') @@ -327,13 +227,6 @@ class RouterQuoteSwapResponse(BaseModel): approximation: bool | None = Field(None, description='True when a BUY was approximated via a sell-leg ExactIn quote because the router has no ExactOut route; amountOut is an estimate rather than exact') -class FetchPoolsRequest(BaseModel): - network: str | None = Field(None, description='Network to use') - limit: confloat(ge=1.0, le=100.0) | None = Field(50, description='Maximum number of pools to return') - query: str | None = Field(None, description='Search query to match pools by name, tokens, or address') - sort_by: str | None = Field(None, alias='sortBy', description='Sort by field (connector-specific)') - - class PoolListItem(BaseModel): address: str = Field(..., description='Pool address') name: str = Field(..., description='Pool name (e.g., SOL-USDC)') @@ -358,11 +251,6 @@ class FetchPoolsResponse(BaseModel): page_size: float = Field(..., alias='pageSize', description='Number of pools per page') -class GetPositionsOwnedRequest(BaseModel): - network: str | None = None - wallet_address: str = Field(..., alias='walletAddress') - - class BinLiquidity(BaseModel): bin_id: float = Field(..., alias='binId') price: Decimal @@ -383,28 +271,6 @@ class PoolInfo(BaseModel): bins: list[BinLiquidity] | None = None -class MeteoraPoolInfo(BaseModel): - address: str - base_token_address: str = Field(..., alias='baseTokenAddress') - quote_token_address: str = Field(..., alias='quoteTokenAddress') - bin_step: float | None = Field(None, alias='binStep') - fee_pct: Decimal = Field(..., alias='feePct') - price: Decimal - base_token_amount: Decimal = Field(..., alias='baseTokenAmount') - quote_token_amount: Decimal = Field(..., alias='quoteTokenAmount') - active_bin_id: float = Field(..., alias='activeBinId') - bins: list[BinLiquidity] | None = None - dynamic_fee_pct: float = Field(..., alias='dynamicFeePct') - min_bin_id: float = Field(..., alias='minBinId') - max_bin_id: float = Field(..., alias='maxBinId') - - -class GetPoolInfoRequest(BaseModel): - network: str | None = None - pool_address: str = Field(..., alias='poolAddress') - bin_count: conint(ge=0, le=401) | None = Field(0, alias='binCount', description='If > 0, include a `bins` array in the response (per-tickSpacing token amounts around the active tick, mirroring Meteora pool-info.bins[]). Default 0 = skip the bin fetch.') - - class PositionInfo(BaseModel): address: str pool_address: str = Field(..., alias='poolAddress') @@ -421,23 +287,6 @@ class PositionInfo(BaseModel): price: Decimal -class GetPositionInfoRequest(BaseModel): - network: str | None = None - position_address: str = Field(..., alias='positionAddress') - wallet_address: str | None = Field(None, alias='walletAddress') - - -class OpenPositionRequest(BaseModel): - network: str | None = None - wallet_address: str | None = Field(None, alias='walletAddress') - lower_price: Decimal = Field(..., alias='lowerPrice') - upper_price: Decimal = Field(..., alias='upperPrice') - pool_address: str = Field(..., alias='poolAddress') - base_token_amount: Decimal | None = Field(None, alias='baseTokenAmount') - quote_token_amount: Decimal | None = Field(None, alias='quoteTokenAmount') - slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct') - - class OpenPositionResponseData(BaseModel): fee: Decimal pool_address: str | None = Field(None, alias='poolAddress', description='Pool this operation acted on') @@ -447,15 +296,6 @@ class OpenPositionResponseData(BaseModel): quote_token_amount_added: Decimal = Field(..., alias='quoteTokenAmountAdded') -class AddLiquidityRequest(BaseModel): - network: str | None = None - wallet_address: str | None = Field(None, alias='walletAddress') - position_address: str = Field(..., alias='positionAddress') - base_token_amount: Decimal = Field(..., alias='baseTokenAmount') - quote_token_amount: Decimal = Field(..., alias='quoteTokenAmount') - slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct') - - class AddLiquidityResponseData(BaseModel): fee: Decimal pool_address: str | None = Field(None, alias='poolAddress', description='Pool this operation acted on') @@ -464,13 +304,6 @@ class AddLiquidityResponseData(BaseModel): quote_token_amount_added: Decimal = Field(..., alias='quoteTokenAmountAdded') -class RemoveLiquidityRequest(BaseModel): - network: str | None = None - wallet_address: str | None = Field(None, alias='walletAddress') - position_address: str = Field(..., alias='positionAddress') - percentage_to_remove: condecimal(ge=Decimal('0'), le=Decimal('100')) = Field(..., alias='percentageToRemove') - - class RemoveLiquidityResponseData(BaseModel): fee: Decimal pool_address: str | None = Field(None, alias='poolAddress', description='Pool this operation acted on') @@ -479,12 +312,6 @@ class RemoveLiquidityResponseData(BaseModel): quote_token_amount_removed: Decimal = Field(..., alias='quoteTokenAmountRemoved') -class CollectFeesRequest(BaseModel): - network: str | None = None - wallet_address: str | None = Field(None, alias='walletAddress') - position_address: str = Field(..., alias='positionAddress') - - class CollectFeesResponseData(BaseModel): fee: Decimal pool_address: str | None = Field(None, alias='poolAddress', description='Pool this operation acted on') @@ -493,12 +320,6 @@ class CollectFeesResponseData(BaseModel): quote_fee_amount_collected: Decimal = Field(..., alias='quoteFeeAmountCollected') -class ClosePositionRequest(BaseModel): - network: str | None = None - wallet_address: str | None = Field(None, alias='walletAddress') - position_address: str = Field(..., alias='positionAddress') - - class ClosePositionResponseData(BaseModel): fee: Decimal pool_address: str | None = Field(None, alias='poolAddress', description='Pool this operation acted on') @@ -514,16 +335,6 @@ class ClmmCreatePoolResponseData(BaseModel): fee: Decimal -class QuotePositionRequest(BaseModel): - network: str | None = None - lower_price: Decimal = Field(..., alias='lowerPrice') - upper_price: Decimal = Field(..., alias='upperPrice') - pool_address: str = Field(..., alias='poolAddress') - base_token_amount: Decimal | None = Field(None, alias='baseTokenAmount') - quote_token_amount: Decimal | None = Field(None, alias='quoteTokenAmount') - slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct') - - class QuotePositionResponse(BaseModel): pool_address: str | None = Field(None, alias='poolAddress', description='Pool the quote was computed against') base_limited: bool = Field(..., alias='baseLimited') @@ -534,91 +345,6 @@ class QuotePositionResponse(BaseModel): liquidity: Any | None = None -class ClmmQuoteSwapRequest(BaseModel): - network: str | None = None - pool_address: str | None = Field(None, alias='poolAddress', description='Pool address (optional - can be looked up from baseToken and quoteToken)') - base_token: str = Field(..., alias='baseToken', description='Token to determine swap direction') - quote_token: str | None = Field(None, alias='quoteToken', description='The other token in the pair (optional - required if poolAddress not provided)') - amount: Decimal - side: Side = Field(..., description='Trade direction') - slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct') - - -class ClmmQuoteSwapResponse(BaseModel): - pool_address: str = Field(..., alias='poolAddress') - token_in: str = Field(..., alias='tokenIn') - token_out: str = Field(..., alias='tokenOut') - amount_in: Decimal = Field(..., alias='amountIn') - amount_out: Decimal = Field(..., alias='amountOut') - price: Decimal - slippage_pct: Decimal | None = Field(None, alias='slippagePct') - min_amount_out: Decimal = Field(..., alias='minAmountOut') - max_amount_in: Decimal = Field(..., alias='maxAmountIn') - price_impact_pct: Decimal = Field(..., alias='priceImpactPct') - - -class ClmmExecuteSwapResponseData(BaseModel): - token_in: str = Field(..., alias='tokenIn') - token_out: str = Field(..., alias='tokenOut') - amount_in: Decimal = Field(..., alias='amountIn') - amount_out: Decimal = Field(..., alias='amountOut') - fee: Decimal - base_token_balance_change: Decimal = Field(..., alias='baseTokenBalanceChange') - quote_token_balance_change: Decimal = Field(..., alias='quoteTokenBalanceChange') - slippage_pct: Decimal | None = Field(None, alias='slippagePct', description='Slippage tolerance percentage actually applied to the swap') - - -class QuoteSwapRequest(BaseModel): - network: str | None = Field(None, description='The blockchain network to use') - base_token: str = Field(..., alias='baseToken', description='Token to determine swap direction') - quote_token: str = Field(..., alias='quoteToken', description='The other token in the pair') - amount: Decimal = Field(..., description='Amount of base token to trade') - side: Side = Field(..., description='Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token') - slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct', description='Maximum acceptable slippage percentage') - approximate_if_no_exact_out: bool | None = Field(True, alias='approximateIfNoExactOut', description='For BUY orders on routers without ExactOut support: approximate the required input via a sell-leg quote and return an ExactIn quote flagged as an approximation. If false, such BUY requests fail with a clear error.') - - -class QuoteSwapResponse(BaseModel): - quote_id: str = Field(..., alias='quoteId', description='Unique identifier for this quote') - token_in: str = Field(..., alias='tokenIn', description='Address of the token being swapped from') - token_out: str = Field(..., alias='tokenOut', description='Address of the token being swapped to') - amount_in: Decimal = Field(..., alias='amountIn', description='Amount of tokenIn to be swapped') - amount_out: Decimal = Field(..., alias='amountOut', description='Expected amount of tokenOut to receive') - price: Decimal = Field(..., description='Exchange rate between tokenIn and tokenOut') - price_impact_pct: Decimal = Field(..., alias='priceImpactPct', description='Estimated price impact percentage (0-100)') - min_amount_out: Decimal = Field(..., alias='minAmountOut', description='Minimum amount of tokenOut that will be accepted') - max_amount_in: Decimal = Field(..., alias='maxAmountIn', description='Maximum amount of tokenIn that will be spent') - approximation: bool | None = Field(None, description='True when a BUY was approximated via a sell-leg ExactIn quote because the router does not support ExactOut; amountOut is an estimate rather than exact') - - -class ExecuteQuoteRequest(BaseModel): - wallet_address: str | None = Field(None, alias='walletAddress', description='Wallet address that will execute the swap') - network: str | None = Field(None, description='The blockchain network to use') - quote_id: str = Field(..., alias='quoteId', description='ID of the quote to execute') - - -class ExecuteSwapRequest(BaseModel): - wallet_address: str | None = Field(None, alias='walletAddress', description='Wallet address that will execute the swap') - network: str | None = Field(None, description='The blockchain network to use') - base_token: str = Field(..., alias='baseToken', description='Token to determine swap direction') - quote_token: str = Field(..., alias='quoteToken', description='The other token in the pair') - amount: Decimal = Field(..., description='Amount of base token to trade') - side: Side = Field(..., description='Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token') - slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct', description='Maximum acceptable slippage percentage') - approximate_if_no_exact_out: bool | None = Field(True, alias='approximateIfNoExactOut', description='For BUY orders on routers without ExactOut support: approximate the required input via a sell-leg quote and execute an ExactIn swap. If false, such BUY requests fail with a clear error.') - - -class SwapExecuteResponseData(BaseModel): - token_in: str = Field(..., alias='tokenIn', description='Address of the token swapped from') - token_out: str = Field(..., alias='tokenOut', description='Address of the token swapped to') - amount_in: Decimal = Field(..., alias='amountIn', description='Actual amount of tokenIn swapped') - amount_out: Decimal = Field(..., alias='amountOut', description='Actual amount of tokenOut received') - fee: Decimal = Field(..., description='Transaction fee paid') - base_token_balance_change: Decimal = Field(..., alias='baseTokenBalanceChange', description='Change in base token balance (negative for decrease)') - quote_token_balance_change: Decimal = Field(..., alias='quoteTokenBalanceChange', description='Change in quote token balance (negative for decrease)') - slippage_pct: Decimal | None = Field(None, alias='slippagePct', description='Slippage tolerance percentage actually applied to the swap') - - class Connector(StrEnum): meteora = 'meteora' raydium = 'raydium' @@ -680,7 +406,35 @@ class AmmCloseRequest(BaseModel): slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct', description='Maximum acceptable slippage on the withdrawn amounts.', examples=[1]) -class Connector5(StrEnum): +class AmmPoolInfoRequest(BaseModel): + connector: Connector = Field(..., description='AMM connector', examples=['meteora']) + chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) + pool_address: str = Field(..., alias='poolAddress', description='Pool contract address') + + +class AmmPositionInfoRequest(BaseModel): + connector: Connector = Field(..., description='AMM connector', examples=['meteora']) + chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) + pool_address: str = Field(..., alias='poolAddress', description='Pool contract address') + wallet_address: str = Field(..., alias='walletAddress', description='Wallet address') + + +class AmmPositionsOwnedRequest(BaseModel): + connector: Connector = Field(..., description='AMM connector (only non-fungible-LP AMMs supported: meteora)', examples=['meteora']) + chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) + wallet_address: str = Field(..., alias='walletAddress', description='Wallet address to list positions for') + + +class AmmQuoteLiquidityRequest(BaseModel): + connector: Connector = Field(..., description='AMM connector', examples=['meteora']) + chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) + pool_address: str = Field(..., alias='poolAddress', description='Pool contract address') + base_token_amount: Decimal = Field(..., alias='baseTokenAmount', description='Amount of base token to deposit') + quote_token_amount: Decimal = Field(..., alias='quoteTokenAmount', description='Amount of quote token to deposit') + slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct', description="Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", examples=[1]) + + +class Connector9(StrEnum): meteora = 'meteora' raydium = 'raydium' pancakeswap_sol = 'pancakeswap-sol' @@ -690,7 +444,7 @@ class Connector5(StrEnum): class ClmmOpenRequest(BaseModel): - connector: Connector5 = Field(..., description='CLMM connector', examples=['meteora']) + connector: Connector9 = Field(..., description='CLMM connector', examples=['meteora']) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) wallet_address: str = Field(..., alias='walletAddress', description='Wallet address') lower_price: Decimal = Field(..., alias='lowerPrice', description='Lower price bound for the position', examples=[150]) @@ -703,7 +457,7 @@ class ClmmOpenRequest(BaseModel): class ClmmAddRequest(BaseModel): - connector: Connector5 = Field(..., description='CLMM connector', examples=['meteora']) + connector: Connector9 = Field(..., description='CLMM connector', examples=['meteora']) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) wallet_address: str = Field(..., alias='walletAddress', description='Wallet address') position_address: str = Field(..., alias='positionAddress', description='Position address', examples=['']) @@ -714,7 +468,7 @@ class ClmmAddRequest(BaseModel): class ClmmRemoveRequest(BaseModel): - connector: Connector5 = Field(..., description='CLMM connector', examples=['meteora']) + connector: Connector9 = Field(..., description='CLMM connector', examples=['meteora']) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) wallet_address: str = Field(..., alias='walletAddress', description='Wallet address') position_address: str = Field(..., alias='positionAddress', description='Position address', examples=['']) @@ -723,21 +477,21 @@ class ClmmRemoveRequest(BaseModel): class ClmmCollectFeesRequest(BaseModel): - connector: Connector5 = Field(..., description='CLMM connector', examples=['meteora']) + connector: Connector9 = Field(..., description='CLMM connector', examples=['meteora']) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) wallet_address: str = Field(..., alias='walletAddress', description='Wallet address') position_address: str = Field(..., alias='positionAddress', description='Position address', examples=['']) class ClmmCloseRequest(BaseModel): - connector: Connector5 = Field(..., description='CLMM connector', examples=['meteora']) + connector: Connector9 = Field(..., description='CLMM connector', examples=['meteora']) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) wallet_address: str = Field(..., alias='walletAddress', description='Wallet address') position_address: str = Field(..., alias='positionAddress', description='Position address', examples=['']) class ClmmCreatePoolRequest(BaseModel): - connector: Connector5 = Field(..., description='CLMM connector', examples=['meteora']) + connector: Connector9 = Field(..., description='CLMM connector', examples=['meteora']) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) wallet_address: str = Field(..., alias='walletAddress', description='Wallet address (pool creator + payer)') base_token: str = Field(..., alias='baseToken') @@ -748,7 +502,68 @@ class ClmmCreatePoolRequest(BaseModel): amm_config_index: float | None = Field(None, alias='ammConfigIndex', description='Fee-config index for the Raydium CLMM family: Raydium API config list index; pancakeswap-sol amm_config PDA index. Default 0.') -class Connector11(StrEnum): +class Connector15(StrEnum): + meteora = 'meteora' + orca = 'orca' + + +class SortDirection(StrEnum): + asc = 'asc' + desc = 'desc' + + +class ClmmFetchPoolsRequest(BaseModel): + chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) + connector: Connector15 = Field(..., description='CLMM connector whose pool-discovery API to query', examples=['meteora']) + limit: confloat(ge=1.0, le=1000.0) | None = Field(50, description='Maximum number of pools to return') + query: str | None = Field(None, description='Search pools by name, token, or address', examples=['SOL']) + sort_by: str | None = Field(None, alias='sortBy', description='Sort field. Meteora takes a "field:direction" pair; Orca takes the field alone with sortDirection.', examples=['tvl']) + page: confloat(ge=0.0) | None = Field(None, description='0-based page index. Only connectors whose API paginates honor this.') + include_unverified: bool | None = Field(None, alias='includeUnverified', description='Include unverified pools') + sort_direction: SortDirection | None = Field(None, alias='sortDirection', description='Sort direction') + verified_only: bool | None = Field(None, alias='verifiedOnly', description='Return only verified pools') + + +class Connector16(StrEnum): + meteora = 'meteora' + raydium = 'raydium' + pancakeswap_sol = 'pancakeswap-sol' + orca = 'orca' + uniswap = 'uniswap' + pancakeswap = 'pancakeswap' + + +class ClmmPoolInfoRequest(BaseModel): + connector: Connector16 = Field(..., description='CLMM connector', examples=['meteora']) + chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) + pool_address: str = Field(..., alias='poolAddress', description='Pool contract address', examples=['2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3']) + bin_count: conint(ge=0, le=401) | None = Field(0, alias='binCount', description='If > 0, include a `bins` array of per-tick liquidity around the active tick. Supported by every connector except Meteora, which always returns its bins and ignores this. Default 0 = skip the bin fetch.') + + +class ClmmPositionInfoRequest(BaseModel): + connector: Connector16 = Field(..., description='CLMM connector', examples=['meteora']) + chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) + position_address: str = Field(..., alias='positionAddress', description='Position address or NFT token ID', examples=['']) + + +class ClmmPositionsOwnedRequest(BaseModel): + connector: Connector16 = Field(..., description='CLMM connector', examples=['meteora']) + chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) + wallet_address: str = Field(..., alias='walletAddress', description='Wallet address') + + +class ClmmQuoteLiquidityRequest(BaseModel): + connector: Connector16 = Field(..., description='CLMM connector', examples=['meteora']) + chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) + lower_price: Decimal = Field(..., alias='lowerPrice', description='Lower price bound for the position', examples=[150]) + upper_price: Decimal = Field(..., alias='upperPrice', description='Upper price bound for the position', examples=[250]) + pool_address: str = Field(..., alias='poolAddress', description='Pool contract address', examples=['2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3']) + base_token_amount: Decimal | None = Field(None, alias='baseTokenAmount', description='Amount of base token to deposit', examples=[0.01]) + quote_token_amount: Decimal | None = Field(None, alias='quoteTokenAmount', description='Amount of quote token to deposit', examples=[2]) + slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct', description="Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", examples=[1]) + + +class Connector20(StrEnum): jupiter = 'jupiter' dflow = 'dflow' okx = 'okx' @@ -760,14 +575,19 @@ class Connector11(StrEnum): class RouterExecuteQuoteRequest(BaseModel): chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) - connector: Connector11 | None = Field('jupiter', description="Router connector. Defaults to the network's swapProvider", examples=['jupiter']) + connector: Connector20 | None = Field('jupiter', description="Router connector. Defaults to the network's swapProvider", examples=['jupiter']) wallet_address: str = Field(..., alias='walletAddress', description='Wallet address that will execute the quote') quote_id: str = Field(..., alias='quoteId', description='ID of a quote returned by /trading/router/quote-swap') +class Side(StrEnum): + buy = 'BUY' + sell = 'SELL' + + class RouterExecuteSwapRequest(BaseModel): chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) - connector: Connector11 | None = Field('jupiter', description="Router connector. Defaults to the network's swapProvider", examples=['jupiter']) + connector: Connector20 | None = Field('jupiter', description="Router connector. Defaults to the network's swapProvider", examples=['jupiter']) wallet_address: str = Field(..., alias='walletAddress', description='Wallet address that will execute the swap') base_token: str = Field(..., alias='baseToken', description='Symbol or address of the base token') quote_token: str = Field(..., alias='quoteToken', description='Symbol or address of the quote token') @@ -777,7 +597,58 @@ class RouterExecuteSwapRequest(BaseModel): approximate_if_no_exact_out: bool | None = Field(True, alias='approximateIfNoExactOut', description='For BUY orders when the router has no ExactOut route: approximate via a sell-leg ExactIn swap instead of failing.') -class Connector13(StrEnum): +class RouterQuoteSwapRequest(BaseModel): + chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) + connector: Connector20 | None = Field('jupiter', description="Router connector. Defaults to the network's swapProvider", examples=['jupiter']) + base_token: str = Field(..., alias='baseToken', description='Symbol or address of the base token') + quote_token: str = Field(..., alias='quoteToken', description='Symbol or address of the quote token') + amount: Decimal = Field(..., description='Amount of base token to trade') + side: Side = Field(..., description='BUY means buying base token with quote token, SELL means selling base token for quote token') + slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct', description="Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", examples=[1]) + wallet_address: str | None = Field(None, alias='walletAddress', description='Taker the quote is priced for. Required by routers that quote per-wallet or return wallet-specific calldata.') + approximate_if_no_exact_out: bool | None = Field(True, alias='approximateIfNoExactOut', description='For BUY orders when the router has no ExactOut route: approximate via a sell-leg ExactIn quote instead of failing.') + indicative_price: bool | None = Field(None, alias='indicativePrice', description='Return an indicative price instead of a firm, executable quote. An indicative quote cannot be executed with /trading/router/execute-quote.') + + +class Connector23(StrEnum): + meteora = 'meteora' + raydium = 'raydium' + uniswap = 'uniswap' + pancakeswap = 'pancakeswap' + + +class AmmQuoteSwapRequest(BaseModel): + chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) + connector: Connector23 | None = Field('meteora', description='AMM connector to price the swap against', examples=['meteora']) + base_token: str = Field(..., alias='baseToken', description='Symbol or address of the base token') + quote_token: str = Field(..., alias='quoteToken', description='Symbol or address of the quote token') + amount: Decimal = Field(..., description='Amount of base token to trade') + side: Side = Field(..., description='BUY means buying base token with quote token, SELL means selling base token for quote token') + pool_address: str | None = Field(None, alias='poolAddress', description="Pool to trade against. Omit to resolve it from Gateway's configured pool list by token pair; pass an address to pin a pool that is not in that list.") + slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct', description="Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", examples=[1]) + + +class Connector24(StrEnum): + meteora = 'meteora' + raydium = 'raydium' + orca = 'orca' + pancakeswap_sol = 'pancakeswap-sol' + uniswap = 'uniswap' + pancakeswap = 'pancakeswap' + + +class ClmmQuoteSwapRequest(BaseModel): + chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) + connector: Connector24 | None = Field('meteora', description='CLMM connector to price the swap against', examples=['meteora']) + base_token: str = Field(..., alias='baseToken', description='Symbol or address of the base token') + quote_token: str = Field(..., alias='quoteToken', description='Symbol or address of the quote token') + amount: Decimal = Field(..., description='Amount of base token to trade') + side: Side = Field(..., description='BUY means buying base token with quote token, SELL means selling base token for quote token') + pool_address: str | None = Field(None, alias='poolAddress', description="Pool to trade against. Omit to resolve it from Gateway's configured pool list by token pair; pass an address to pin a pool that is not in that list.") + slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct', description="Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", examples=[1]) + + +class Connector25(StrEnum): meteora = 'meteora' raydium = 'raydium' uniswap = 'uniswap' @@ -786,7 +657,7 @@ class Connector13(StrEnum): class AmmExecuteSwapRequest(BaseModel): chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) - connector: Connector13 | None = Field('meteora', description='AMM connector to execute the swap against', examples=['meteora']) + connector: Connector25 | None = Field('meteora', description='AMM connector to execute the swap against', examples=['meteora']) wallet_address: str = Field(..., alias='walletAddress', description='Wallet address that will execute the swap') base_token: str = Field(..., alias='baseToken', description='Symbol or address of the base token') quote_token: str = Field(..., alias='quoteToken', description='Symbol or address of the quote token') @@ -796,7 +667,7 @@ class AmmExecuteSwapRequest(BaseModel): slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct', description="Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", examples=[1]) -class Connector14(StrEnum): +class Connector26(StrEnum): meteora = 'meteora' raydium = 'raydium' orca = 'orca' @@ -807,7 +678,7 @@ class Connector14(StrEnum): class ClmmExecuteSwapRequest(BaseModel): chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) - connector: Connector14 | None = Field('meteora', description='CLMM connector to execute the swap against', examples=['meteora']) + connector: Connector26 | None = Field('meteora', description='CLMM connector to execute the swap against', examples=['meteora']) wallet_address: str = Field(..., alias='walletAddress', description='Wallet address that will execute the swap') base_token: str = Field(..., alias='baseToken', description='Symbol or address of the base token') quote_token: str = Field(..., alias='quoteToken', description='Symbol or address of the quote token') @@ -817,7 +688,7 @@ class ClmmExecuteSwapRequest(BaseModel): slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct', description="Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", examples=[1]) -class Network7(StrEnum): +class Network6(StrEnum): arbitrum = 'arbitrum' avalanche = 'avalanche' base = 'base' @@ -833,14 +704,14 @@ class Network7(StrEnum): class AllowancesRequest(BaseModel): - network: Network7 | None = Field('mainnet', description='The Ethereum network to use') + network: Network6 | None = Field('mainnet', description='The Ethereum network to use') address: str | None = Field('0xDA50C69342216b538Daf06FfECDa7363E0B96684', description='Ethereum wallet address') spender: str = Field(..., description='Connector name (e.g., uniswap/clmm, uniswap/amm, 0x/router) or contract address', examples=['uniswap/router']) tokens: list[str] = Field(..., description='Array of token symbols or addresses', examples=[['USDC', 'WETH']]) class ApproveRequest(BaseModel): - network: Network7 | None = Field('mainnet', description='The Ethereum network to use') + network: Network6 | None = Field('mainnet', description='The Ethereum network to use') address: str | None = Field('0xDA50C69342216b538Daf06FfECDa7363E0B96684', description='Ethereum wallet address') spender: str = Field(..., description='Connector name (e.g., uniswap/clmm, uniswap/amm, 0x/router) contract address', examples=['uniswap/router']) token: str = Field(..., description='Token symbol or address', examples=['USDC']) @@ -863,6 +734,14 @@ class AddHardwareWalletRequest(BaseModel): set_default: bool | None = Field(False, alias='setDefault', description='Set this wallet as the default for the chain') +class Token(BaseModel): + chain_id: float | None = Field(None, alias='chainId', description='The chain ID', examples=[1]) + name: str = Field(..., description='The full name of the token', examples=['USD Coin']) + symbol: str = Field(..., description='The token symbol', examples=['USDC']) + address: str = Field(..., description='The token contract address', examples=['0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48']) + decimals: confloat(ge=0.0, le=255.0) = Field(..., description='The number of decimals the token uses', examples=[6]) + + class AmmAddLiquidityResponse(BaseModel): signature: str status: float = Field(..., description='TransactionStatus enum value') @@ -895,12 +774,6 @@ class CreatePoolResponse(BaseModel): data: CreatePoolResponseData | None = None -class AmmExecuteSwapResponse(BaseModel): - signature: str - status: float = Field(..., description='TransactionStatus enum value') - data: AmmExecuteSwapResponseData | None = None - - class ChainExecuteSwapResponse(BaseModel): signature: str = Field(..., description='Transaction signature/hash') status: float = Field(..., description='Transaction status: 0 = PENDING, 1 = CONFIRMED, -1 = FAILED') @@ -949,15 +822,3 @@ class ClmmCreatePoolResponse(BaseModel): pool_address: str = Field(..., alias='poolAddress', description='Address of the newly created pool') price: Decimal | None = Field(None, description='Initial price the pool was initialized at (quote per base)') data: ClmmCreatePoolResponseData | None = None - - -class ClmmExecuteSwapResponse(BaseModel): - signature: str - status: float = Field(..., description='TransactionStatus enum value') - data: ClmmExecuteSwapResponseData | None = None - - -class SwapExecuteResponse(BaseModel): - signature: str = Field(..., description='Transaction signature/hash') - status: float = Field(..., description='Transaction status: 0 = PENDING, 1 = CONFIRMED, -1 = FAILED') - data: SwapExecuteResponseData | None = None From ad3a17b255d0471a25999a8ee9196821a665e6b4 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Wed, 19 Aug 2026 18:02:58 -0700 Subject: [PATCH 23/54] refactor(gateway): build /trading requests from the generated models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client hand-wrote every request as a camelCase dict. The 21 unified /trading methods now build their payload from the model generated off Gateway's spec, so a field Gateway renames fails here rather than going out wrong — the same move hummingbot's client made, and the same reason: this is the layer where a rename is otherwise silent. Regenerated with --ignore-enum-constraints. Gateway constrains `connector` and `network` by enum so its docs can offer dropdowns, and generating those as Python enums would bake a venue and network roster into this service — a connector added to Gateway after the last spec refresh would be rejected before the request left the process, which is exactly what resolve_swap_route exists to avoid. It also removes the only classes the generator had to number (Connector9, Connector15, ...), renamed by any unrelated route insertion. 93 classes down to 80. Three conversions, all in one place: _body dumps in python mode and widens Decimal to float. mode="json" renders it as a string, and Gateway declares these fields `type: number`. _query stringifies, which is all a URL carries. _wire_str keeps a whole number whole. Gateway types every numeric field as `number`, so pydantic holds a page index as a float and str() would render it "2.0" — the contract tests caught that on fetch-pools. Both drop None rather than sending null, replacing the `if x is not None` ladders: an absent parameter gets Gateway's default, an explicit null does not. quote_swap and execute_swap now reject a pool_address given for a router connector. Routers route across pools rather than executing against one, so RouterQuoteSwapRequest has no poolAddress — and pydantic drops an unknown keyword in silence, which would have read as though the pin applied. Connector-specific params (Meteora's strategyType, configAddress, ammConfigIndex) are merged after the model, not through it: they are named by the connector rather than the route, so no route schema declares them. test_gateway_models_match_spec gains a check that every keyword passed to a model is a field of it. Pydantic catches a misspelled *required* field, since the real one then goes missing, but drops a misspelled optional one — the request would go out without the slippage the caller asked for. Mutation-checked: renaming slippagePct at all 10 call sites fails it with all 10 named. --- Makefile | 8 + models/gateway_generated.py | 208 +++------- services/gateway_client.py | 516 +++++++++++++++---------- test/test_gateway_client_contract.py | 10 +- test/test_gateway_models_match_spec.py | 52 ++- 5 files changed, 424 insertions(+), 370 deletions(-) diff --git a/Makefile b/Makefile index 64b8dd57..4a507124 100644 --- a/Makefile +++ b/Makefile @@ -95,6 +95,13 @@ $(HASH) flake8: noqa: E501 endef export GATEWAY_MODELS_HEADER +# --ignore-enum-constraints keeps `connector` and `network` as plain strings. Gateway +# constrains them by enum so its docs can offer dropdowns, but generating those as Python +# enums would bake a connector and network roster into this service: a venue Gateway added +# after the last spec refresh would be rejected before the request left the process. It +# also removes the only classes the generator had to number (Connector9, Connector15, ...), +# which were renamed by any unrelated route insertion. +# # Regenerate models/gateway_generated.py from the vendored Gateway spec. # Adopting a Gateway change is two steps — refresh the spec, then rerun this: # cd ../gateway && pnpm generate:openapi && cp openapi.json ../hummingbot-api/gateway-openapi.json @@ -105,6 +112,7 @@ gateway-models: --input gateway-openapi.json --input-file-type openapi --openapi-scopes schemas \ --output models/gateway_generated.py --output-model-type pydantic_v2.BaseModel \ --snake-case-field --target-python-version 3.12 --disable-timestamp \ + --ignore-enum-constraints \ --formatters black --formatters isort \ --custom-file-header "$$GATEWAY_MODELS_HEADER" diff --git a/models/gateway_generated.py b/models/gateway_generated.py index 12358e29..4f405404 100644 --- a/models/gateway_generated.py +++ b/models/gateway_generated.py @@ -4,7 +4,6 @@ from __future__ import annotations from decimal import Decimal -from enum import StrEnum from typing import Any from pydantic import BaseModel, Field, condecimal, confloat, conint @@ -89,25 +88,8 @@ class AmmPositionInfo(BaseModel): positions: list[PositionDetail] | None = None -class Network(StrEnum): - devnet = 'devnet' - mainnet_beta = 'mainnet-beta' - arbitrum = 'arbitrum' - avalanche = 'avalanche' - base = 'base' - bsc = 'bsc' - celo = 'celo' - mainnet = 'mainnet' - optimism = 'optimism' - polygon = 'polygon' - robinhoodchain_testnet = 'robinhoodchain-testnet' - robinhoodchain = 'robinhoodchain' - sepolia = 'sepolia' - unichain = 'unichain' - - class EstimateGasRequest(BaseModel): - network: Network | None = Field(None, description="Network to use. Defaults to the chain's configured default network.", examples=['mainnet-beta']) + network: str | None = Field(None, description="Network to use. Defaults to the chain's configured default network.", examples=['mainnet-beta']) class EstimateGasResponse(BaseModel): @@ -125,7 +107,7 @@ class EstimateGasResponse(BaseModel): class BalanceRequest(BaseModel): - network: Network | None = Field(None, description="Network to use. Defaults to the chain's configured default network.", examples=['mainnet-beta']) + network: str | None = Field(None, description="Network to use. Defaults to the chain's configured default network.", examples=['mainnet-beta']) address: str | None = None tokens: list[str] | None = Field(None, description='a list of token symbols or addresses') fetch_all: bool | None = Field(None, alias='fetchAll', description='fetch all tokens in wallet, not just those in token list (default: false)') @@ -136,7 +118,7 @@ class BalanceResponse(BaseModel): class PollRequest(BaseModel): - network: Network | None = Field(None, description="Network to use. Defaults to the chain's configured default network.", examples=['mainnet-beta']) + network: str | None = Field(None, description="Network to use. Defaults to the chain's configured default network.", examples=['mainnet-beta']) signature: str = Field(..., description='Transaction signature/hash') @@ -151,7 +133,7 @@ class PollResponse(BaseModel): class StatusRequest(BaseModel): - network: Network | None = Field(None, description="Network to use. Defaults to the chain's configured default network.", examples=['mainnet-beta']) + network: str | None = Field(None, description="Network to use. Defaults to the chain's configured default network.", examples=['mainnet-beta']) class StatusResponse(BaseModel): @@ -191,13 +173,13 @@ class ChainExecuteSwapResponseData(BaseModel): class WrapRequest(BaseModel): - network: Network | None = Field(None, description="Network to use. Defaults to the chain's configured default network.", examples=['mainnet-beta']) + network: str | None = Field(None, description="Network to use. Defaults to the chain's configured default network.", examples=['mainnet-beta']) address: str = Field(..., description='Wallet address holding the native token') amount: str = Field(..., description='Amount of the native token to wrap, in whole units (not lamports/wei)', examples=['1.0']) class UnwrapRequest(BaseModel): - network: Network | None = Field(None, description="Network to use. Defaults to the chain's configured default network.", examples=['mainnet-beta']) + network: str | None = Field(None, description="Network to use. Defaults to the chain's configured default network.", examples=['mainnet-beta']) address: str = Field(..., description='Wallet address holding the wrapped token') amount: str | None = Field(None, description='Amount of the wrapped token to unwrap, in whole units. Solana unwraps the full balance when omitted; EVM chains require it.', examples=['1.0']) @@ -345,15 +327,8 @@ class QuotePositionResponse(BaseModel): liquidity: Any | None = None -class Connector(StrEnum): - meteora = 'meteora' - raydium = 'raydium' - uniswap = 'uniswap' - pancakeswap = 'pancakeswap' - - class AmmCreatePoolRequest(BaseModel): - connector: Connector = Field(..., description='AMM connector', examples=['meteora']) + connector: str = Field(..., description='AMM connector', examples=['meteora']) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) wallet_address: str = Field(..., alias='walletAddress', description='Wallet address (pool creator + payer)') base_token: str = Field(..., alias='baseToken', description='Base token symbol or address (becomes the pool base)') @@ -367,7 +342,7 @@ class AmmCreatePoolRequest(BaseModel): class AmmAddRequest(BaseModel): - connector: Connector = Field(..., description='AMM connector', examples=['meteora']) + connector: str = Field(..., description='AMM connector', examples=['meteora']) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) wallet_address: str = Field(..., alias='walletAddress', description='Wallet address') pool_address: str = Field(..., alias='poolAddress', description='Pool contract address') @@ -378,7 +353,7 @@ class AmmAddRequest(BaseModel): class AmmRemoveRequest(BaseModel): - connector: Connector = Field(..., description='AMM connector', examples=['meteora']) + connector: str = Field(..., description='AMM connector', examples=['meteora']) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) wallet_address: str = Field(..., alias='walletAddress', description='Wallet address') pool_address: str = Field(..., alias='poolAddress', description='Pool contract address') @@ -388,7 +363,7 @@ class AmmRemoveRequest(BaseModel): class AmmOpenRequest(BaseModel): - connector: Connector = Field(..., description='AMM connector', examples=['meteora']) + connector: str = Field(..., description='AMM connector', examples=['meteora']) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) wallet_address: str = Field(..., alias='walletAddress', description='Wallet that will own the position') pool_address: str = Field(..., alias='poolAddress', description='Pool to open the position in') @@ -398,7 +373,7 @@ class AmmOpenRequest(BaseModel): class AmmCloseRequest(BaseModel): - connector: Connector = Field(..., description='AMM connector', examples=['meteora']) + connector: str = Field(..., description='AMM connector', examples=['meteora']) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) wallet_address: str = Field(..., alias='walletAddress', description='Wallet that owns the position') pool_address: str = Field(..., alias='poolAddress', description='Pool the position belongs to') @@ -407,26 +382,26 @@ class AmmCloseRequest(BaseModel): class AmmPoolInfoRequest(BaseModel): - connector: Connector = Field(..., description='AMM connector', examples=['meteora']) + connector: str = Field(..., description='AMM connector', examples=['meteora']) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) pool_address: str = Field(..., alias='poolAddress', description='Pool contract address') class AmmPositionInfoRequest(BaseModel): - connector: Connector = Field(..., description='AMM connector', examples=['meteora']) + connector: str = Field(..., description='AMM connector', examples=['meteora']) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) pool_address: str = Field(..., alias='poolAddress', description='Pool contract address') wallet_address: str = Field(..., alias='walletAddress', description='Wallet address') class AmmPositionsOwnedRequest(BaseModel): - connector: Connector = Field(..., description='AMM connector (only non-fungible-LP AMMs supported: meteora)', examples=['meteora']) + connector: str = Field(..., description='AMM connector (only non-fungible-LP AMMs supported: meteora)', examples=['meteora']) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) wallet_address: str = Field(..., alias='walletAddress', description='Wallet address to list positions for') class AmmQuoteLiquidityRequest(BaseModel): - connector: Connector = Field(..., description='AMM connector', examples=['meteora']) + connector: str = Field(..., description='AMM connector', examples=['meteora']) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) pool_address: str = Field(..., alias='poolAddress', description='Pool contract address') base_token_amount: Decimal = Field(..., alias='baseTokenAmount', description='Amount of base token to deposit') @@ -434,17 +409,8 @@ class AmmQuoteLiquidityRequest(BaseModel): slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct', description="Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", examples=[1]) -class Connector9(StrEnum): - meteora = 'meteora' - raydium = 'raydium' - pancakeswap_sol = 'pancakeswap-sol' - orca = 'orca' - uniswap = 'uniswap' - pancakeswap = 'pancakeswap' - - class ClmmOpenRequest(BaseModel): - connector: Connector9 = Field(..., description='CLMM connector', examples=['meteora']) + connector: str = Field(..., description='CLMM connector', examples=['meteora']) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) wallet_address: str = Field(..., alias='walletAddress', description='Wallet address') lower_price: Decimal = Field(..., alias='lowerPrice', description='Lower price bound for the position', examples=[150]) @@ -457,7 +423,7 @@ class ClmmOpenRequest(BaseModel): class ClmmAddRequest(BaseModel): - connector: Connector9 = Field(..., description='CLMM connector', examples=['meteora']) + connector: str = Field(..., description='CLMM connector', examples=['meteora']) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) wallet_address: str = Field(..., alias='walletAddress', description='Wallet address') position_address: str = Field(..., alias='positionAddress', description='Position address', examples=['']) @@ -468,7 +434,7 @@ class ClmmAddRequest(BaseModel): class ClmmRemoveRequest(BaseModel): - connector: Connector9 = Field(..., description='CLMM connector', examples=['meteora']) + connector: str = Field(..., description='CLMM connector', examples=['meteora']) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) wallet_address: str = Field(..., alias='walletAddress', description='Wallet address') position_address: str = Field(..., alias='positionAddress', description='Position address', examples=['']) @@ -477,21 +443,21 @@ class ClmmRemoveRequest(BaseModel): class ClmmCollectFeesRequest(BaseModel): - connector: Connector9 = Field(..., description='CLMM connector', examples=['meteora']) + connector: str = Field(..., description='CLMM connector', examples=['meteora']) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) wallet_address: str = Field(..., alias='walletAddress', description='Wallet address') position_address: str = Field(..., alias='positionAddress', description='Position address', examples=['']) class ClmmCloseRequest(BaseModel): - connector: Connector9 = Field(..., description='CLMM connector', examples=['meteora']) + connector: str = Field(..., description='CLMM connector', examples=['meteora']) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) wallet_address: str = Field(..., alias='walletAddress', description='Wallet address') position_address: str = Field(..., alias='positionAddress', description='Position address', examples=['']) class ClmmCreatePoolRequest(BaseModel): - connector: Connector9 = Field(..., description='CLMM connector', examples=['meteora']) + connector: str = Field(..., description='CLMM connector', examples=['meteora']) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) wallet_address: str = Field(..., alias='walletAddress', description='Wallet address (pool creator + payer)') base_token: str = Field(..., alias='baseToken') @@ -502,58 +468,39 @@ class ClmmCreatePoolRequest(BaseModel): amm_config_index: float | None = Field(None, alias='ammConfigIndex', description='Fee-config index for the Raydium CLMM family: Raydium API config list index; pancakeswap-sol amm_config PDA index. Default 0.') -class Connector15(StrEnum): - meteora = 'meteora' - orca = 'orca' - - -class SortDirection(StrEnum): - asc = 'asc' - desc = 'desc' - - class ClmmFetchPoolsRequest(BaseModel): chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) - connector: Connector15 = Field(..., description='CLMM connector whose pool-discovery API to query', examples=['meteora']) + connector: str = Field(..., description='CLMM connector whose pool-discovery API to query', examples=['meteora']) limit: confloat(ge=1.0, le=1000.0) | None = Field(50, description='Maximum number of pools to return') query: str | None = Field(None, description='Search pools by name, token, or address', examples=['SOL']) sort_by: str | None = Field(None, alias='sortBy', description='Sort field. Meteora takes a "field:direction" pair; Orca takes the field alone with sortDirection.', examples=['tvl']) page: confloat(ge=0.0) | None = Field(None, description='0-based page index. Only connectors whose API paginates honor this.') include_unverified: bool | None = Field(None, alias='includeUnverified', description='Include unverified pools') - sort_direction: SortDirection | None = Field(None, alias='sortDirection', description='Sort direction') + sort_direction: str | None = Field(None, alias='sortDirection', description='Sort direction') verified_only: bool | None = Field(None, alias='verifiedOnly', description='Return only verified pools') -class Connector16(StrEnum): - meteora = 'meteora' - raydium = 'raydium' - pancakeswap_sol = 'pancakeswap-sol' - orca = 'orca' - uniswap = 'uniswap' - pancakeswap = 'pancakeswap' - - class ClmmPoolInfoRequest(BaseModel): - connector: Connector16 = Field(..., description='CLMM connector', examples=['meteora']) + connector: str = Field(..., description='CLMM connector', examples=['meteora']) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) pool_address: str = Field(..., alias='poolAddress', description='Pool contract address', examples=['2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3']) bin_count: conint(ge=0, le=401) | None = Field(0, alias='binCount', description='If > 0, include a `bins` array of per-tick liquidity around the active tick. Supported by every connector except Meteora, which always returns its bins and ignores this. Default 0 = skip the bin fetch.') class ClmmPositionInfoRequest(BaseModel): - connector: Connector16 = Field(..., description='CLMM connector', examples=['meteora']) + connector: str = Field(..., description='CLMM connector', examples=['meteora']) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) position_address: str = Field(..., alias='positionAddress', description='Position address or NFT token ID', examples=['']) class ClmmPositionsOwnedRequest(BaseModel): - connector: Connector16 = Field(..., description='CLMM connector', examples=['meteora']) + connector: str = Field(..., description='CLMM connector', examples=['meteora']) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) wallet_address: str = Field(..., alias='walletAddress', description='Wallet address') class ClmmQuoteLiquidityRequest(BaseModel): - connector: Connector16 = Field(..., description='CLMM connector', examples=['meteora']) + connector: str = Field(..., description='CLMM connector', examples=['meteora']) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) lower_price: Decimal = Field(..., alias='lowerPrice', description='Lower price bound for the position', examples=[150]) upper_price: Decimal = Field(..., alias='upperPrice', description='Upper price bound for the position', examples=[250]) @@ -563,173 +510,106 @@ class ClmmQuoteLiquidityRequest(BaseModel): slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct', description="Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", examples=[1]) -class Connector20(StrEnum): - jupiter = 'jupiter' - dflow = 'dflow' - okx = 'okx' - titan = 'titan' - uniswap = 'uniswap' - pancakeswap = 'pancakeswap' - field_0x = '0x' - - class RouterExecuteQuoteRequest(BaseModel): chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) - connector: Connector20 | None = Field('jupiter', description="Router connector. Defaults to the network's swapProvider", examples=['jupiter']) + connector: str | None = Field('jupiter', description="Router connector. Defaults to the network's swapProvider", examples=['jupiter']) wallet_address: str = Field(..., alias='walletAddress', description='Wallet address that will execute the quote') quote_id: str = Field(..., alias='quoteId', description='ID of a quote returned by /trading/router/quote-swap') -class Side(StrEnum): - buy = 'BUY' - sell = 'SELL' - - class RouterExecuteSwapRequest(BaseModel): chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) - connector: Connector20 | None = Field('jupiter', description="Router connector. Defaults to the network's swapProvider", examples=['jupiter']) + connector: str | None = Field('jupiter', description="Router connector. Defaults to the network's swapProvider", examples=['jupiter']) wallet_address: str = Field(..., alias='walletAddress', description='Wallet address that will execute the swap') base_token: str = Field(..., alias='baseToken', description='Symbol or address of the base token') quote_token: str = Field(..., alias='quoteToken', description='Symbol or address of the quote token') amount: Decimal = Field(..., description='Amount of base token to trade') - side: Side = Field(..., description='BUY means buying base token with quote token, SELL means selling base token for quote token') + side: str = Field(..., description='BUY means buying base token with quote token, SELL means selling base token for quote token') slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct', description="Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", examples=[1]) approximate_if_no_exact_out: bool | None = Field(True, alias='approximateIfNoExactOut', description='For BUY orders when the router has no ExactOut route: approximate via a sell-leg ExactIn swap instead of failing.') class RouterQuoteSwapRequest(BaseModel): chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) - connector: Connector20 | None = Field('jupiter', description="Router connector. Defaults to the network's swapProvider", examples=['jupiter']) + connector: str | None = Field('jupiter', description="Router connector. Defaults to the network's swapProvider", examples=['jupiter']) base_token: str = Field(..., alias='baseToken', description='Symbol or address of the base token') quote_token: str = Field(..., alias='quoteToken', description='Symbol or address of the quote token') amount: Decimal = Field(..., description='Amount of base token to trade') - side: Side = Field(..., description='BUY means buying base token with quote token, SELL means selling base token for quote token') + side: str = Field(..., description='BUY means buying base token with quote token, SELL means selling base token for quote token') slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct', description="Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", examples=[1]) wallet_address: str | None = Field(None, alias='walletAddress', description='Taker the quote is priced for. Required by routers that quote per-wallet or return wallet-specific calldata.') approximate_if_no_exact_out: bool | None = Field(True, alias='approximateIfNoExactOut', description='For BUY orders when the router has no ExactOut route: approximate via a sell-leg ExactIn quote instead of failing.') indicative_price: bool | None = Field(None, alias='indicativePrice', description='Return an indicative price instead of a firm, executable quote. An indicative quote cannot be executed with /trading/router/execute-quote.') -class Connector23(StrEnum): - meteora = 'meteora' - raydium = 'raydium' - uniswap = 'uniswap' - pancakeswap = 'pancakeswap' - - class AmmQuoteSwapRequest(BaseModel): chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) - connector: Connector23 | None = Field('meteora', description='AMM connector to price the swap against', examples=['meteora']) + connector: str | None = Field('meteora', description='AMM connector to price the swap against', examples=['meteora']) base_token: str = Field(..., alias='baseToken', description='Symbol or address of the base token') quote_token: str = Field(..., alias='quoteToken', description='Symbol or address of the quote token') amount: Decimal = Field(..., description='Amount of base token to trade') - side: Side = Field(..., description='BUY means buying base token with quote token, SELL means selling base token for quote token') + side: str = Field(..., description='BUY means buying base token with quote token, SELL means selling base token for quote token') pool_address: str | None = Field(None, alias='poolAddress', description="Pool to trade against. Omit to resolve it from Gateway's configured pool list by token pair; pass an address to pin a pool that is not in that list.") slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct', description="Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", examples=[1]) -class Connector24(StrEnum): - meteora = 'meteora' - raydium = 'raydium' - orca = 'orca' - pancakeswap_sol = 'pancakeswap-sol' - uniswap = 'uniswap' - pancakeswap = 'pancakeswap' - - class ClmmQuoteSwapRequest(BaseModel): chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) - connector: Connector24 | None = Field('meteora', description='CLMM connector to price the swap against', examples=['meteora']) + connector: str | None = Field('meteora', description='CLMM connector to price the swap against', examples=['meteora']) base_token: str = Field(..., alias='baseToken', description='Symbol or address of the base token') quote_token: str = Field(..., alias='quoteToken', description='Symbol or address of the quote token') amount: Decimal = Field(..., description='Amount of base token to trade') - side: Side = Field(..., description='BUY means buying base token with quote token, SELL means selling base token for quote token') + side: str = Field(..., description='BUY means buying base token with quote token, SELL means selling base token for quote token') pool_address: str | None = Field(None, alias='poolAddress', description="Pool to trade against. Omit to resolve it from Gateway's configured pool list by token pair; pass an address to pin a pool that is not in that list.") slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct', description="Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", examples=[1]) -class Connector25(StrEnum): - meteora = 'meteora' - raydium = 'raydium' - uniswap = 'uniswap' - pancakeswap = 'pancakeswap' - - class AmmExecuteSwapRequest(BaseModel): chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) - connector: Connector25 | None = Field('meteora', description='AMM connector to execute the swap against', examples=['meteora']) + connector: str | None = Field('meteora', description='AMM connector to execute the swap against', examples=['meteora']) wallet_address: str = Field(..., alias='walletAddress', description='Wallet address that will execute the swap') base_token: str = Field(..., alias='baseToken', description='Symbol or address of the base token') quote_token: str = Field(..., alias='quoteToken', description='Symbol or address of the quote token') amount: Decimal = Field(..., description='Amount of base token to trade') - side: Side = Field(..., description='BUY means buying base token with quote token, SELL means selling base token for quote token') + side: str = Field(..., description='BUY means buying base token with quote token, SELL means selling base token for quote token') pool_address: str | None = Field(None, alias='poolAddress', description="Pool to trade against. Omit to resolve it from Gateway's configured pool list by token pair; pass an address to pin a pool that is not in that list.") slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct', description="Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", examples=[1]) -class Connector26(StrEnum): - meteora = 'meteora' - raydium = 'raydium' - orca = 'orca' - pancakeswap_sol = 'pancakeswap-sol' - uniswap = 'uniswap' - pancakeswap = 'pancakeswap' - - class ClmmExecuteSwapRequest(BaseModel): chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) - connector: Connector26 | None = Field('meteora', description='CLMM connector to execute the swap against', examples=['meteora']) + connector: str | None = Field('meteora', description='CLMM connector to execute the swap against', examples=['meteora']) wallet_address: str = Field(..., alias='walletAddress', description='Wallet address that will execute the swap') base_token: str = Field(..., alias='baseToken', description='Symbol or address of the base token') quote_token: str = Field(..., alias='quoteToken', description='Symbol or address of the quote token') amount: Decimal = Field(..., description='Amount of base token to trade') - side: Side = Field(..., description='BUY means buying base token with quote token, SELL means selling base token for quote token') + side: str = Field(..., description='BUY means buying base token with quote token, SELL means selling base token for quote token') pool_address: str | None = Field(None, alias='poolAddress', description="Pool to trade against. Omit to resolve it from Gateway's configured pool list by token pair; pass an address to pin a pool that is not in that list.") slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct', description="Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", examples=[1]) -class Network6(StrEnum): - arbitrum = 'arbitrum' - avalanche = 'avalanche' - base = 'base' - bsc = 'bsc' - celo = 'celo' - mainnet = 'mainnet' - optimism = 'optimism' - polygon = 'polygon' - robinhoodchain_testnet = 'robinhoodchain-testnet' - robinhoodchain = 'robinhoodchain' - sepolia = 'sepolia' - unichain = 'unichain' - - class AllowancesRequest(BaseModel): - network: Network6 | None = Field('mainnet', description='The Ethereum network to use') + network: str | None = Field('mainnet', description='The Ethereum network to use') address: str | None = Field('0xDA50C69342216b538Daf06FfECDa7363E0B96684', description='Ethereum wallet address') spender: str = Field(..., description='Connector name (e.g., uniswap/clmm, uniswap/amm, 0x/router) or contract address', examples=['uniswap/router']) tokens: list[str] = Field(..., description='Array of token symbols or addresses', examples=[['USDC', 'WETH']]) class ApproveRequest(BaseModel): - network: Network6 | None = Field('mainnet', description='The Ethereum network to use') + network: str | None = Field('mainnet', description='The Ethereum network to use') address: str | None = Field('0xDA50C69342216b538Daf06FfECDa7363E0B96684', description='Ethereum wallet address') spender: str = Field(..., description='Connector name (e.g., uniswap/clmm, uniswap/amm, 0x/router) contract address', examples=['uniswap/router']) token: str = Field(..., description='Token symbol or address', examples=['USDC']) amount: str | None = Field('', description='The amount to approve. If not provided, defaults to maximum amount (unlimited approval).') -class Chain(StrEnum): - ethereum = 'ethereum' - solana = 'solana' - - class RemoveWalletRequest(BaseModel): - chain: Chain = Field(..., description='Blockchain to remove wallet from', examples=['solana']) + chain: str = Field(..., description='Blockchain to remove wallet from', examples=['solana']) address: str = Field(..., description='Wallet address to remove') class AddHardwareWalletRequest(BaseModel): - chain: Chain = Field(..., description='Blockchain for hardware wallet', examples=['solana']) + chain: str = Field(..., description='Blockchain for hardware wallet', examples=['solana']) address: str = Field(..., description='Hardware wallet address to add (must exist on connected Ledger device)') set_default: bool | None = Field(False, alias='setDefault', description='Set this wallet as the default for the chain') diff --git a/services/gateway_client.py b/services/gateway_client.py index c96be56e..b0fc9801 100644 --- a/services/gateway_client.py +++ b/services/gateway_client.py @@ -1,11 +1,99 @@ import logging import ssl +from decimal import Decimal from typing import Any, Callable, Dict, List, Optional import aiohttp +from models.gateway_generated import ( + AmmAddRequest, + AmmCreatePoolRequest, + AmmExecuteSwapRequest, + AmmPoolInfoRequest, + AmmPositionInfoRequest, + AmmPositionsOwnedRequest, + AmmQuoteLiquidityRequest, + AmmQuoteSwapRequest, + AmmRemoveRequest, + ClmmAddRequest, + ClmmCloseRequest, + ClmmCollectFeesRequest, + ClmmCreatePoolRequest, + ClmmExecuteSwapRequest, + ClmmFetchPoolsRequest, + ClmmOpenRequest, + ClmmPoolInfoRequest, + ClmmPositionInfoRequest, + ClmmPositionsOwnedRequest, + ClmmQuoteLiquidityRequest, + ClmmQuoteSwapRequest, + ClmmRemoveRequest, + RouterExecuteSwapRequest, + RouterQuoteSwapRequest, +) + logger = logging.getLogger(__name__) +# The request model for each unified /trading route, keyed by the trading type resolved +# at call time. The three surfaces do not take the same fields — only the router accepts +# approximateIfNoExactOut, only the pool-scoped ones accept poolAddress — so each has its +# own model rather than one shape covering all three. +_QUOTE_SWAP_REQUESTS = { + "router": RouterQuoteSwapRequest, + "clmm": ClmmQuoteSwapRequest, + "amm": AmmQuoteSwapRequest, +} +_EXECUTE_SWAP_REQUESTS = { + "router": RouterExecuteSwapRequest, + "clmm": ClmmExecuteSwapRequest, + "amm": AmmExecuteSwapRequest, +} + + +def _wire_str(value: Any) -> str: + """A query value as text, keeping whole numbers whole. + + Gateway types every numeric field as `number`, so pydantic holds a page index or a + row limit as a float and ``str`` would render it "2.0" — which is not what a page + index looks like to the DEX listing APIs behind fetch-pools. Integral values are + emitted without the fractional part; the amounts are unaffected either way, since + Gateway coerces the string back per its schema. + """ + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float, Decimal)) and value == int(value): + return str(int(value)) + return str(value) + + +def _query(request: Any) -> Dict[str, str]: + """A request model as query parameters. + + Everything is stringified because aiohttp rejects a non-string query value, and + Gateway coerces the strings back per its schema. Fields left as None are dropped: + Gateway applies its own default for an absent parameter, which is not the same as + being told the value is null. + """ + return { + key: _wire_str(value) + for key, value in request.model_dump(by_alias=True, exclude_none=True).items() + } + + +def _body(request: Any) -> Dict[str, Any]: + """A request model as a JSON body. + + Dumped in python mode and widened here rather than with ``mode="json"``, which + renders Decimal as a string. Gateway declares these fields as `type: number` — its + `decimal` format tells a client to *hold* the value as a decimal, not to send it as + text — so a string would arrive as the wrong JSON type. + """ + return { + key: (float(value) if isinstance(value, Decimal) else value) + for key, value in request.model_dump(by_alias=True, exclude_none=True).items() + } + + # When a caller names a connector without a trading type, Gateway's own # config/connectors listing decides the type — preferring a router route, then # CLMM, then AMM. A hardcoded roster silently misrouted every connector Gateway @@ -558,21 +646,33 @@ async def quote_swap( (e.g. approximateIfNoExactOut). The router validates keys first. """ name, trading_type = await self.resolve_swap_route(connector) - params = { - "chainNetwork": chain_network, - "connector": name, - "baseToken": base_asset, - "quoteToken": quote_asset, - "amount": str(amount), - "side": side.upper() - } - if slippage_pct is not None: - params["slippagePct"] = str(slippage_pct) - if pool_address: - params["poolAddress"] = pool_address + request_model = _QUOTE_SWAP_REQUESTS[trading_type] + if pool_address and trading_type == "router": + # The router model has no poolAddress, and pydantic drops an unknown keyword + # silently — which would look like the pin applied. Routers choose their own + # path across pools, so there is nothing to pin. + raise ValueError( + f"Router connector '{name}' does not take a pool_address: a router routes " + "across pools rather than executing against one. Name an amm or clmm " + "connector to pin a pool." + ) + params = _query( + request_model( + chainNetwork=chain_network, + connector=name, + baseToken=base_asset, + quoteToken=quote_asset, + amount=amount, + side=side.upper(), + slippagePct=slippage_pct, + poolAddress=pool_address or None, + ) + ) if extra_params: - # Query params must be strings for aiohttp; Gateway's schema coerces - # "true"/"false" back to booleans. + # Connector-specific params, merged after the model: they are named by the + # connector rather than the route, so no route schema declares them. Query + # params must be strings for aiohttp; Gateway's schema coerces "true"/"false" + # back to booleans. for key, value in extra_params.items(): params[key] = str(value).lower() if isinstance(value, bool) else str(value) @@ -599,20 +699,29 @@ async def execute_swap( own names (e.g. approximateIfNoExactOut); the router validates keys first. """ name, trading_type = await self.resolve_swap_route(connector) - payload = { - "chainNetwork": chain_network, - "connector": name, - "walletAddress": wallet_address, - "baseToken": base_asset, - "quoteToken": quote_asset, - "amount": amount, - "side": side.upper() - } - if slippage_pct is not None: - payload["slippagePct"] = slippage_pct - if pool_address: - payload["poolAddress"] = pool_address + if pool_address and trading_type == "router": + # See quote_swap: the router model has no poolAddress and pydantic would drop + # it in silence, which would read as though the pin applied. + raise ValueError( + f"Router connector '{name}' does not take a pool_address: a router routes " + "across pools rather than executing against one. Name an amm or clmm " + "connector to pin a pool." + ) + payload = _body( + _EXECUTE_SWAP_REQUESTS[trading_type]( + chainNetwork=chain_network, + connector=name, + walletAddress=wallet_address, + baseToken=base_asset, + quoteToken=quote_asset, + amount=amount, + side=side.upper(), + slippagePct=slippage_pct, + poolAddress=pool_address or None, + ) + ) if extra_params: + # Connector-specific params, merged after the model — see quote_swap. payload.update(extra_params) return await self._request("POST", f"trading/{trading_type}/execute-swap", json=payload) @@ -635,22 +744,23 @@ async def clmm_open_position( extra_params: Optional[Dict] = None ) -> Dict: """Open a NEW CLMM position with initial liquidity""" - payload = { - "connector": connector, - "chainNetwork": chain_network, - "walletAddress": wallet_address, - "poolAddress": pool_address, - "lowerPrice": lower_price, - "upperPrice": upper_price - } - if base_token_amount is not None: - payload["baseTokenAmount"] = base_token_amount - if quote_token_amount is not None: - payload["quoteTokenAmount"] = quote_token_amount - if slippage_pct is not None: - payload["slippagePct"] = slippage_pct - - # Connector-specific parameters (e.g. Meteora's strategyType) + payload = _body( + ClmmOpenRequest( + connector=connector, + chainNetwork=chain_network, + walletAddress=wallet_address, + poolAddress=pool_address, + lowerPrice=lower_price, + upperPrice=upper_price, + baseTokenAmount=base_token_amount, + quoteTokenAmount=quote_token_amount, + slippagePct=slippage_pct, + ) + ) + + # Connector-specific parameters (e.g. Meteora's strategyType), merged after the + # model: they are named by the connector rather than the route, so no route + # schema declares them. if extra_params: payload.update(extra_params) @@ -668,20 +778,19 @@ async def clmm_add_liquidity( extra_params: Optional[Dict] = None ) -> Dict: """Add more liquidity to an existing CLMM position""" - payload = { - "connector": connector, - "chainNetwork": chain_network, - "walletAddress": wallet_address, - "positionAddress": position_address - } - if base_token_amount is not None: - payload["baseTokenAmount"] = base_token_amount - if quote_token_amount is not None: - payload["quoteTokenAmount"] = quote_token_amount - if slippage_pct is not None: - payload["slippagePct"] = slippage_pct - - # Connector-specific parameters (e.g. Meteora's strategyType) + payload = _body( + ClmmAddRequest( + connector=connector, + chainNetwork=chain_network, + walletAddress=wallet_address, + positionAddress=position_address, + baseTokenAmount=base_token_amount, + quoteTokenAmount=quote_token_amount, + slippagePct=slippage_pct, + ) + ) + + # Connector-specific parameters, merged after the model — see clmm_open_position. if extra_params: payload.update(extra_params) @@ -695,12 +804,14 @@ async def clmm_close_position( position_address: str ) -> Dict: """Close a CLMM position completely""" - return await self._request("POST", "trading/clmm/close", json={ - "connector": connector, - "chainNetwork": chain_network, - "walletAddress": wallet_address, - "positionAddress": position_address - }) + return await self._request("POST", "trading/clmm/close", json=_body( + ClmmCloseRequest( + connector=connector, + chainNetwork=chain_network, + walletAddress=wallet_address, + positionAddress=position_address, + ) + )) async def clmm_remove_liquidity( self, @@ -715,15 +826,16 @@ async def clmm_remove_liquidity( slippage_pct is only honored by the Orca connector; others ignore it. """ - payload = { - "connector": connector, - "chainNetwork": chain_network, - "walletAddress": wallet_address, - "positionAddress": position_address, - "percentageToRemove": percentage_to_remove - } - if slippage_pct is not None: - payload["slippagePct"] = slippage_pct + payload = _body( + ClmmRemoveRequest( + connector=connector, + chainNetwork=chain_network, + walletAddress=wallet_address, + positionAddress=position_address, + percentageToRemove=percentage_to_remove, + slippagePct=slippage_pct, + ) + ) return await self._request("POST", "trading/clmm/remove", json=payload) @@ -747,11 +859,13 @@ async def clmm_position_info( if not position_address: raise ValueError("position_address is required for clmm_position_info") - params = { - "connector": connector, - "chainNetwork": chain_network, - "positionAddress": position_address - } + params = _query( + ClmmPositionInfoRequest( + connector=connector, + chainNetwork=chain_network, + positionAddress=position_address, + ) + ) return await self._request("GET", "trading/clmm/position-info", params=params) async def clmm_positions_owned( @@ -782,11 +896,13 @@ async def clmm_positions_owned( - lowerBinId, upperBinId - lowerPrice, upperPrice, price """ - params = { - "connector": connector, - "chainNetwork": chain_network, - "walletAddress": wallet_address, - } + params = _query( + ClmmPositionsOwnedRequest( + connector=connector, + chainNetwork=chain_network, + walletAddress=wallet_address, + ) + ) return await self._request("GET", "trading/clmm/positions-owned", params=params) async def clmm_quote_position( @@ -801,19 +917,18 @@ async def clmm_quote_position( slippage_pct: Optional[float] = None, ) -> Dict: """Quote the base/quote split a candidate position would take, without signing anything.""" - params = { - "connector": connector, - "chainNetwork": chain_network, - "poolAddress": pool_address, - "lowerPrice": lower_price, - "upperPrice": upper_price, - } - if base_token_amount is not None: - params["baseTokenAmount"] = base_token_amount - if quote_token_amount is not None: - params["quoteTokenAmount"] = quote_token_amount - if slippage_pct is not None: - params["slippagePct"] = slippage_pct + params = _query( + ClmmQuoteLiquidityRequest( + connector=connector, + chainNetwork=chain_network, + poolAddress=pool_address, + lowerPrice=lower_price, + upperPrice=upper_price, + baseTokenAmount=base_token_amount, + quoteTokenAmount=quote_token_amount, + slippagePct=slippage_pct, + ) + ) return await self._request("GET", "trading/clmm/quote-liquidity", params=params) async def clmm_create_pool( @@ -833,15 +948,16 @@ async def clmm_create_pool( same contract as clmm open's extra_params. The router validates keys before this is called. """ - payload = { - "connector": connector, - "chainNetwork": chain_network, - "walletAddress": wallet_address, - "baseToken": base_token, - "quoteToken": quote_token, - } - if initial_price is not None: - payload["initialPrice"] = initial_price + payload = _body( + ClmmCreatePoolRequest( + connector=connector, + chainNetwork=chain_network, + walletAddress=wallet_address, + baseToken=base_token, + quoteToken=quote_token, + initialPrice=initial_price, + ) + ) if extra_params: payload.update(extra_params) return await self._request("POST", "trading/clmm/create-pool", json=payload) @@ -854,12 +970,14 @@ async def clmm_collect_fees( position_address: str ) -> Dict: """Collect accumulated fees from a CLMM position""" - return await self._request("POST", "trading/clmm/collect-fees", json={ - "connector": connector, - "chainNetwork": chain_network, - "walletAddress": wallet_address, - "positionAddress": position_address - }) + return await self._request("POST", "trading/clmm/collect-fees", json=_body( + ClmmCollectFeesRequest( + connector=connector, + chainNetwork=chain_network, + walletAddress=wallet_address, + positionAddress=position_address, + ) + )) async def clmm_pool_info( self, @@ -874,13 +992,14 @@ async def clmm_pool_info( (`bins`) around the active tick. Meteora always returns its bins and ignores the parameter; orca, raydium, uniswap and pancakeswap honour it. """ - params = { - "connector": connector, - "chainNetwork": chain_network, - "poolAddress": pool_address - } - if bin_count: - params["binCount"] = bin_count + params = _query( + ClmmPoolInfoRequest( + connector=connector, + chainNetwork=chain_network, + poolAddress=pool_address, + binCount=bin_count or None, + ) + ) return await self._request("GET", "trading/clmm/pool-info", params=params) async def clmm_fetch_pools( @@ -904,23 +1023,19 @@ async def clmm_fetch_pools( paginate — so only keys the caller sets are sent. Gateway drops a knob the chosen connector ignores, meaning the wrong connector's knob is a silent no-op. """ - params = { - "chainNetwork": chain_network, - "connector": connector, - "limit": limit, - } - if query: - params["query"] = query - if sort_by: - params["sortBy"] = sort_by - if page is not None and page > 0: - params["page"] = page - if include_unverified is not None: - params["includeUnverified"] = "true" if include_unverified else "false" - if sort_direction: - params["sortDirection"] = sort_direction - if verified_only is not None: - params["verifiedOnly"] = "true" if verified_only else "false" + params = _query( + ClmmFetchPoolsRequest( + chainNetwork=chain_network, + connector=connector, + limit=limit, + query=query or None, + sortBy=sort_by or None, + page=page if page else None, + includeUnverified=include_unverified, + sortDirection=sort_direction or None, + verifiedOnly=verified_only, + ) + ) return await self._request("GET", "trading/clmm/fetch-pools", params=params) @@ -933,32 +1048,38 @@ async def clmm_fetch_pools( async def amm_pool_info(self, connector: str, chain_network: str, pool_address: str) -> Dict: """Get AMM pool information (reserves, price, base fee).""" - return await self._request("GET", "trading/amm/pool-info", params={ - "connector": connector, - "chainNetwork": chain_network, - "poolAddress": pool_address, - }) + return await self._request("GET", "trading/amm/pool-info", params=_query( + AmmPoolInfoRequest( + connector=connector, + chainNetwork=chain_network, + poolAddress=pool_address, + ) + )) async def amm_position_info( self, connector: str, chain_network: str, pool_address: str, wallet_address: str ) -> Dict: """Get a wallet's aggregate liquidity in an AMM pool plus a per-position breakdown (DAMM v2).""" - return await self._request("GET", "trading/amm/position-info", params={ - "connector": connector, - "chainNetwork": chain_network, - "poolAddress": pool_address, - "walletAddress": wallet_address, - }) + return await self._request("GET", "trading/amm/position-info", params=_query( + AmmPositionInfoRequest( + connector=connector, + chainNetwork=chain_network, + poolAddress=pool_address, + walletAddress=wallet_address, + ) + )) async def amm_positions_owned( self, connector: str, chain_network: str, wallet_address: str ) -> List[Dict]: """List all of a wallet's AMM positions across pools (meteora only; fungible-LP → Gateway 400).""" - return await self._request("GET", "trading/amm/positions-owned", params={ - "connector": connector, - "chainNetwork": chain_network, - "walletAddress": wallet_address, - }) + return await self._request("GET", "trading/amm/positions-owned", params=_query( + AmmPositionsOwnedRequest( + connector=connector, + chainNetwork=chain_network, + walletAddress=wallet_address, + ) + )) async def amm_quote_liquidity( self, @@ -970,15 +1091,16 @@ async def amm_quote_liquidity( slippage_pct: Optional[float] = None, ) -> Dict: """Quote a two-sided liquidity deposit.""" - payload = { - "connector": connector, - "chainNetwork": chain_network, - "poolAddress": pool_address, - "baseTokenAmount": base_token_amount, - "quoteTokenAmount": quote_token_amount, - } - if slippage_pct is not None: - payload["slippagePct"] = slippage_pct + payload = _query( + AmmQuoteLiquidityRequest( + connector=connector, + chainNetwork=chain_network, + poolAddress=pool_address, + baseTokenAmount=base_token_amount, + quoteTokenAmount=quote_token_amount, + slippagePct=slippage_pct, + ) + ) return await self._request("GET", "trading/amm/quote-liquidity", params=payload) async def amm_add_liquidity( @@ -993,18 +1115,18 @@ async def amm_add_liquidity( position_address: Optional[str] = None, ) -> Dict: """Add two-sided liquidity. For meteora, position_address adds to that NFT position (omit = new).""" - payload = { - "connector": connector, - "chainNetwork": chain_network, - "walletAddress": wallet_address, - "poolAddress": pool_address, - "baseTokenAmount": base_token_amount, - "quoteTokenAmount": quote_token_amount, - } - if slippage_pct is not None: - payload["slippagePct"] = slippage_pct - if position_address is not None: - payload["positionAddress"] = position_address + payload = _body( + AmmAddRequest( + connector=connector, + chainNetwork=chain_network, + walletAddress=wallet_address, + poolAddress=pool_address, + baseTokenAmount=base_token_amount, + quoteTokenAmount=quote_token_amount, + slippagePct=slippage_pct, + positionAddress=position_address, + ) + ) return await self._request("POST", "trading/amm/add", json=payload) async def amm_remove_liquidity( @@ -1018,17 +1140,17 @@ async def amm_remove_liquidity( position_address: Optional[str] = None, ) -> Dict: """Remove liquidity. Gateway requires position_address for meteora (DAMM v2 NFT positions).""" - payload = { - "connector": connector, - "chainNetwork": chain_network, - "walletAddress": wallet_address, - "poolAddress": pool_address, - "percentageToRemove": percentage_to_remove, - } - if slippage_pct is not None: - payload["slippagePct"] = slippage_pct - if position_address is not None: - payload["positionAddress"] = position_address + payload = _body( + AmmRemoveRequest( + connector=connector, + chainNetwork=chain_network, + walletAddress=wallet_address, + poolAddress=pool_address, + percentageToRemove=percentage_to_remove, + slippagePct=slippage_pct, + positionAddress=position_address, + ) + ) return await self._request("POST", "trading/amm/remove", json=payload) async def amm_create_pool( @@ -1052,21 +1174,21 @@ async def amm_create_pool( ammConfigIndex) and is spread into the payload — the same contract as clmm open's extra_params. The router validates keys before this is called. """ - payload = { - "connector": connector, - "chainNetwork": chain_network, - "walletAddress": wallet_address, - "baseToken": base_token, - "quoteToken": quote_token, - "baseTokenAmount": base_token_amount, - } - # Seed price: at most one of quoteTokenAmount / initialPrice; Gateway falls back to market price. - if quote_token_amount is not None: - payload["quoteTokenAmount"] = quote_token_amount - if initial_price is not None: - payload["initialPrice"] = initial_price - if slippage_pct is not None: - payload["slippagePct"] = slippage_pct + # Seed price: at most one of quoteTokenAmount / initialPrice; Gateway falls back + # to market price when neither is given, which _body expresses by dropping None. + payload = _body( + AmmCreatePoolRequest( + connector=connector, + chainNetwork=chain_network, + walletAddress=wallet_address, + baseToken=base_token, + quoteToken=quote_token, + baseTokenAmount=base_token_amount, + quoteTokenAmount=quote_token_amount, + initialPrice=initial_price, + slippagePct=slippage_pct, + ) + ) if extra_params: payload.update(extra_params) return await self._request("POST", "trading/amm/create-pool", json=payload) diff --git a/test/test_gateway_client_contract.py b/test/test_gateway_client_contract.py index 463aa8cd..97489954 100644 --- a/test/test_gateway_client_contract.py +++ b/test/test_gateway_client_contract.py @@ -117,7 +117,9 @@ async def test_quote_swap_routes_by_trading_type(client_and_calls): "quoteToken": "USDC", "amount": "0.1", "side": "SELL", - "slippagePct": "1.0", + # Query values are text — that is all a URL carries — and a whole number is + # rendered whole, so a page index does not go out as "2.0". + "slippagePct": "1", } @@ -318,7 +320,7 @@ async def test_clmm_fetch_pools_meteora_params(client_and_calls): assert (call["method"], call["path"]) == ("GET", "trading/clmm/fetch-pools") assert call["params"]["connector"] == "meteora" assert call["params"]["chainNetwork"] == "solana-mainnet-beta" - assert call["params"]["page"] == 2 + assert call["params"]["page"] == "2" assert call["params"]["includeUnverified"] == "false" assert call["params"]["sortBy"] == "volume_24h:desc" for orca_only in ("sortDirection", "verifiedOnly"): @@ -419,7 +421,7 @@ async def test_amm_quote_liquidity_path_and_slippage_omitted(client_and_calls): c = calls[0] assert (c["method"], c["path"]) == ("GET", "trading/amm/quote-liquidity") assert c["params"] == {"connector": "meteora", "chainNetwork": NET, "poolAddress": POOL, - "baseTokenAmount": 1.0, "quoteTokenAmount": 100.0} + "baseTokenAmount": "1", "quoteTokenAmount": "100"} # Omitted slippage means "use the connector's configured slippagePct" assert "slippagePct" not in c["params"] @@ -429,7 +431,7 @@ async def test_amm_quote_liquidity_sends_zero_slippage(client_and_calls): client, calls = client_and_calls await client.amm_quote_liquidity(connector="meteora", chain_network=NET, pool_address=POOL, base_token_amount=1.0, quote_token_amount=100.0, slippage_pct=0) - assert calls[0]["params"]["slippagePct"] == 0 + assert calls[0]["params"]["slippagePct"] == "0" @pytest.mark.asyncio diff --git a/test/test_gateway_models_match_spec.py b/test/test_gateway_models_match_spec.py index f5f9ad19..0d801b08 100644 --- a/test/test_gateway_models_match_spec.py +++ b/test/test_gateway_models_match_spec.py @@ -14,9 +14,11 @@ built from. Those models are constructed by splatting a Gateway response (`Model(**result)`), so a field Gateway does not send is dead on arrival. - Every camelCase key `services/gateway_client.py` writes or reads appears in the spec. - This is the only check that reaches GET query parameters, which live in the spec as - `parameters` and so are absent from `components.schemas` entirely — no generated - model covers them. + This covers the routes still built by hand — /wallet, /config, /tokens, /pools. +- Every keyword the client passes to a generated request model is a field of it. The + /trading methods build their payloads from those models, so their names are checked by + construction — except that pydantic drops an unknown keyword, which on an optional + field would send the request without it, in silence. Refresh the spec and models together when adopting a Gateway change: @@ -109,6 +111,9 @@ def test_the_generated_models_match_the_vendored_spec(): "--snake-case-field", "--target-python-version", "3.12", "--disable-timestamp", + # Keeps `connector`/`network` as plain strings rather than baking Gateway's + # current roster into this service. See the Makefile. + "--ignore-enum-constraints", "--formatters", "black", "--formatters", "isort", "--custom-file-header", GENERATED_PATH.read_text().split("\n\n")[0], @@ -160,11 +165,48 @@ def test_every_wire_key_the_client_uses_exists_in_the_spec(): ) +# `ModelNameRequest(\n key=..., ...)` — the shape every converted call site uses. +_MODEL_CALL = re.compile(r"\b([A-Z][A-Za-z0-9]*Request)\(\s*\n((?:\s+\w+=.*\n)+)") +_MODEL_KWARG = re.compile(r"^\s+(\w+)=", re.M) + + +def _model_calls() -> list: + return [ + (m.group(1), _MODEL_KWARG.findall(m.group(2))) + for m in _MODEL_CALL.finditer(CLIENT_PATH.read_text()) + ] + + +def test_every_kwarg_is_a_field_of_the_model_it_names(): + """Pydantic ignores an unknown keyword. + + On a required field that still fails loudly — the real one goes missing — but + `slippagePc=1` for `slippagePct` would be dropped in silence, and the request would + go out without the slippage the caller asked for. + """ + from models import gateway_generated + + unknown = [] + for model_name, kwargs in _model_calls(): + model = getattr(gateway_generated, model_name, None) + assert model is not None, f"{model_name} is not a generated model" + fields = {(f.alias or n) for n, f in model.model_fields.items()} | set(model.model_fields) + unknown += [f"{model_name}.{k}" for k in kwargs if k not in fields] + + assert not unknown, ( + "GatewayClient passes keywords no generated model declares:\n " + + "\n ".join(sorted(unknown)) + + "\n\nPydantic drops an unknown keyword, so an optional one goes missing in " + "silence. Follow the rename, or correct the spelling." + ) + + def test_the_checks_above_are_not_vacuous(): """A truncated spec or a regex matching nothing would pass every check silently.""" spec = _spec() assert len(spec["components"]["schemas"]) > 50, "components.schemas looks truncated" assert len(_schema_property_names(spec)) > 100, "Found almost no property names in the spec" - assert len(re.findall(r'"([a-z][a-zA-Z0-9]*[A-Z][a-zA-Z0-9]*)"', CLIENT_PATH.read_text())) > 50, ( - "Found almost no camelCase literals in the client — has the regex gone stale?" + assert len(_model_calls()) > 10, ( + f"Only {len(_model_calls())} model constructions found in the client — has the regex " + "gone stale, or did the /trading methods go back to hand-written dicts?" ) From 8d4c7cd01c7b55cb90f734577a4b21847543cb40 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Wed, 19 Aug 2026 18:24:43 -0700 Subject: [PATCH 24/54] fix(clmm): report zero uncollected fees as zero, not as absent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit positions_owned mapped Gateway's fee amounts through a truthiness check, so a position with nothing uncollected came back with base_fee_amount=None — indistinguishable from Gateway not having reported the field at all. The single-position read on the same model already used `is not None` and returned 0, so the two routes disagreed about the same position. Live: condor's CLMM position listing printed "Uncollected fees — base: None quote: None" for a position the position-info route reported as 0. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- routers/gateway_clmm.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/routers/gateway_clmm.py b/routers/gateway_clmm.py index e50cae41..38e6769a 100644 --- a/routers/gateway_clmm.py +++ b/routers/gateway_clmm.py @@ -1300,8 +1300,12 @@ async def get_clmm_positions_owned( current_price=current_price, lower_price=lower_price, upper_price=upper_price, - base_fee_amount=Decimal(str(pos.get("baseFeeAmount", 0))) if pos.get("baseFeeAmount") else None, - quote_fee_amount=Decimal(str(pos.get("quoteFeeAmount", 0))) if pos.get("quoteFeeAmount") else None, + # `is not None`, not truthiness: a position with nothing uncollected has + # fees of 0, and reporting that as None says "Gateway did not tell us" + # instead of "there are none". The single-position read below already + # draws the distinction this way. + base_fee_amount=Decimal(str(pos["baseFeeAmount"])) if pos.get("baseFeeAmount") is not None else None, + quote_fee_amount=Decimal(str(pos["quoteFeeAmount"])) if pos.get("quoteFeeAmount") is not None else None, lower_bin_id=pos.get("lowerBinId"), upper_bin_id=pos.get("upperBinId"), in_range=in_range From 504642d1c20e3145c7efdf44c8626d35a24c0be6 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Wed, 19 Aug 2026 21:20:15 -0700 Subject: [PATCH 25/54] feat(amm): record the rent a full removal refunds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gateway folded close into remove at 100%: the position account is closed in the same transaction, and positionRentRefunded now arrives on the remove response. This records it, the way the CLMM close path already does. The refund needs its own field because the rent was never liquidity — subtracting the removed amounts does not account for it, and on a small position the rent is the larger number. It arrives only on a full removal: a partial one leaves the account open and refunds nothing, and fungible-LP AMMs have no account to close, so its absence there is a fact rather than a gap. Also drops the two AMM open/close entries from the passthrough table in test_gateway_models_match_spec, whose schemas Gateway no longer publishes. That test is what caught the spec change. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- .../repositories/gateway_amm_repository.py | 16 +- gateway-openapi.json | 908 ++++++++++++------ models/gateway_generated.py | 50 +- routers/gateway_amm.py | 14 +- test/test_gateway_models_match_spec.py | 5 +- 5 files changed, 642 insertions(+), 351 deletions(-) diff --git a/database/repositories/gateway_amm_repository.py b/database/repositories/gateway_amm_repository.py index c3ca0941..b8a3ef89 100644 --- a/database/repositories/gateway_amm_repository.py +++ b/database/repositories/gateway_amm_repository.py @@ -107,12 +107,24 @@ async def subtract_from_position_amounts( await self.session.flush() return position - async def close_position(self, position_address: str) -> Optional[GatewayAMMPosition]: - """Mark a position closed. DAMM v2 closes when its liquidity is fully removed.""" + async def close_position( + self, + position_address: str, + position_rent_refunded: Optional[Decimal] = None + ) -> Optional[GatewayAMMPosition]: + """Mark a position closed, recording the rent the chain gave back. + + A DAMM v2 position closes when its liquidity is fully removed: Gateway closes the + position account in the same transaction, which is what returns its rent. The + refund needs recording separately because the rent was never liquidity — + subtracting the removed amounts does not account for it. + """ position = await self.get_position_by_address(position_address) if position: position.status = "CLOSED" position.closed_at = datetime.now(timezone.utc) + if position_rent_refunded is not None: + position.position_rent_refunded = position_rent_refunded await self.session.flush() return position diff --git a/gateway-openapi.json b/gateway-openapi.json index 59d3592d..b6145150 100644 --- a/gateway-openapi.json +++ b/gateway-openapi.json @@ -115,119 +115,6 @@ "quoteTokenAmountAdded" ] }, - "AmmOpenPositionResponse": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "$ref": "#/components/schemas/AmmOpenPositionResponseData" - } - }, - "required": [ - "signature", - "status" - ] - }, - "AmmOpenPositionResponseData": { - "type": "object", - "properties": { - "fee": { - "format": "decimal", - "type": "number" - }, - "poolAddress": { - "description": "Pool this operation acted on", - "type": "string" - }, - "positionAddress": { - "description": "Address of the newly opened position. Absent on fungible-LP AMMs, which hold liquidity as LP tokens rather than a position account.", - "type": "string" - }, - "positionRent": { - "format": "decimal", - "description": "Native token locked as rent for the position account, refunded on close. 0 on fungible-LP AMMs, which lock no rent.", - "type": "number" - }, - "baseTokenAmountAdded": { - "format": "decimal", - "type": "number" - }, - "quoteTokenAmountAdded": { - "format": "decimal", - "type": "number" - } - }, - "required": [ - "fee", - "positionRent", - "baseTokenAmountAdded", - "quoteTokenAmountAdded" - ] - }, - "AmmClosePositionResponse": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "$ref": "#/components/schemas/AmmClosePositionResponseData" - } - }, - "required": [ - "signature", - "status" - ] - }, - "AmmClosePositionResponseData": { - "type": "object", - "properties": { - "fee": { - "format": "decimal", - "type": "number" - }, - "poolAddress": { - "description": "Pool this operation acted on", - "type": "string" - }, - "positionAddress": { - "description": "Position this operation acted on", - "x-connectors": [ - "meteora" - ], - "type": "string" - }, - "positionRentRefunded": { - "format": "decimal", - "description": "Native token rent returned when the position account closed. 0 on fungible-LP AMMs, which have no position account to close.", - "type": "number" - }, - "baseTokenAmountRemoved": { - "format": "decimal", - "type": "number" - }, - "quoteTokenAmountRemoved": { - "format": "decimal", - "type": "number" - } - }, - "required": [ - "fee", - "positionRentRefunded", - "baseTokenAmountRemoved", - "quoteTokenAmountRemoved" - ] - }, "QuoteLiquidityResponse": { "type": "object", "properties": { @@ -300,6 +187,14 @@ ], "type": "string" }, + "positionRentRefunded": { + "format": "decimal", + "description": "Native token rent returned when the position account closed. Present only on a 100% removal from an AMM whose positions are accounts.", + "x-connectors": [ + "meteora" + ], + "type": "number" + }, "baseTokenAmountRemoved": { "format": "decimal", "type": "number" @@ -1690,6 +1585,22 @@ }, "chainNetwork": { "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "default": "solana-mainnet-beta", "type": "string", "example": "solana-mainnet-beta" @@ -1771,6 +1682,22 @@ }, "chainNetwork": { "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "default": "solana-mainnet-beta", "type": "string", "example": "solana-mainnet-beta" @@ -1836,6 +1763,22 @@ }, "chainNetwork": { "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "default": "solana-mainnet-beta", "type": "string", "example": "solana-mainnet-beta" @@ -1882,65 +1825,7 @@ "percentageToRemove" ] }, - "AmmOpenRequest": { - "type": "object", - "properties": { - "connector": { - "description": "AMM connector", - "enum": [ - "meteora", - "raydium", - "uniswap", - "pancakeswap" - ], - "default": "meteora", - "type": "string", - "example": "meteora" - }, - "chainNetwork": { - "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", - "default": "solana-mainnet-beta", - "type": "string", - "example": "solana-mainnet-beta" - }, - "walletAddress": { - "description": "Wallet that will own the position", - "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", - "type": "string" - }, - "poolAddress": { - "description": "Pool to open the position in", - "type": "string" - }, - "baseTokenAmount": { - "format": "decimal", - "description": "Amount of base token to deposit", - "type": "number" - }, - "quoteTokenAmount": { - "format": "decimal", - "description": "Amount of quote token to deposit", - "type": "number" - }, - "slippagePct": { - "format": "decimal", - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", - "type": "number", - "example": 1 - } - }, - "required": [ - "connector", - "chainNetwork", - "walletAddress", - "poolAddress", - "baseTokenAmount", - "quoteTokenAmount" - ] - }, - "AmmCloseRequest": { + "AmmPoolInfoRequest": { "type": "object", "properties": { "connector": { @@ -1957,59 +1842,22 @@ }, "chainNetwork": { "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", - "default": "solana-mainnet-beta", - "type": "string", - "example": "solana-mainnet-beta" - }, - "walletAddress": { - "description": "Wallet that owns the position", - "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", - "type": "string" - }, - "poolAddress": { - "description": "Pool the position belongs to", - "type": "string" - }, - "positionAddress": { - "description": "Position to close. Required on AMMs whose positions are discrete accounts (meteora DAMM v2), where a wallet may hold several per pool. Ignored by fungible-LP AMMs, which hold one LP balance per pool.", - "x-connectors": [ - "meteora" - ], - "type": "string" - }, - "slippagePct": { - "format": "decimal", - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage on the withdrawn amounts.", - "type": "number", - "example": 1 - } - }, - "required": [ - "connector", - "chainNetwork", - "walletAddress", - "poolAddress" - ] - }, - "AmmPoolInfoRequest": { - "type": "object", - "properties": { - "connector": { - "description": "AMM connector", "enum": [ - "meteora", - "raydium", - "uniswap", - "pancakeswap" + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" ], - "default": "meteora", - "type": "string", - "example": "meteora" - }, - "chainNetwork": { - "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", "default": "solana-mainnet-beta", "type": "string", "example": "solana-mainnet-beta" @@ -2042,6 +1890,22 @@ }, "chainNetwork": { "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "default": "solana-mainnet-beta", "type": "string", "example": "solana-mainnet-beta" @@ -2080,6 +1944,22 @@ }, "chainNetwork": { "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "default": "solana-mainnet-beta", "type": "string", "example": "solana-mainnet-beta" @@ -2113,6 +1993,22 @@ }, "chainNetwork": { "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "default": "solana-mainnet-beta", "type": "string", "example": "solana-mainnet-beta" @@ -2156,8 +2052,8 @@ "enum": [ "meteora", "raydium", - "pancakeswap-sol", "orca", + "pancakeswap-sol", "uniswap", "pancakeswap" ], @@ -2167,6 +2063,22 @@ }, "chainNetwork": { "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "default": "solana-mainnet-beta", "type": "string", "example": "solana-mainnet-beta" @@ -2239,8 +2151,8 @@ "enum": [ "meteora", "raydium", - "pancakeswap-sol", "orca", + "pancakeswap-sol", "uniswap", "pancakeswap" ], @@ -2250,6 +2162,22 @@ }, "chainNetwork": { "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "default": "solana-mainnet-beta", "type": "string", "example": "solana-mainnet-beta" @@ -2308,8 +2236,8 @@ "enum": [ "meteora", "raydium", - "pancakeswap-sol", "orca", + "pancakeswap-sol", "uniswap", "pancakeswap" ], @@ -2319,6 +2247,22 @@ }, "chainNetwork": { "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "default": "solana-mainnet-beta", "type": "string", "example": "solana-mainnet-beta" @@ -2367,8 +2311,8 @@ "enum": [ "meteora", "raydium", - "pancakeswap-sol", "orca", + "pancakeswap-sol", "uniswap", "pancakeswap" ], @@ -2378,6 +2322,22 @@ }, "chainNetwork": { "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "default": "solana-mainnet-beta", "type": "string", "example": "solana-mainnet-beta" @@ -2408,8 +2368,8 @@ "enum": [ "meteora", "raydium", - "pancakeswap-sol", "orca", + "pancakeswap-sol", "uniswap", "pancakeswap" ], @@ -2419,6 +2379,22 @@ }, "chainNetwork": { "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "default": "solana-mainnet-beta", "type": "string", "example": "solana-mainnet-beta" @@ -2449,8 +2425,8 @@ "enum": [ "meteora", "raydium", - "pancakeswap-sol", "orca", + "pancakeswap-sol", "uniswap", "pancakeswap" ], @@ -2460,6 +2436,22 @@ }, "chainNetwork": { "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "default": "solana-mainnet-beta", "type": "string", "example": "solana-mainnet-beta" @@ -2519,6 +2511,22 @@ "properties": { "chainNetwork": { "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "default": "solana-mainnet-beta", "type": "string", "example": "solana-mainnet-beta" @@ -2597,8 +2605,8 @@ "enum": [ "meteora", "raydium", - "pancakeswap-sol", "orca", + "pancakeswap-sol", "uniswap", "pancakeswap" ], @@ -2608,6 +2616,22 @@ }, "chainNetwork": { "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "default": "solana-mainnet-beta", "type": "string", "example": "solana-mainnet-beta" @@ -2639,8 +2663,8 @@ "enum": [ "meteora", "raydium", - "pancakeswap-sol", "orca", + "pancakeswap-sol", "uniswap", "pancakeswap" ], @@ -2650,6 +2674,22 @@ }, "chainNetwork": { "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "default": "solana-mainnet-beta", "type": "string", "example": "solana-mainnet-beta" @@ -2674,8 +2714,8 @@ "enum": [ "meteora", "raydium", - "pancakeswap-sol", "orca", + "pancakeswap-sol", "uniswap", "pancakeswap" ], @@ -2685,6 +2725,22 @@ }, "chainNetwork": { "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "default": "solana-mainnet-beta", "type": "string", "example": "solana-mainnet-beta" @@ -2709,8 +2765,8 @@ "enum": [ "meteora", "raydium", - "pancakeswap-sol", "orca", + "pancakeswap-sol", "uniswap", "pancakeswap" ], @@ -2720,6 +2776,22 @@ }, "chainNetwork": { "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "default": "solana-mainnet-beta", "type": "string", "example": "solana-mainnet-beta" @@ -2775,6 +2847,22 @@ "properties": { "chainNetwork": { "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "default": "solana-mainnet-beta", "type": "string", "example": "solana-mainnet-beta" @@ -2815,6 +2903,22 @@ "properties": { "chainNetwork": { "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "default": "solana-mainnet-beta", "type": "string", "example": "solana-mainnet-beta" @@ -2898,6 +3002,22 @@ "properties": { "chainNetwork": { "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "default": "solana-mainnet-beta", "type": "string", "example": "solana-mainnet-beta" @@ -2986,6 +3106,22 @@ "properties": { "chainNetwork": { "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "default": "solana-mainnet-beta", "type": "string", "example": "solana-mainnet-beta" @@ -3053,6 +3189,22 @@ "properties": { "chainNetwork": { "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "default": "solana-mainnet-beta", "type": "string", "example": "solana-mainnet-beta" @@ -3122,6 +3274,22 @@ "properties": { "chainNetwork": { "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "default": "solana-mainnet-beta", "type": "string", "example": "solana-mainnet-beta" @@ -3195,6 +3363,22 @@ "properties": { "chainNetwork": { "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "default": "solana-mainnet-beta", "type": "string", "example": "solana-mainnet-beta" @@ -5497,6 +5681,22 @@ "parameters": [ { "schema": { + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "default": "solana-mainnet-beta", "type": "string" }, @@ -5706,8 +5906,8 @@ "enum": [ "meteora", "raydium", - "pancakeswap-sol", "orca", + "pancakeswap-sol", "uniswap", "pancakeswap" ], @@ -5722,6 +5922,22 @@ }, { "schema": { + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "default": "solana-mainnet-beta", "type": "string" }, @@ -5780,8 +5996,8 @@ "enum": [ "meteora", "raydium", - "pancakeswap-sol", "orca", + "pancakeswap-sol", "uniswap", "pancakeswap" ], @@ -5796,6 +6012,22 @@ }, { "schema": { + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "default": "solana-mainnet-beta", "type": "string" }, @@ -5842,8 +6074,8 @@ "enum": [ "meteora", "raydium", - "pancakeswap-sol", "orca", + "pancakeswap-sol", "uniswap", "pancakeswap" ], @@ -5858,6 +6090,22 @@ }, { "schema": { + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "default": "solana-mainnet-beta", "type": "string" }, @@ -5907,8 +6155,8 @@ "enum": [ "meteora", "raydium", - "pancakeswap-sol", "orca", + "pancakeswap-sol", "uniswap", "pancakeswap" ], @@ -5923,6 +6171,22 @@ }, { "schema": { + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "default": "solana-mainnet-beta", "type": "string" }, @@ -6023,6 +6287,22 @@ "parameters": [ { "schema": { + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "default": "solana-mainnet-beta", "type": "string" }, @@ -6170,6 +6450,22 @@ "parameters": [ { "schema": { + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "default": "solana-mainnet-beta", "type": "string" }, @@ -6509,6 +6805,22 @@ }, { "schema": { + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "default": "solana-mainnet-beta", "type": "string" }, @@ -6568,6 +6880,22 @@ }, { "schema": { + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "default": "solana-mainnet-beta", "type": "string" }, @@ -6637,6 +6965,22 @@ }, { "schema": { + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "default": "solana-mainnet-beta", "type": "string" }, @@ -6700,6 +7044,22 @@ }, { "schema": { + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "default": "solana-mainnet-beta", "type": "string" }, @@ -6775,6 +7135,22 @@ "parameters": [ { "schema": { + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "default": "solana-mainnet-beta", "type": "string" }, @@ -6912,35 +7288,6 @@ } } }, - "/trading/amm/open": { - "post": { - "tags": [ - "/trading/amm" - ], - "description": "Open a position with initial liquidity. On AMMs whose positions are discrete accounts (meteora DAMM v2) this mints the position and returns its address and rent; on fungible-LP AMMs (raydium, uniswap, pancakeswap) it performs the equivalent deposit and returns neither.", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AmmOpenRequest" - } - } - } - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AmmOpenPositionResponse" - } - } - } - } - } - } - }, "/trading/amm/add": { "post": { "tags": [ @@ -6999,35 +7346,6 @@ } } }, - "/trading/amm/close": { - "post": { - "tags": [ - "/trading/amm" - ], - "description": "Withdraw all of a position's liquidity. On AMMs whose positions are discrete accounts (meteora DAMM v2) this also closes the position account and refunds its rent; on fungible-LP AMMs (raydium, uniswap, pancakeswap) it withdraws the full LP balance and refunds no rent.", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AmmCloseRequest" - } - } - } - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AmmClosePositionResponse" - } - } - } - } - } - } - }, "/trading/amm/create-pool": { "post": { "tags": [ diff --git a/models/gateway_generated.py b/models/gateway_generated.py index 4f405404..e4d08562 100644 --- a/models/gateway_generated.py +++ b/models/gateway_generated.py @@ -28,24 +28,6 @@ class AmmAddLiquidityResponseData(BaseModel): quote_token_amount_added: Decimal = Field(..., alias='quoteTokenAmountAdded') -class AmmOpenPositionResponseData(BaseModel): - fee: Decimal - pool_address: str | None = Field(None, alias='poolAddress', description='Pool this operation acted on') - position_address: str | None = Field(None, alias='positionAddress', description='Address of the newly opened position. Absent on fungible-LP AMMs, which hold liquidity as LP tokens rather than a position account.') - position_rent: Decimal = Field(..., alias='positionRent', description='Native token locked as rent for the position account, refunded on close. 0 on fungible-LP AMMs, which lock no rent.') - base_token_amount_added: Decimal = Field(..., alias='baseTokenAmountAdded') - quote_token_amount_added: Decimal = Field(..., alias='quoteTokenAmountAdded') - - -class AmmClosePositionResponseData(BaseModel): - fee: Decimal - pool_address: str | None = Field(None, alias='poolAddress', description='Pool this operation acted on') - position_address: str | None = Field(None, alias='positionAddress', description='Position this operation acted on') - position_rent_refunded: Decimal = Field(..., alias='positionRentRefunded', description='Native token rent returned when the position account closed. 0 on fungible-LP AMMs, which have no position account to close.') - base_token_amount_removed: Decimal = Field(..., alias='baseTokenAmountRemoved') - quote_token_amount_removed: Decimal = Field(..., alias='quoteTokenAmountRemoved') - - class QuoteLiquidityResponse(BaseModel): pool_address: str | None = Field(None, alias='poolAddress', description='Pool the quote was computed against') base_limited: bool = Field(..., alias='baseLimited') @@ -59,6 +41,7 @@ class AmmRemoveLiquidityResponseData(BaseModel): fee: Decimal pool_address: str | None = Field(None, alias='poolAddress', description='Pool this operation acted on') position_address: str | None = Field(None, alias='positionAddress', description='Position this operation acted on') + position_rent_refunded: Decimal | None = Field(None, alias='positionRentRefunded', description='Native token rent returned when the position account closed. Present only on a 100% removal from an AMM whose positions are accounts.') base_token_amount_removed: Decimal = Field(..., alias='baseTokenAmountRemoved') quote_token_amount_removed: Decimal = Field(..., alias='quoteTokenAmountRemoved') @@ -362,25 +345,6 @@ class AmmRemoveRequest(BaseModel): slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct', description="Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", examples=[1]) -class AmmOpenRequest(BaseModel): - connector: str = Field(..., description='AMM connector', examples=['meteora']) - chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) - wallet_address: str = Field(..., alias='walletAddress', description='Wallet that will own the position') - pool_address: str = Field(..., alias='poolAddress', description='Pool to open the position in') - base_token_amount: Decimal = Field(..., alias='baseTokenAmount', description='Amount of base token to deposit') - quote_token_amount: Decimal = Field(..., alias='quoteTokenAmount', description='Amount of quote token to deposit') - slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct', description="Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", examples=[1]) - - -class AmmCloseRequest(BaseModel): - connector: str = Field(..., description='AMM connector', examples=['meteora']) - chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) - wallet_address: str = Field(..., alias='walletAddress', description='Wallet that owns the position') - pool_address: str = Field(..., alias='poolAddress', description='Pool the position belongs to') - position_address: str | None = Field(None, alias='positionAddress', description='Position to close. Required on AMMs whose positions are discrete accounts (meteora DAMM v2), where a wallet may hold several per pool. Ignored by fungible-LP AMMs, which hold one LP balance per pool.') - slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct', description='Maximum acceptable slippage on the withdrawn amounts.', examples=[1]) - - class AmmPoolInfoRequest(BaseModel): connector: str = Field(..., description='AMM connector', examples=['meteora']) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) @@ -628,18 +592,6 @@ class AmmAddLiquidityResponse(BaseModel): data: AmmAddLiquidityResponseData | None = None -class AmmOpenPositionResponse(BaseModel): - signature: str - status: float = Field(..., description='TransactionStatus enum value') - data: AmmOpenPositionResponseData | None = None - - -class AmmClosePositionResponse(BaseModel): - signature: str - status: float = Field(..., description='TransactionStatus enum value') - data: AmmClosePositionResponseData | None = None - - class AmmRemoveLiquidityResponse(BaseModel): signature: str status: float = Field(..., description='TransactionStatus enum value') diff --git a/routers/gateway_amm.py b/routers/gateway_amm.py index a8768448..b23f434b 100644 --- a/routers/gateway_amm.py +++ b/routers/gateway_amm.py @@ -445,10 +445,18 @@ async def remove_amm_liquidity( base_delta=Decimal(str(data.get("baseTokenAmountRemoved") or 0)), quote_delta=Decimal(str(data.get("quoteTokenAmountRemoved") or 0)), ) - # DAMM v2 burns the position NFT on a full withdrawal, so a 100% - # remove is the close — there is no separate close route. + # A 100% remove is the close: Gateway closes the position account in + # the same transaction, which is what returns its rent. There is no + # separate close route, and positionRentRefunded arrives only on this + # path — a partial removal leaves the account open and refunds + # nothing, so its absence there is a fact rather than a gap. if position and float(request.percentage_to_remove) >= 100: - await repo.close_position(request.position_address) + rent_refunded = data.get("positionRentRefunded") + await repo.close_position( + request.position_address, + position_rent_refunded=(Decimal(str(rent_refunded)) + if rent_refunded is not None else None), + ) except Exception as db_error: logger.error(f"Error booking AMM removal for {request.position_address}: " f"{db_error}", exc_info=True) diff --git a/test/test_gateway_models_match_spec.py b/test/test_gateway_models_match_spec.py index 0d801b08..6b259250 100644 --- a/test/test_gateway_models_match_spec.py +++ b/test/test_gateway_models_match_spec.py @@ -53,10 +53,11 @@ ("AMMQuoteLiquidityResponse", "QuoteLiquidityResponse"), ("AMMCreatePoolResponse", "CreatePoolResponse"), ("AMMCreatePoolResponse", "ClmmCreatePoolResponse"), + # Gateway dropped /trading/amm/{open,close}: open was a synonym for add without a + # position address, and close is now what remove does at 100%, which is why the + # remove response is the one that carries positionRentRefunded. ("AMMTransactionResponse", "AmmAddLiquidityResponse"), ("AMMTransactionResponse", "AmmRemoveLiquidityResponse"), - ("AMMTransactionResponse", "AmmOpenPositionResponse"), - ("AMMTransactionResponse", "AmmClosePositionResponse"), ] # camelCase strings in the client that address Gateway's YAML config tree rather than an From baaec0fd6eab0ce6ebdfa11d60b69253be691f0a Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Wed, 19 Aug 2026 21:32:29 -0700 Subject: [PATCH 26/54] fix(amm): record the position rent, and take the position address Gateway gives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things, all on the DAMM v2 add path. positionRent was discarded. Gateway reports it separately precisely because rent is not liquidity — the chain returns it when the position account closes — and the position table has had a position_rent column all along, filled by the CLMM path and never by this one. So the inflated figure from GW-20 was booked with nothing alongside it to back it out. Now recorded, and the close already records the refund, so the two can be compared: a refund short of what was locked means an account was left behind. position_to_dict exposed neither rent field, so /gateway/amm/positions/search would have kept them invisible. The CLMM dict has always returned both. _resolve_new_position_address is deleted. It diffed on-chain positions against tracked ones to guess which one an add had just created, because the response carried no address (GW-6) — its own docstring said to delete it the moment that field existed. It does: Gateway generates the NFT keypair, so it is the only thing that can attribute a position to a transaction, and the diff gave up whenever two were new. The route now reads data["positionAddress"]. get_open_position_addresses went with it, its only caller. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0166iQoxKce23GkUwuQJxdkr --- .../repositories/gateway_amm_repository.py | 15 ++---- routers/gateway_amm.py | 53 +++++-------------- 2 files changed, 18 insertions(+), 50 deletions(-) diff --git a/database/repositories/gateway_amm_repository.py b/database/repositories/gateway_amm_repository.py index b8a3ef89..22423403 100644 --- a/database/repositories/gateway_amm_repository.py +++ b/database/repositories/gateway_amm_repository.py @@ -35,16 +35,6 @@ async def get_position_by_address(self, position_address: str) -> Optional[Gatew ) return result.scalar_one_or_none() - async def get_open_position_addresses(self, wallet_address: str, pool_address: str) -> set: - """Addresses this API already tracks for a wallet/pool, used to spot a new one.""" - result = await self.session.execute( - select(GatewayAMMPosition.position_address).where( - GatewayAMMPosition.wallet_address == wallet_address, - GatewayAMMPosition.pool_address == pool_address, - ) - ) - return set(result.scalars().all()) - async def create_position(self, position_data: Dict) -> GatewayAMMPosition: position = GatewayAMMPosition(**position_data) self.session.add(position) @@ -174,6 +164,11 @@ def num(value): "base_token_amount": num(position.base_token_amount), "quote_token_amount": num(position.quote_token_amount), "lp_token_amount": num(position.lp_token_amount), + # Rent locked at open and what came back at close, kept apart so the two can + # be compared: a refund short of what was locked means an account was left + # behind. Neither is liquidity, so neither is in the amounts above. + "position_rent": num(position.position_rent), + "position_rent_refunded": num(position.position_rent_refunded), "entry_price": num(position.entry_price), "current_price": num(position.current_price), } diff --git a/routers/gateway_amm.py b/routers/gateway_amm.py index b23f434b..7298dabc 100644 --- a/routers/gateway_amm.py +++ b/routers/gateway_amm.py @@ -70,43 +70,6 @@ async def _resolve_wallet(accounts_service: AccountsService, network: str, walle ) -async def _resolve_new_position_address( - accounts_service: AccountsService, - db_manager: AsyncDatabaseManager, - connector: str, - network: str, - wallet_address: str, - pool_address: str, -) -> Optional[str]: - """Find the position address an add just created, by diffing against what we track. - - Workaround for a Gateway gap (GW-6): the AMM add-liquidity response carries no - positionAddress, even though Gateway generates the NFT keypair itself and logs the - address. Delete this the moment that field exists — a diff cannot attribute an - address to a transaction and loses to a concurrent write on the same pool. - """ - try: - live = check_gateway_error(await accounts_service.gateway_client.amm_position_info( - connector=connector, chain_network=network, - pool_address=pool_address, wallet_address=wallet_address, - )) - on_chain = {p.get("positionAddress") for p in (live.get("positions") or []) if p.get("positionAddress")} - if not on_chain: - return None - async with db_manager.get_session_context() as session: - known = await GatewayAMMRepository(session).get_open_position_addresses( - wallet_address, pool_address) - new = on_chain - known - if len(new) == 1: - return new.pop() - if len(new) > 1: - logger.warning(f"{len(new)} untracked DAMM v2 positions in pool {pool_address}; " - "cannot attribute the add to one of them") - except Exception as e: - logger.warning(f"Could not resolve the new DAMM v2 position in pool {pool_address}: {e}") - return None - - async def _read_pool( accounts_service: AccountsService, connector: str, @@ -142,6 +105,12 @@ async def _book_position_add( """Create or top up the DAMM v2 position row for a confirmed add.""" base_added = data.get("baseTokenAmountAdded") or 0 quote_added = data.get("quoteTokenAmountAdded") or 0 + # Rent is locked, not spent: the chain returns it when the position account closes, + # and Gateway reports it separately for exactly that reason. Recorded here so the + # close can be checked against it — a refund smaller than what was locked means an + # account was left behind. Present only when this add opened the position; adding to + # one that already exists locks no further rent. + position_rent = data.get("positionRent") try: async with db_manager.get_session_context() as session: @@ -176,6 +145,7 @@ async def _book_position_add( "initial_quote_token_amount": quote_added, "base_token_amount": base_added, "quote_token_amount": quote_added, + "position_rent": Decimal(str(position_rent)) if position_rent is not None else None, "entry_price": price, "current_price": price, }) @@ -381,9 +351,12 @@ async def add_amm_liquidity( # its price — is the entire record. if confirmed and has_nft_positions(request.connector): if position_address is None: - position_address = await _resolve_new_position_address( - accounts_service, db_manager, request.connector, request.network, - wallet_address, request.pool_address) + # An add that opened a position names it in the response. This used to be + # a diff of on-chain positions against tracked ones (GW-6), which could + # not attribute an address to a transaction and gave up whenever two were + # new — Gateway generates the NFT keypair, so it is the only thing that + # can say which position this write created. + position_address = data.get("positionAddress") if position_address: await _book_position_add( accounts_service, db_manager, request, wallet_address, position_address, From cc60cffa2441de4cffa1b122dee97be1820fda87 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Thu, 20 Aug 2026 07:00:56 -0700 Subject: [PATCH 27/54] fix(pairs): read a trading pair from the right, so a hyphen in a symbol survives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `base, quote = trading_pair.split("-")` assumes no symbol contains a hyphen. Symbols now come off the chain rather than from a curated list, so they are whatever the mint says: the first pool Gateway's chain-learning recorded had a base of `DOGE-1`, making the pair `DOGE-1-SOL`. That unpacks three values into two, and the ValueError escaped as the HTTP message — `too many values to unpack (expected 2)` — which names neither the pair nor the problem. One helper rather than an rsplit at each site. utils/trading_pair.py splits from the right and raises InvalidTradingPair, a ValueError, which the routers already map to a 400. Splitting from the right is correct rather than merely forgiving: the quote asset is the last segment, so `DOGE-1-SOL` reads as `DOGE-1` over `SOL`, which is what it means. A hyphen in the *quote* symbol stays genuinely ambiguous. Eight sites. The two in gateway_swap are the ones that returned the 400. The two in orders_recorder sat inside a broad `except` that logged a warning, so a hyphenated pair became a silently missing fee rather than an error. The four `len(parts) == 2` guards — executors ×2, executor_ws_manager, executor_service — were skipping unrealized PnL without saying so; they keep their tolerance, since one unreadable pair should not fail a whole listing, but it now applies only to pairs that really are unreadable. A structural test asserts no bare split("-") on a trading pair survives under routers, services, utils or models. It found executor_service.py:1072, which reading by hand had missed. bots/controllers is out of scope: those are strategy templates, shipped separately. Verified by test rather than live — the hyphenated token is no longer in the local token list and none of the 42 highest-TVL mints across Orca and Meteora has one, so the original 400 could not be reproduced. The tests assert against the exact recorded string. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK --- routers/executors.py | 21 ++++++++---- routers/gateway_swap.py | 5 +-- routers/market_data.py | 5 +-- services/executor_service.py | 9 +++-- services/executor_ws_manager.py | 13 ++++--- services/orders_recorder.py | 5 +-- test/test_trading_pair_split.py | 61 +++++++++++++++++++++++++++++++++ utils/trading_pair.py | 37 ++++++++++++++++++++ 8 files changed, 137 insertions(+), 19 deletions(-) create mode 100644 test/test_trading_pair_split.py create mode 100644 utils/trading_pair.py diff --git a/routers/executors.py b/routers/executors.py index bffe1552..70922b51 100644 --- a/routers/executors.py +++ b/routers/executors.py @@ -28,6 +28,7 @@ from models.pagination import PaginatedResponse from services.executor_service import ExecutorService from services.market_data_service import MarketDataService +from utils.trading_pair import InvalidTradingPair, split_trading_pair logger = logging.getLogger(__name__) @@ -435,9 +436,15 @@ async def get_positions_summary( for p in positions: unrealized_pnl = None - parts = p.trading_pair.split("-") - if len(parts) == 2: - base, quote = parts + # A pair whose base symbol contains a hyphen used to split into three parts + # and fail this length check, leaving the PnL silently absent. Tolerance is + # still right here — one unreadable pair should not fail the whole listing — + # but it now applies only to pairs that really are unreadable. + try: + base, quote = split_trading_pair(p.trading_pair) + except InvalidTradingPair: + base = quote = None + if base and quote: rate = market_data_service.get_rate(base, quote) if rate is not None: unrealized_pnl = float(p.get_unrealized_pnl(rate)) @@ -514,9 +521,11 @@ async def get_position_held( ) unrealized_pnl = None - parts = trading_pair.split("-") - if len(parts) == 2: - base, quote = parts + try: + base, quote = split_trading_pair(trading_pair) + except InvalidTradingPair: + base = quote = None + if base and quote: rate = market_data_service.get_rate(base, quote) if rate is not None: unrealized_pnl = float(position.get_unrealized_pnl(rate)) diff --git a/routers/gateway_swap.py b/routers/gateway_swap.py index 9e826563..8e4ceab5 100644 --- a/routers/gateway_swap.py +++ b/routers/gateway_swap.py @@ -18,6 +18,7 @@ from routers.gateway_extras import ExtraParamsSpec, get_transaction_status_from_response, validate_extra_params from services.accounts_service import AccountsService from services.gateway_client import GatewayError, check_gateway_error, get_native_gas_token +from utils.trading_pair import split_trading_pair logger = logging.getLogger(__name__) @@ -59,7 +60,7 @@ async def get_swap_quote( raise HTTPException(status_code=503, detail="Gateway service is not available") # Parse trading pair - base, quote = request.trading_pair.split("-") + base, quote = split_trading_pair(request.trading_pair) # Get quote from Gateway result = check_gateway_error(await accounts_service.gateway_client.quote_swap( @@ -145,7 +146,7 @@ async def execute_swap( ) # Parse trading pair - base, quote = request.trading_pair.split("-") + base, quote = split_trading_pair(request.trading_pair) # Execute swap result = check_gateway_error(await accounts_service.gateway_client.execute_swap( diff --git a/routers/market_data.py b/routers/market_data.py index 2c14ed6f..42786fb3 100644 --- a/routers/market_data.py +++ b/routers/market_data.py @@ -37,6 +37,7 @@ from services.market_data_service import MarketDataService from services.ticker_sources import TickerFetchError, TickerUnsupportedError from services.unified_connector_service import UnknownConnectorError +from utils.trading_pair import split_trading_pair logger = logging.getLogger(__name__) @@ -379,7 +380,7 @@ async def get_rates( rates = {} for pair in request.trading_pairs: if request.connector: - base, quote = pair.split("-") if "-" in pair else (pair, None) + base, quote = split_trading_pair(pair) if "-" in pair else (pair, None) rate = market_data_manager.get_rate_for_connector(request.connector, base, quote) if quote else None else: rate = market_data_manager.get_pair_rate(pair) @@ -403,7 +404,7 @@ async def get_single_rate( Pass ``?connector=`` to restrict resolution to a single exchange's tickers. """ if connector: - base, quote = trading_pair.split("-") if "-" in trading_pair else (trading_pair, None) + base, quote = split_trading_pair(trading_pair) if "-" in trading_pair else (trading_pair, None) rate = market_data_manager.get_rate_for_connector(connector, base, quote) if quote else None else: rate = market_data_manager.get_pair_rate(trading_pair) diff --git a/services/executor_service.py b/services/executor_service.py index 46e58143..ef91ff7a 100644 --- a/services/executor_service.py +++ b/services/executor_service.py @@ -36,6 +36,7 @@ from models.executors import PositionHold from services.trading_service import AccountTradingInterface, TradingService from utils.executor_log_capture import ExecutorLogCapture, current_executor_id +from utils.trading_pair import InvalidTradingPair, split_trading_pair logger = logging.getLogger(__name__) @@ -1069,10 +1070,12 @@ async def get_performance_report( # First pass: try oracle for each position, collect misses grouped by connector missing_by_connector: Dict[str, List[tuple]] = {} # connector_key -> [(position, trading_pair)] for p in positions: - parts = p.trading_pair.split("-") - if len(parts) != 2: + # A hyphenated base symbol produced three parts and was skipped here, + # so the position simply contributed nothing to unrealized PnL. + try: + base, quote = split_trading_pair(p.trading_pair) + except InvalidTradingPair: continue - base, quote = parts rate = market_data_service.get_rate(base, quote) if rate is not None: unrealized_pnl += float(p.get_unrealized_pnl(rate)) diff --git a/services/executor_ws_manager.py b/services/executor_ws_manager.py index cdbe2f51..29191925 100644 --- a/services/executor_ws_manager.py +++ b/services/executor_ws_manager.py @@ -14,9 +14,10 @@ from fastapi import WebSocket +from services.bots_orchestrator import BotsOrchestrator from services.executor_service import ExecutorService from services.market_data_service import MarketDataService -from services.bots_orchestrator import BotsOrchestrator +from utils.trading_pair import InvalidTradingPair, split_trading_pair logger = logging.getLogger(__name__) @@ -376,9 +377,13 @@ async def _positions_push_loop( for p in positions: unrealized_pnl = None - parts = p.trading_pair.split("-") - if len(parts) == 2: - base, quote = parts + # See routers/executors.py: a hyphenated base symbol failed the + # old length check and dropped the PnL without saying so. + try: + base, quote = split_trading_pair(p.trading_pair) + except InvalidTradingPair: + base = quote = None + if base and quote: rate = self._market_data_service.get_rate(base, quote) if rate is not None: unrealized_pnl = float(p.get_unrealized_pnl(rate)) diff --git a/services/orders_recorder.py b/services/orders_recorder.py index 6aa6828f..1c18d376 100644 --- a/services/orders_recorder.py +++ b/services/orders_recorder.py @@ -11,6 +11,7 @@ from hummingbot.core.event.events import BuyOrderCreatedEvent, MarketEvent, OrderFilledEvent, SellOrderCreatedEvent, TradeType from database import AsyncDatabaseManager, OrderRepository, TradeRepository +from utils.trading_pair import split_trading_pair # Initialize logger logger = logging.getLogger(__name__) @@ -223,7 +224,7 @@ async def _handle_order_filled(self, event: OrderFilledEvent): if event.trade_fee: try: - base_asset, quote_asset = event.trading_pair.split("-") + base_asset, quote_asset = split_trading_pair(event.trading_pair) fee_in_quote = event.trade_fee.fee_amount_in_token( trading_pair=event.trading_pair, price=event.price, @@ -235,7 +236,7 @@ async def _handle_order_filled(self, event: OrderFilledEvent): except Exception as e: logger.warning(f"Primary fee calculation failed: {e}. Attempting fallback...") try: - base_asset, quote_asset = event.trading_pair.split("-") + base_asset, quote_asset = split_trading_pair(event.trading_pair) fallback_fee = await self._calculate_fee_fallback( trade_fee=event.trade_fee, base_asset=base_asset, diff --git a/test/test_trading_pair_split.py b/test/test_trading_pair_split.py new file mode 100644 index 00000000..477a9e73 --- /dev/null +++ b/test/test_trading_pair_split.py @@ -0,0 +1,61 @@ +"""A trading pair is base-quote, and a base symbol may contain a hyphen. + +Gateway reads a token's symbol off the chain the first time it is used, so symbols are +whatever the mint says rather than what a curated list allows. The first pool that +exercised that recorded its base as `DOGE-1`, and every bare `split("-")` in this service +either raised `too many values to unpack` at the caller — as a 400 naming neither the pair +nor the problem — or silently produced nothing. +""" +import pytest + +from utils.trading_pair import InvalidTradingPair, split_trading_pair + + +@pytest.mark.parametrize( + "pair,expected", + [ + ("SOL-USDC", ("SOL", "USDC")), + # The case found live. `DOGE-1` over `SOL`, not three assets. + ("DOGE-1-SOL", ("DOGE-1", "SOL")), + ("ETH-USDT", ("ETH", "USDT")), + # Splitting from the right is what makes extra hyphens belong to the base. + ("a-b-c-USDC", ("a-b-c", "USDC")), + ], +) +def test_the_quote_asset_is_the_last_segment(pair, expected): + assert split_trading_pair(pair) == expected + + +@pytest.mark.parametrize("pair", ["SOL", "", "-SOL", "SOL-", "-"]) +def test_a_pair_that_is_not_base_quote_is_rejected_by_name(pair): + with pytest.raises(InvalidTradingPair) as raised: + split_trading_pair(pair) + + # The message a caller sees has to name the pair; `too many values to unpack` did not, + # which is what made the live failure unreadable. + assert repr(pair) in str(raised.value) + + +def test_the_error_is_a_value_error(): + """The routers map ValueError to a 400, so this keeps that mapping without new code.""" + assert issubclass(InvalidTradingPair, ValueError) + + +def test_no_bare_two_way_unpack_survives_in_the_service(): + """The defect was one line repeated; this is what stops it being reintroduced. + + Only the service's own code is checked. `bots/controllers/` holds strategy templates + that are edited and shipped separately. + """ + import pathlib + import re + + root = pathlib.Path(__file__).resolve().parent.parent + offenders = [] + for directory in ("routers", "services", "utils", "models"): + for path in (root / directory).rglob("*.py"): + for number, line in enumerate(path.read_text().splitlines(), 1): + if re.search(r"=\s*[\w.]*(trading_pair|pair)\.split\(\"-\"\)", line): + offenders.append(f"{path.relative_to(root)}:{number}") + + assert offenders == [], "bare split('-') on a trading pair: use split_trading_pair" diff --git a/utils/trading_pair.py b/utils/trading_pair.py new file mode 100644 index 00000000..a91cd1ca --- /dev/null +++ b/utils/trading_pair.py @@ -0,0 +1,37 @@ +"""Splitting a trading pair into its two assets. + +A pair is written base-quote, which is unambiguous only while no symbol contains a +hyphen. Symbols now come off the chain rather than from a curated list — Gateway reads a +token's name, symbol and decimals from its mint the first time it is used — so they are +whatever the mint says. The first pool that exercised this recorded its base token as +`DOGE-1`, making the pair `DOGE-1-SOL`. + +`"DOGE-1-SOL".split("-")` returns three parts, and unpacking three into two raises +`ValueError: too many values to unpack (expected 2)`. That escaped to callers as the HTTP +message, which names neither the pair nor the problem. + +Splitting from the right is correct rather than merely more forgiving: the quote asset is +the last segment, so `rsplit("-", 1)` reads `DOGE-1-SOL` as `DOGE-1` over `SOL` — which is +what it means. A quote symbol containing a hyphen is genuinely ambiguous and stays so. +""" + + +class InvalidTradingPair(ValueError): + """A pair that cannot be read as base-quote, naming the pair that could not be.""" + + +def split_trading_pair(trading_pair: str) -> tuple[str, str]: + """Split `base-quote` into its two assets, tolerating a hyphen in the base symbol. + + Raises InvalidTradingPair when there is no hyphen to split on, or when either side is + empty — `"-SOL"` and `"SOL-"` are malformed rather than merely unusual. + """ + base, separator, quote = trading_pair.rpartition("-") + + if not separator or not base or not quote: + raise InvalidTradingPair( + f"Trading pair {trading_pair!r} is not in base-quote form. " + "Expected two asset symbols separated by a hyphen, e.g. 'SOL-USDC'." + ) + + return base, quote From 363c9f20664ae5b82af839d42e59b372210595ae Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Thu, 20 Aug 2026 07:12:04 -0700 Subject: [PATCH 28/54] chore(gateway): refresh the vendored spec and models, dropping a real wallet address Gateway's generator now writes the template placeholders for walletAddress defaults and the template port for servers[0].url, so the spec is the same on any machine. Before that it carried whoever generated it: a real trading wallet, 21 times, vendored here and baked into two lines of the generated models. Nothing else moves. The models diff is exactly the two lines that held the address. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK --- gateway-openapi.json | 44 ++++++++++++++++++------------------- models/gateway_generated.py | 4 ++-- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/gateway-openapi.json b/gateway-openapi.json index b6145150..fa4e0721 100644 --- a/gateway-openapi.json +++ b/gateway-openapi.json @@ -1607,7 +1607,7 @@ }, "walletAddress": { "description": "Wallet address (pool creator + payer)", - "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "default": "", "type": "string" }, "baseToken": { @@ -1704,7 +1704,7 @@ }, "walletAddress": { "description": "Wallet address", - "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "default": "", "type": "string" }, "poolAddress": { @@ -1785,7 +1785,7 @@ }, "walletAddress": { "description": "Wallet address", - "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "default": "", "type": "string" }, "poolAddress": { @@ -1916,7 +1916,7 @@ }, "walletAddress": { "description": "Wallet address", - "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "default": "", "type": "string" } }, @@ -1966,7 +1966,7 @@ }, "walletAddress": { "description": "Wallet address to list positions for", - "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "default": "", "type": "string" } }, @@ -2085,7 +2085,7 @@ }, "walletAddress": { "description": "Wallet address", - "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "default": "", "type": "string" }, "lowerPrice": { @@ -2184,7 +2184,7 @@ }, "walletAddress": { "description": "Wallet address", - "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "default": "", "type": "string" }, "positionAddress": { @@ -2269,7 +2269,7 @@ }, "walletAddress": { "description": "Wallet address", - "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "default": "", "type": "string" }, "positionAddress": { @@ -2344,7 +2344,7 @@ }, "walletAddress": { "description": "Wallet address", - "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "default": "", "type": "string" }, "positionAddress": { @@ -2401,7 +2401,7 @@ }, "walletAddress": { "description": "Wallet address", - "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "default": "", "type": "string" }, "positionAddress": { @@ -2458,7 +2458,7 @@ }, "walletAddress": { "description": "Wallet address (pool creator + payer)", - "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "default": "", "type": "string" }, "baseToken": { @@ -2747,7 +2747,7 @@ }, "walletAddress": { "description": "Wallet address", - "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "default": "", "type": "string" } }, @@ -2884,7 +2884,7 @@ }, "walletAddress": { "description": "Wallet address that will execute the quote", - "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "default": "", "type": "string" }, "quoteId": { @@ -2940,7 +2940,7 @@ }, "walletAddress": { "description": "Wallet address that will execute the swap", - "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "default": "", "type": "string" }, "baseToken": { @@ -3308,7 +3308,7 @@ }, "walletAddress": { "description": "Wallet address that will execute the swap", - "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "default": "", "type": "string" }, "baseToken": { @@ -3399,7 +3399,7 @@ }, "walletAddress": { "description": "Wallet address that will execute the swap", - "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "default": "", "type": "string" }, "baseToken": { @@ -3473,7 +3473,7 @@ }, "address": { "description": "Ethereum wallet address", - "default": "0xDA50C69342216b538Daf06FfECDa7363E0B96684", + "default": "", "type": "string" }, "spender": { @@ -3522,7 +3522,7 @@ }, "address": { "description": "Ethereum wallet address", - "default": "0xDA50C69342216b538Daf06FfECDa7363E0B96684", + "default": "", "type": "string" }, "spender": { @@ -6117,7 +6117,7 @@ }, { "schema": { - "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "default": "", "type": "string" }, "in": "query", @@ -6916,7 +6916,7 @@ }, { "schema": { - "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "default": "", "type": "string" }, "in": "query", @@ -6992,7 +6992,7 @@ }, { "schema": { - "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "default": "", "type": "string" }, "in": "query", @@ -7795,7 +7795,7 @@ }, "servers": [ { - "url": "http://localhost:15889" + "url": "http://localhost:15888" } ], "tags": [ diff --git a/models/gateway_generated.py b/models/gateway_generated.py index e4d08562..7c1da5a7 100644 --- a/models/gateway_generated.py +++ b/models/gateway_generated.py @@ -554,14 +554,14 @@ class ClmmExecuteSwapRequest(BaseModel): class AllowancesRequest(BaseModel): network: str | None = Field('mainnet', description='The Ethereum network to use') - address: str | None = Field('0xDA50C69342216b538Daf06FfECDa7363E0B96684', description='Ethereum wallet address') + address: str | None = Field('', description='Ethereum wallet address') spender: str = Field(..., description='Connector name (e.g., uniswap/clmm, uniswap/amm, 0x/router) or contract address', examples=['uniswap/router']) tokens: list[str] = Field(..., description='Array of token symbols or addresses', examples=[['USDC', 'WETH']]) class ApproveRequest(BaseModel): network: str | None = Field('mainnet', description='The Ethereum network to use') - address: str | None = Field('0xDA50C69342216b538Daf06FfECDa7363E0B96684', description='Ethereum wallet address') + address: str | None = Field('', description='Ethereum wallet address') spender: str = Field(..., description='Connector name (e.g., uniswap/clmm, uniswap/amm, 0x/router) contract address', examples=['uniswap/router']) token: str = Field(..., description='Token symbol or address', examples=['USDC']) amount: str | None = Field('', description='The amount to approve. If not provided, defaults to maximum amount (unlimited approval).') From 53bd36933fe0ebd5ed6a81d0d70e92dfe34aa4ac Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Thu, 20 Aug 2026 07:58:20 -0700 Subject: [PATCH 29/54] fix(db): give AMM positions the rent columns their code already uses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit baaec0f added position_rent and position_rent_refunded to both position repositories, having checked the columns existed — but at database/models.py:289, inside GatewayCLMMPosition. The AMM model has neither, and the two failures look nothing alike. Reading one raises AttributeError, which reached callers as `500 'GatewayAMMPosition' object has no attribute 'position_rent'` on every POST /gateway/amm/positions/search. Writing one raises TypeError inside GatewayAMMPosition(**position_data), where the broad except around the booking logs it and carries on — so a confirmed DAMM v2 open was simply never recorded, with nothing to see. close_position(position_rent_refunded=...) would have failed the same way on the first close, which is what made GW-20 and GW-21 unverifiable end to end. The columns belong on the AMM table rather than being dropped from the code: a DAMM v2 position is an NFT with its own account, so it locks rent exactly as a CLMM position does, and that is the accounting GW-20 exists to get right. create_all only creates missing tables, so a model gaining a column reaches an existing database only through _run_migrations. Both tables get entries — the CLMM pair had none either, so any database predating those columns is missing them too. The suite had no test touching the AMM position_to_dict, which is why 139 passed over a live 500. There is one now, and it is structural rather than a case: every attribute those two methods read or assign must be a real column on the model they are given, and every rent column must have a migration. Removing the columns again fails three of its six. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK --- database/connection.py | 20 +++++ database/models.py | 7 ++ ...test_position_models_match_their_tables.py | 88 +++++++++++++++++++ 3 files changed, 115 insertions(+) create mode 100644 test/test_position_models_match_their_tables.py diff --git a/database/connection.py b/database/connection.py index a9ae68f7..2546ac85 100644 --- a/database/connection.py +++ b/database/connection.py @@ -74,6 +74,26 @@ async def _run_migrations(self, conn): "position_holds", "cum_fees_quote", "ALTER TABLE position_holds ADD COLUMN cum_fees_quote NUMERIC(30,18) NOT NULL DEFAULT 0" ), + # Position-account rent, locked on open and refunded on close. create_all only + # creates missing tables, so a model gaining a column reaches an existing + # database only through this list. Both position tables carry the pair: DAMM v2 + # positions are NFTs with their own account, exactly like CLMM ones. + ( + "gateway_clmm_positions", "position_rent", + "ALTER TABLE gateway_clmm_positions ADD COLUMN position_rent NUMERIC(30,18)" + ), + ( + "gateway_clmm_positions", "position_rent_refunded", + "ALTER TABLE gateway_clmm_positions ADD COLUMN position_rent_refunded NUMERIC(30,18)" + ), + ( + "gateway_amm_positions", "position_rent", + "ALTER TABLE gateway_amm_positions ADD COLUMN position_rent NUMERIC(30,18)" + ), + ( + "gateway_amm_positions", "position_rent_refunded", + "ALTER TABLE gateway_amm_positions ADD COLUMN position_rent_refunded NUMERIC(30,18)" + ), ] for table, column, sql in migrations: try: diff --git a/database/models.py b/database/models.py index 154a25b6..aae54c56 100644 --- a/database/models.py +++ b/database/models.py @@ -387,6 +387,13 @@ class GatewayAMMPosition(Base): quote_token_amount = Column(Numeric(precision=30, scale=18), nullable=False, default=0) lp_token_amount = Column(Numeric(precision=30, scale=18), nullable=True) + # Rent for the position account, locked on open and returned by the chain on close. + # DAMM v2 positions are NFTs with their own account, so they carry rent exactly as + # CLMM positions do — it is not liquidity, and a close that refunds less than was + # locked means an account was left behind. + position_rent = Column(Numeric(precision=30, scale=18), nullable=True) + position_rent_refunded = Column(Numeric(precision=30, scale=18), nullable=True) + entry_price = Column(Numeric(precision=30, scale=18), nullable=True) # base-weighted across adds current_price = Column(Numeric(precision=30, scale=18), nullable=True) diff --git a/test/test_position_models_match_their_tables.py b/test/test_position_models_match_their_tables.py new file mode 100644 index 00000000..9aa27558 --- /dev/null +++ b/test/test_position_models_match_their_tables.py @@ -0,0 +1,88 @@ +"""Every field the gateway position code reads or writes must be a real column. + +`position_to_dict` and `create_position` name columns as plain attributes, so a field that +does not exist fails only when the code path runs — and each fails differently. Reading a +missing attribute raises AttributeError, which surfaced as +`500 'GatewayAMMPosition' object has no attribute 'position_rent'` on +POST /gateway/amm/positions/search. Writing one raises TypeError inside +`GatewayAMMPosition(**position_data)`, where a broad `except` around the booking logs it +and moves on — so an opened position is simply never recorded, with no error to see. + +Both came from the same commit, which added the two rent columns to the CLMM model and to +*both* repositories. The suite had no test touching the AMM `position_to_dict`, so it +stayed green. +""" +import inspect +import re + +import pytest + +from database.models import GatewayAMMPosition, GatewayCLMMPosition +from database.repositories.gateway_amm_repository import GatewayAMMRepository +from database.repositories.gateway_clmm_repository import GatewayCLMMRepository + + +def _columns(model) -> set: + return {column.name for column in model.__table__.columns} + + +# `"key": num(position.attr)` and `"key": position.attr` — how position_to_dict reads. +_ATTRIBUTE_READ = re.compile(r"position\.(\w+)") + + +@pytest.mark.parametrize( + "repository,model", + [(GatewayAMMRepository, GatewayAMMPosition), (GatewayCLMMRepository, GatewayCLMMPosition)], +) +def test_position_to_dict_reads_only_real_columns(repository, model): + source = inspect.getsource(repository.position_to_dict) + read = set(_ATTRIBUTE_READ.findall(source)) + # Attributes that are genuinely methods or relationships, not columns, would go here. + missing = sorted(name for name in read if name not in _columns(model)) + + assert missing == [], ( + f"{repository.__name__}.position_to_dict reads {missing} but {model.__name__} has no such " + f"column — an AttributeError at request time, not at import." + ) + + +@pytest.mark.parametrize( + "repository,model", + [(GatewayAMMRepository, GatewayAMMPosition), (GatewayCLMMRepository, GatewayCLMMPosition)], +) +def test_close_position_writes_only_real_columns(repository, model): + source = inspect.getsource(repository.close_position) + written = set(re.findall(r"position\.(\w+)\s*=", source)) + missing = sorted(name for name in written if name not in _columns(model)) + + assert missing == [], f"{repository.__name__}.close_position assigns {missing}, absent from {model.__name__}" + + +def test_both_position_tables_carry_the_rent_columns(): + """A DAMM v2 position is an NFT with its own account, so it locks rent like a CLMM one. + + Pinned by name because the two tables drifted apart silently: the columns were added to + one model and then used from both repositories. + """ + for model in (GatewayAMMPosition, GatewayCLMMPosition): + assert "position_rent" in _columns(model), model.__name__ + assert "position_rent_refunded" in _columns(model), model.__name__ + + +def test_every_model_column_addition_has_a_migration(): + """create_all only creates missing tables, so a new column needs an ALTER to land. + + Without one the model and an existing database disagree, which is the same failure in + a different disguise — the attribute exists in Python and the column does not in SQL. + """ + from database import connection + + migration_source = inspect.getsource(connection.AsyncDatabaseManager._run_migrations) + + for model in (GatewayAMMPosition, GatewayCLMMPosition): + for column in ("position_rent", "position_rent_refunded"): + expected = f'"{model.__tablename__}", "{column}"' + assert expected in migration_source, ( + f"{model.__tablename__}.{column} has no migration entry, so it will be missing " + f"from any database created before it was added to the model." + ) From baefa28765af3e29fafcee7b27e002a3a99be147 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Thu, 20 Aug 2026 09:53:25 -0700 Subject: [PATCH 30/54] chore(gateway): follow the response renames and the named operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gateway prefixed its response components so an AMM shape and its CLMM twin no longer share a name, and gave every operation an operationId and an error model. Refreshes the vendored spec and the generated models, and moves the four passthrough pins that named the old components: PoolInfo, QuotePositionResponse, QuoteLiquidityResponse and CreatePoolResponse. The pins are what caught it — regenerating alone would have left them comparing against components the spec no longer defines. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK --- gateway-openapi.json | 1471 +++++++++++++++++++++--- models/gateway_generated.py | 72 +- test/test_gateway_models_match_spec.py | 12 +- 3 files changed, 1382 insertions(+), 173 deletions(-) diff --git a/gateway-openapi.json b/gateway-openapi.json index fa4e0721..85dd5ffc 100644 --- a/gateway-openapi.json +++ b/gateway-openapi.json @@ -115,7 +115,7 @@ "quoteTokenAmountAdded" ] }, - "QuoteLiquidityResponse": { + "AmmQuoteLiquidityResponse": { "type": "object", "properties": { "poolAddress": { @@ -210,7 +210,7 @@ "quoteTokenAmountRemoved" ] }, - "CreatePoolResponse": { + "AmmCreatePoolResponse": { "type": "object", "properties": { "signature": { @@ -230,7 +230,7 @@ "type": "number" }, "data": { - "$ref": "#/components/schemas/CreatePoolResponseData" + "$ref": "#/components/schemas/AmmCreatePoolResponseData" } }, "required": [ @@ -239,7 +239,7 @@ "poolAddress" ] }, - "CreatePoolResponseData": { + "AmmCreatePoolResponseData": { "type": "object", "properties": { "fee": { @@ -1053,7 +1053,7 @@ "tvl" ] }, - "FetchPoolsResponse": { + "ClmmFetchPoolsResponse": { "type": "object", "properties": { "pools": { @@ -1108,7 +1108,7 @@ "quoteTokenAmount" ] }, - "PoolInfo": { + "ClmmPoolInfo": { "type": "object", "properties": { "address": { @@ -1160,7 +1160,7 @@ "activeBinId" ] }, - "PositionInfo": { + "ClmmPositionInfo": { "type": "object", "properties": { "address": { @@ -1226,7 +1226,7 @@ "price" ] }, - "OpenPositionResponse": { + "ClmmOpenPositionResponse": { "type": "object", "properties": { "signature": { @@ -1237,7 +1237,7 @@ "type": "number" }, "data": { - "$ref": "#/components/schemas/OpenPositionResponseData" + "$ref": "#/components/schemas/ClmmOpenPositionResponseData" } }, "required": [ @@ -1245,7 +1245,7 @@ "status" ] }, - "OpenPositionResponseData": { + "ClmmOpenPositionResponseData": { "type": "object", "properties": { "fee": { @@ -1280,7 +1280,7 @@ "quoteTokenAmountAdded" ] }, - "AddLiquidityResponse": { + "ClmmAddLiquidityResponse": { "type": "object", "properties": { "signature": { @@ -1291,7 +1291,7 @@ "type": "number" }, "data": { - "$ref": "#/components/schemas/AddLiquidityResponseData" + "$ref": "#/components/schemas/ClmmAddLiquidityResponseData" } }, "required": [ @@ -1299,7 +1299,7 @@ "status" ] }, - "AddLiquidityResponseData": { + "ClmmAddLiquidityResponseData": { "type": "object", "properties": { "fee": { @@ -1329,7 +1329,7 @@ "quoteTokenAmountAdded" ] }, - "RemoveLiquidityResponse": { + "ClmmRemoveLiquidityResponse": { "type": "object", "properties": { "signature": { @@ -1340,7 +1340,7 @@ "type": "number" }, "data": { - "$ref": "#/components/schemas/RemoveLiquidityResponseData" + "$ref": "#/components/schemas/ClmmRemoveLiquidityResponseData" } }, "required": [ @@ -1348,7 +1348,7 @@ "status" ] }, - "RemoveLiquidityResponseData": { + "ClmmRemoveLiquidityResponseData": { "type": "object", "properties": { "fee": { @@ -1378,7 +1378,7 @@ "quoteTokenAmountRemoved" ] }, - "CollectFeesResponse": { + "ClmmCollectFeesResponse": { "type": "object", "properties": { "signature": { @@ -1389,7 +1389,7 @@ "type": "number" }, "data": { - "$ref": "#/components/schemas/CollectFeesResponseData" + "$ref": "#/components/schemas/ClmmCollectFeesResponseData" } }, "required": [ @@ -1397,7 +1397,7 @@ "status" ] }, - "CollectFeesResponseData": { + "ClmmCollectFeesResponseData": { "type": "object", "properties": { "fee": { @@ -1427,7 +1427,7 @@ "quoteFeeAmountCollected" ] }, - "ClosePositionResponse": { + "ClmmClosePositionResponse": { "type": "object", "properties": { "signature": { @@ -1438,7 +1438,7 @@ "type": "number" }, "data": { - "$ref": "#/components/schemas/ClosePositionResponseData" + "$ref": "#/components/schemas/ClmmClosePositionResponseData" } }, "required": [ @@ -1446,7 +1446,7 @@ "status" ] }, - "ClosePositionResponseData": { + "ClmmClosePositionResponseData": { "type": "object", "properties": { "fee": { @@ -1532,7 +1532,7 @@ "fee" ] }, - "QuotePositionResponse": { + "ClmmQuoteLiquidityResponse": { "type": "object", "properties": { "poolAddress": { @@ -3498,6 +3498,24 @@ "tokens" ] }, + "AllowancesResponse": { + "type": "object", + "properties": { + "spender": { + "type": "string" + }, + "approvals": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "spender", + "approvals" + ] + }, "ApproveRequest": { "type": "object", "properties": { @@ -3546,6 +3564,52 @@ "token" ] }, + "ApproveResponse": { + "type": "object", + "properties": { + "signature": { + "type": "string" + }, + "status": { + "description": "TransactionStatus enum value", + "type": "number" + }, + "data": { + "$ref": "#/components/schemas/ApproveResponseData" + } + }, + "required": [ + "signature", + "status" + ] + }, + "ApproveResponseData": { + "type": "object", + "properties": { + "tokenAddress": { + "type": "string" + }, + "spender": { + "type": "string" + }, + "amount": { + "type": "string" + }, + "nonce": { + "type": "number" + }, + "fee": { + "type": "string" + } + }, + "required": [ + "tokenAddress", + "spender", + "amount", + "nonce", + "fee" + ] + }, "RemoveWalletRequest": { "type": "object", "properties": { @@ -3633,12 +3697,52 @@ "address", "decimals" ] + }, + "ErrorResponse": { + "type": "object", + "properties": { + "statusCode": { + "description": "HTTP status code", + "type": "integer", + "example": 400 + }, + "error": { + "description": "HTTP status name", + "type": "string", + "example": "Bad Request" + }, + "message": { + "description": "What went wrong, in terms of the request that caused it", + "type": "string", + "example": "Connector 'meteora' runs on solana, not ethereum" + }, + "code": { + "description": "Machine-readable cause, present when Gateway can name one. This is what a caller branches on: TRANSACTION_TIMEOUT and RATE_LIMITED are retryable, the rest are not.", + "enum": [ + "TRANSACTION_TIMEOUT", + "SIMULATION_FAILED", + "TRANSACTION_FAILED", + "INSUFFICIENT_BALANCE", + "INVALID_PARAMS", + "SLIPPAGE_EXCEEDED", + "NO_ROUTE_FOUND", + "RATE_LIMITED" + ], + "type": "string" + } + }, + "required": [ + "statusCode", + "error", + "message" + ] } } }, "paths": { "/config/": { "get": { + "operationId": "getConfig", "tags": [ "/config" ], @@ -3679,12 +3783,33 @@ } } } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, "/config/update": { "post": { + "operationId": "updateConfig", "tags": [ "/config" ], @@ -3794,12 +3919,33 @@ } } } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, "/config/chains": { "get": { + "operationId": "listChains", "tags": [ "/config" ], @@ -3840,12 +3986,33 @@ } } } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, "/config/connectors": { "get": { + "operationId": "listConnectors", "tags": [ "/config" ], @@ -3897,12 +4064,33 @@ } } } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, "/config/namespaces": { "get": { + "operationId": "listNamespaces", "tags": [ "/config" ], @@ -3928,12 +4116,33 @@ } } } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, "/wallet/": { "get": { + "operationId": "listWallets", "tags": [ "/wallet" ], @@ -3989,12 +4198,33 @@ } } } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, "/wallet/add": { "post": { + "operationId": "addWallet", "tags": [ "/wallet" ], @@ -4058,12 +4288,33 @@ } } } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, "/wallet/add-hardware": { "post": { + "operationId": "addHardwareWallet", "tags": [ "/wallet" ], @@ -4111,12 +4362,33 @@ } } } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, "/wallet/remove": { "delete": { + "operationId": "removeWallet", "tags": [ "/wallet" ], @@ -4149,12 +4421,33 @@ } } } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, "/wallet/setDefault": { "post": { + "operationId": "setDefaultWallet", "tags": [ "/wallet" ], @@ -4231,12 +4524,33 @@ } } } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, "/tokens/{symbolOrAddress}": { "get": { + "operationId": "getToken", "tags": [ "/tokens" ], @@ -4315,20 +4629,41 @@ } } } - } - } - } - }, - "/tokens/find/{address}": { - "get": { - "tags": [ - "/tokens" - ], - "description": "Get token information with market data from GeckoTerminal by address", - "parameters": [ - { - "schema": { - "type": "string" + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/tokens/find/{address}": { + "get": { + "operationId": "findToken", + "tags": [ + "/tokens" + ], + "description": "Get token information with market data from GeckoTerminal by address", + "parameters": [ + { + "schema": { + "type": "string" }, "examples": { "solana-mainnet-beta": { @@ -4377,12 +4712,33 @@ } } } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, "/tokens/": { "get": { + "operationId": "listTokens", "tags": [ "/tokens" ], @@ -4464,10 +4820,31 @@ } } } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } }, "post": { + "operationId": "addToken", "tags": [ "/tokens" ], @@ -4521,12 +4898,33 @@ } } } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, "/tokens/save/{address}": { "post": { + "operationId": "saveToken", "tags": [ "/tokens" ], @@ -4595,12 +4993,33 @@ } } } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, "/tokens/{address}": { "delete": { + "operationId": "removeToken", "tags": [ "/tokens" ], @@ -4672,12 +5091,33 @@ } } } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, "/pools/{tradingPair}": { "get": { + "operationId": "getPool", "tags": [ "/pools" ], @@ -4840,17 +5280,22 @@ } } }, - "404": { + "400": { "description": "Default Response", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - } + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -4860,6 +5305,7 @@ }, "/pools/find/{address}": { "get": { + "operationId": "findPool", "tags": [ "/pools" ], @@ -4965,12 +5411,33 @@ } } } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, "/pools/find": { "get": { + "operationId": "findPools", "tags": [ "/pools" ], @@ -5165,12 +5632,33 @@ } } } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, "/pools/": { "get": { + "operationId": "listPools", "tags": [ "/pools" ], @@ -5332,10 +5820,31 @@ } } } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } }, "post": { + "operationId": "addPool", "tags": [ "/pools" ], @@ -5442,12 +5951,17 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - } + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -5457,6 +5971,7 @@ }, "/pools/save/{address}": { "post": { + "operationId": "savePool", "tags": [ "/pools" ], @@ -5580,12 +6095,33 @@ } } } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, "/pools/{address}": { "delete": { + "operationId": "removePool", "tags": [ "/pools" ], @@ -5654,17 +6190,22 @@ } } }, - "404": { + "400": { "description": "Default Response", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - } + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -5674,6 +6215,7 @@ }, "/trading/router/quote-swap": { "get": { + "operationId": "quoteRouterSwap", "tags": [ "/trading/router" ], @@ -5832,12 +6374,33 @@ } } } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, "/trading/router/execute-quote": { "post": { + "operationId": "executeRouterQuote", "tags": [ "/trading/router" ], @@ -5861,17 +6424,38 @@ } } } - } - } - } - }, - "/trading/router/execute-swap": { - "post": { - "tags": [ - "/trading/router" - ], - "description": "Quote and execute a swap through a router connector on any supported chain", - "requestBody": { + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/trading/router/execute-swap": { + "post": { + "operationId": "executeRouterSwap", + "tags": [ + "/trading/router" + ], + "description": "Quote and execute a swap through a router connector on any supported chain", + "requestBody": { "content": { "application/json": { "schema": { @@ -5890,12 +6474,33 @@ } } } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, "/trading/clmm/pool-info": { "get": { + "operationId": "getClmmPoolInfo", "tags": [ "/trading/clmm" ], @@ -5976,7 +6581,27 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PoolInfo" + "$ref": "#/components/schemas/ClmmPoolInfo" + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -5986,6 +6611,7 @@ }, "/trading/clmm/position-info": { "get": { + "operationId": "getClmmPositionInfo", "tags": [ "/trading/clmm" ], @@ -6054,7 +6680,27 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PositionInfo" + "$ref": "#/components/schemas/ClmmPositionInfo" + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -6064,6 +6710,7 @@ }, "/trading/clmm/positions-owned": { "get": { + "operationId": "listClmmPositions", "tags": [ "/trading/clmm" ], @@ -6134,17 +6781,38 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/PositionInfo" + "$ref": "#/components/schemas/ClmmPositionInfo" } } } } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, "/trading/clmm/quote-liquidity": { "get": { + "operationId": "quoteClmmLiquidity", "tags": [ "/trading/clmm" ], @@ -6270,7 +6938,27 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QuotePositionResponse" + "$ref": "#/components/schemas/ClmmQuoteLiquidityResponse" + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -6280,6 +6968,7 @@ }, "/trading/clmm/fetch-pools": { "get": { + "operationId": "fetchClmmPools", "tags": [ "/trading/clmm" ], @@ -6433,7 +7122,27 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FetchPoolsResponse" + "$ref": "#/components/schemas/ClmmFetchPoolsResponse" + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -6443,6 +7152,7 @@ }, "/trading/clmm/quote-swap": { "get": { + "operationId": "quoteClmmSwap", "tags": [ "/trading/clmm" ], @@ -6572,12 +7282,33 @@ } } } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, "/trading/clmm/execute-swap": { "post": { + "operationId": "executeClmmSwap", "tags": [ "/trading/clmm" ], @@ -6601,12 +7332,33 @@ } } } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, "/trading/clmm/open": { "post": { + "operationId": "openClmmPosition", "tags": [ "/trading/clmm" ], @@ -6626,7 +7378,27 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OpenPositionResponse" + "$ref": "#/components/schemas/ClmmOpenPositionResponse" + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -6636,6 +7408,7 @@ }, "/trading/clmm/add": { "post": { + "operationId": "addClmmLiquidity", "tags": [ "/trading/clmm" ], @@ -6655,7 +7428,27 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AddLiquidityResponse" + "$ref": "#/components/schemas/ClmmAddLiquidityResponse" + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -6665,6 +7458,7 @@ }, "/trading/clmm/remove": { "post": { + "operationId": "removeClmmLiquidity", "tags": [ "/trading/clmm" ], @@ -6684,7 +7478,27 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RemoveLiquidityResponse" + "$ref": "#/components/schemas/ClmmRemoveLiquidityResponse" + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -6694,6 +7508,7 @@ }, "/trading/clmm/collect-fees": { "post": { + "operationId": "collectClmmFees", "tags": [ "/trading/clmm" ], @@ -6713,7 +7528,27 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CollectFeesResponse" + "$ref": "#/components/schemas/ClmmCollectFeesResponse" + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -6723,6 +7558,7 @@ }, "/trading/clmm/close": { "post": { + "operationId": "closeClmmPosition", "tags": [ "/trading/clmm" ], @@ -6742,7 +7578,27 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ClosePositionResponse" + "$ref": "#/components/schemas/ClmmClosePositionResponse" + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -6752,6 +7608,7 @@ }, "/trading/clmm/create-pool": { "post": { + "operationId": "createClmmPool", "tags": [ "/trading/clmm" ], @@ -6775,13 +7632,34 @@ } } } - } - } - } - }, - "/trading/amm/pool-info": { - "get": { - "tags": [ + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/trading/amm/pool-info": { + "get": { + "operationId": "getAmmPoolInfo", + "tags": [ "/trading/amm" ], "description": "Get AMM pool information from any supported connector", @@ -6850,12 +7728,33 @@ } } } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, "/trading/amm/position-info": { "get": { + "operationId": "getAmmPositionInfo", "tags": [ "/trading/amm" ], @@ -6935,12 +7834,33 @@ } } } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, "/trading/amm/positions-owned": { "get": { + "operationId": "listAmmPositions", "tags": [ "/trading/amm" ], @@ -7014,12 +7934,33 @@ } } } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, "/trading/amm/quote-liquidity": { "get": { + "operationId": "quoteAmmLiquidity", "tags": [ "/trading/amm" ], @@ -7118,7 +8059,27 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/QuoteLiquidityResponse" + "$ref": "#/components/schemas/AmmQuoteLiquidityResponse" + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -7128,6 +8089,7 @@ }, "/trading/amm/quote-swap": { "get": { + "operationId": "quoteAmmSwap", "tags": [ "/trading/amm" ], @@ -7255,12 +8217,33 @@ } } } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, "/trading/amm/execute-swap": { "post": { + "operationId": "executeAmmSwap", "tags": [ "/trading/amm" ], @@ -7284,12 +8267,33 @@ } } } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, "/trading/amm/add": { "post": { + "operationId": "addAmmLiquidity", "tags": [ "/trading/amm" ], @@ -7313,12 +8317,33 @@ } } } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, "/trading/amm/remove": { "post": { + "operationId": "removeAmmLiquidity", "tags": [ "/trading/amm" ], @@ -7342,12 +8367,33 @@ } } } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, "/trading/amm/create-pool": { "post": { + "operationId": "createAmmPool", "tags": [ "/trading/amm" ], @@ -7367,7 +8413,27 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreatePoolResponse" + "$ref": "#/components/schemas/AmmCreatePoolResponse" + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -7377,6 +8443,7 @@ }, "/chains/{chain}/status": { "get": { + "operationId": "getChainStatus", "tags": [ "/chains" ], @@ -7433,12 +8500,33 @@ } } } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, "/chains/{chain}/estimate-gas": { "get": { + "operationId": "estimateGas", "tags": [ "/chains" ], @@ -7495,12 +8583,33 @@ } } } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, "/chains/{chain}/balances": { "post": { + "operationId": "getBalances", "tags": [ "/chains" ], @@ -7540,12 +8649,33 @@ } } } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, "/chains/{chain}/poll": { "post": { + "operationId": "pollTransaction", "tags": [ "/chains" ], @@ -7585,12 +8715,33 @@ } } } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, "/chains/{chain}/wrap": { "post": { + "operationId": "wrapNativeToken", "tags": [ "/chains" ], @@ -7630,12 +8781,33 @@ } } } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, "/chains/{chain}/unwrap": { "post": { + "operationId": "unwrapNativeToken", "tags": [ "/chains" ], @@ -7675,14 +8847,35 @@ } } } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } } } } }, "/chains/ethereum/allowances": { "post": { + "operationId": "getAllowances", "tags": [ - "/chain/ethereum" + "/chains" ], "description": "Get token allowances", "requestBody": { @@ -7700,22 +8893,27 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "spender": { - "type": "string" - }, - "approvals": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "required": [ - "spender", - "approvals" - ] + "$ref": "#/components/schemas/AllowancesResponse" + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -7725,8 +8923,9 @@ }, "/chains/ethereum/approve": { "post": { + "operationId": "approveToken", "tags": [ - "/chain/ethereum" + "/chains" ], "description": "Approve token spending", "requestBody": { @@ -7744,47 +8943,27 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "signature": { - "type": "string" - }, - "status": { - "description": "TransactionStatus enum value", - "type": "number" - }, - "data": { - "type": "object", - "properties": { - "tokenAddress": { - "type": "string" - }, - "spender": { - "type": "string" - }, - "amount": { - "type": "string" - }, - "nonce": { - "type": "number" - }, - "fee": { - "type": "string" - } - }, - "required": [ - "tokenAddress", - "spender", - "amount", - "nonce", - "fee" - ] - } - }, - "required": [ - "signature", - "status" - ] + "$ref": "#/components/schemas/ApproveResponse" + } + } + } + }, + "400": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" } } } diff --git a/models/gateway_generated.py b/models/gateway_generated.py index 7c1da5a7..ee3dda69 100644 --- a/models/gateway_generated.py +++ b/models/gateway_generated.py @@ -28,7 +28,7 @@ class AmmAddLiquidityResponseData(BaseModel): quote_token_amount_added: Decimal = Field(..., alias='quoteTokenAmountAdded') -class QuoteLiquidityResponse(BaseModel): +class AmmQuoteLiquidityResponse(BaseModel): pool_address: str | None = Field(None, alias='poolAddress', description='Pool the quote was computed against') base_limited: bool = Field(..., alias='baseLimited') base_token_amount: Decimal = Field(..., alias='baseTokenAmount') @@ -46,7 +46,7 @@ class AmmRemoveLiquidityResponseData(BaseModel): quote_token_amount_removed: Decimal = Field(..., alias='quoteTokenAmountRemoved') -class CreatePoolResponseData(BaseModel): +class AmmCreatePoolResponseData(BaseModel): fee: Decimal base_token_amount_added: Decimal = Field(..., alias='baseTokenAmountAdded') quote_token_amount_added: Decimal = Field(..., alias='quoteTokenAmountAdded') @@ -209,7 +209,7 @@ class PoolListItem(BaseModel): fees24h: Decimal | None = Field(None, description='24-hour fees collected') -class FetchPoolsResponse(BaseModel): +class ClmmFetchPoolsResponse(BaseModel): pools: list[PoolListItem] total: float = Field(..., description='Total number of matching pools') page: float = Field(..., description='Current page number') @@ -223,7 +223,7 @@ class BinLiquidity(BaseModel): quote_token_amount: Decimal = Field(..., alias='quoteTokenAmount') -class PoolInfo(BaseModel): +class ClmmPoolInfo(BaseModel): address: str base_token_address: str = Field(..., alias='baseTokenAddress') quote_token_address: str = Field(..., alias='quoteTokenAddress') @@ -236,7 +236,7 @@ class PoolInfo(BaseModel): bins: list[BinLiquidity] | None = None -class PositionInfo(BaseModel): +class ClmmPositionInfo(BaseModel): address: str pool_address: str = Field(..., alias='poolAddress') base_token_address: str = Field(..., alias='baseTokenAddress') @@ -252,7 +252,7 @@ class PositionInfo(BaseModel): price: Decimal -class OpenPositionResponseData(BaseModel): +class ClmmOpenPositionResponseData(BaseModel): fee: Decimal pool_address: str | None = Field(None, alias='poolAddress', description='Pool this operation acted on') position_address: str = Field(..., alias='positionAddress') @@ -261,7 +261,7 @@ class OpenPositionResponseData(BaseModel): quote_token_amount_added: Decimal = Field(..., alias='quoteTokenAmountAdded') -class AddLiquidityResponseData(BaseModel): +class ClmmAddLiquidityResponseData(BaseModel): fee: Decimal pool_address: str | None = Field(None, alias='poolAddress', description='Pool this operation acted on') position_address: str | None = Field(None, alias='positionAddress', description='Position this operation acted on') @@ -269,7 +269,7 @@ class AddLiquidityResponseData(BaseModel): quote_token_amount_added: Decimal = Field(..., alias='quoteTokenAmountAdded') -class RemoveLiquidityResponseData(BaseModel): +class ClmmRemoveLiquidityResponseData(BaseModel): fee: Decimal pool_address: str | None = Field(None, alias='poolAddress', description='Pool this operation acted on') position_address: str | None = Field(None, alias='positionAddress', description='Position this operation acted on') @@ -277,7 +277,7 @@ class RemoveLiquidityResponseData(BaseModel): quote_token_amount_removed: Decimal = Field(..., alias='quoteTokenAmountRemoved') -class CollectFeesResponseData(BaseModel): +class ClmmCollectFeesResponseData(BaseModel): fee: Decimal pool_address: str | None = Field(None, alias='poolAddress', description='Pool this operation acted on') position_address: str | None = Field(None, alias='positionAddress', description='Position this operation acted on') @@ -285,7 +285,7 @@ class CollectFeesResponseData(BaseModel): quote_fee_amount_collected: Decimal = Field(..., alias='quoteFeeAmountCollected') -class ClosePositionResponseData(BaseModel): +class ClmmClosePositionResponseData(BaseModel): fee: Decimal pool_address: str | None = Field(None, alias='poolAddress', description='Pool this operation acted on') position_address: str | None = Field(None, alias='positionAddress', description='Position this operation acted on') @@ -300,7 +300,7 @@ class ClmmCreatePoolResponseData(BaseModel): fee: Decimal -class QuotePositionResponse(BaseModel): +class ClmmQuoteLiquidityResponse(BaseModel): pool_address: str | None = Field(None, alias='poolAddress', description='Pool the quote was computed against') base_limited: bool = Field(..., alias='baseLimited') base_token_amount: Decimal = Field(..., alias='baseTokenAmount') @@ -559,6 +559,11 @@ class AllowancesRequest(BaseModel): tokens: list[str] = Field(..., description='Array of token symbols or addresses', examples=[['USDC', 'WETH']]) +class AllowancesResponse(BaseModel): + spender: str + approvals: dict[str, str] + + class ApproveRequest(BaseModel): network: str | None = Field('mainnet', description='The Ethereum network to use') address: str | None = Field('', description='Ethereum wallet address') @@ -567,6 +572,14 @@ class ApproveRequest(BaseModel): amount: str | None = Field('', description='The amount to approve. If not provided, defaults to maximum amount (unlimited approval).') +class ApproveResponseData(BaseModel): + token_address: str = Field(..., alias='tokenAddress') + spender: str + amount: str + nonce: float + fee: str + + class RemoveWalletRequest(BaseModel): chain: str = Field(..., description='Blockchain to remove wallet from', examples=['solana']) address: str = Field(..., description='Wallet address to remove') @@ -586,6 +599,13 @@ class Token(BaseModel): decimals: confloat(ge=0.0, le=255.0) = Field(..., description='The number of decimals the token uses', examples=[6]) +class ErrorResponse(BaseModel): + status_code: int = Field(..., alias='statusCode', description='HTTP status code', examples=[400]) + error: str = Field(..., description='HTTP status name', examples=['Bad Request']) + message: str = Field(..., description='What went wrong, in terms of the request that caused it', examples=["Connector 'meteora' runs on solana, not ethereum"]) + code: str | None = Field(None, description='Machine-readable cause, present when Gateway can name one. This is what a caller branches on: TRANSACTION_TIMEOUT and RATE_LIMITED are retryable, the rest are not.') + + class AmmAddLiquidityResponse(BaseModel): signature: str status: float = Field(..., description='TransactionStatus enum value') @@ -598,12 +618,12 @@ class AmmRemoveLiquidityResponse(BaseModel): data: AmmRemoveLiquidityResponseData | None = None -class CreatePoolResponse(BaseModel): +class AmmCreatePoolResponse(BaseModel): signature: str status: float = Field(..., description='TransactionStatus enum value') pool_address: str = Field(..., alias='poolAddress', description='Address of the newly created pool') price: Decimal | None = Field(None, description='Initial price the pool was seeded at (quote per base)') - data: CreatePoolResponseData | None = None + data: AmmCreatePoolResponseData | None = None class ChainExecuteSwapResponse(BaseModel): @@ -618,34 +638,34 @@ class ChainWrapResponse(BaseModel): data: ChainWrapResponseData | None = None -class OpenPositionResponse(BaseModel): +class ClmmOpenPositionResponse(BaseModel): signature: str status: float = Field(..., description='TransactionStatus enum value') - data: OpenPositionResponseData | None = None + data: ClmmOpenPositionResponseData | None = None -class AddLiquidityResponse(BaseModel): +class ClmmAddLiquidityResponse(BaseModel): signature: str status: float = Field(..., description='TransactionStatus enum value') - data: AddLiquidityResponseData | None = None + data: ClmmAddLiquidityResponseData | None = None -class RemoveLiquidityResponse(BaseModel): +class ClmmRemoveLiquidityResponse(BaseModel): signature: str status: float = Field(..., description='TransactionStatus enum value') - data: RemoveLiquidityResponseData | None = None + data: ClmmRemoveLiquidityResponseData | None = None -class CollectFeesResponse(BaseModel): +class ClmmCollectFeesResponse(BaseModel): signature: str status: float = Field(..., description='TransactionStatus enum value') - data: CollectFeesResponseData | None = None + data: ClmmCollectFeesResponseData | None = None -class ClosePositionResponse(BaseModel): +class ClmmClosePositionResponse(BaseModel): signature: str status: float = Field(..., description='TransactionStatus enum value') - data: ClosePositionResponseData | None = None + data: ClmmClosePositionResponseData | None = None class ClmmCreatePoolResponse(BaseModel): @@ -654,3 +674,9 @@ class ClmmCreatePoolResponse(BaseModel): pool_address: str = Field(..., alias='poolAddress', description='Address of the newly created pool') price: Decimal | None = Field(None, description='Initial price the pool was initialized at (quote per base)') data: ClmmCreatePoolResponseData | None = None + + +class ApproveResponse(BaseModel): + signature: str + status: float = Field(..., description='TransactionStatus enum value') + data: ApproveResponseData | None = None diff --git a/test/test_gateway_models_match_spec.py b/test/test_gateway_models_match_spec.py index 6b259250..d72710db 100644 --- a/test/test_gateway_models_match_spec.py +++ b/test/test_gateway_models_match_spec.py @@ -44,14 +44,18 @@ # and write-transaction responses are shared across the AMM and CLMM surfaces, and each # of those schemas must independently satisfy it. PASSTHROUGH_MODELS = [ - ("CLMMPoolInfoResponse", "PoolInfo"), + # Gateway prefixed its response components so an AMM shape and its CLMM twin no + # longer share a name: PoolInfo became ClmmPoolInfo against AmmPoolInfo, and + # QuotePositionResponse became ClmmQuoteLiquidityResponse — the route had been + # renamed to quote-liquidity and the response had kept the older word. + ("CLMMPoolInfoResponse", "ClmmPoolInfo"), ("CLMMPoolBin", "BinLiquidity"), - ("CLMMQuotePositionResponse", "QuotePositionResponse"), + ("CLMMQuotePositionResponse", "ClmmQuoteLiquidityResponse"), ("AMMPoolInfoResponse", "AmmPoolInfo"), ("AMMPositionInfoResponse", "AmmPositionInfo"), ("AMMPositionDetail", "PositionDetail"), - ("AMMQuoteLiquidityResponse", "QuoteLiquidityResponse"), - ("AMMCreatePoolResponse", "CreatePoolResponse"), + ("AMMQuoteLiquidityResponse", "AmmQuoteLiquidityResponse"), + ("AMMCreatePoolResponse", "AmmCreatePoolResponse"), ("AMMCreatePoolResponse", "ClmmCreatePoolResponse"), # Gateway dropped /trading/amm/{open,close}: open was a synonym for add without a # position address, and close is now what remove does at 100%, which is why the From a518bc41cf6e38752ed55256f758852838a5e051 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Thu, 20 Aug 2026 10:18:32 -0700 Subject: [PATCH 31/54] chore(gateway): call /pools and /tokens with chainNetwork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gateway collapsed three addressing conventions into one: everything outside /chains/{chain}/ takes a single chainNetwork, so the six calls that sent chain and network separately now send the pair joined. /chains/{chain}/balances and /chains/{chain}/poll are unchanged, since the chain is in their path, and the wallet calls keep `chain` alone — a keypair works on every network of its chain. Also refreshes the vendored spec and models. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK --- gateway-openapi.json | 363 ++++++++++++++++--------------------- services/gateway_client.py | 18 +- 2 files changed, 163 insertions(+), 218 deletions(-) diff --git a/gateway-openapi.json b/gateway-openapi.json index 85dd5ffc..97a65476 100644 --- a/gateway-openapi.json +++ b/gateway-openapi.json @@ -4558,40 +4558,29 @@ "parameters": [ { "schema": { + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "type": "string" }, - "examples": { - "ethereum": { - "value": "ethereum" - }, - "solana": { - "value": "solana" - } - }, - "in": "query", - "name": "chain", - "required": true, - "description": "Blockchain network (e.g., ethereum, solana)" - }, - { - "schema": { - "type": "string" - }, - "examples": { - "mainnet": { - "value": "mainnet" - }, - "mainnet-beta": { - "value": "mainnet-beta" - }, - "devnet": { - "value": "devnet" - } - }, + "example": "solana-mainnet-beta", "in": "query", - "name": "network", + "name": "chainNetwork", "required": true, - "description": "Network name (e.g., mainnet, mainnet-beta)" + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" }, { "schema": { @@ -4614,17 +4603,13 @@ "token": { "$ref": "#/components/schemas/Token" }, - "chain": { - "type": "string" - }, - "network": { + "chainNetwork": { "type": "string" } }, "required": [ "token", - "chain", - "network" + "chainNetwork" ] } } @@ -4746,40 +4731,29 @@ "parameters": [ { "schema": { + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "type": "string" }, - "examples": { - "ethereum": { - "value": "ethereum" - }, - "solana": { - "value": "solana" - } - }, - "in": "query", - "name": "chain", - "required": false, - "description": "Blockchain network (e.g., ethereum, solana)" - }, - { - "schema": { - "type": "string" - }, - "examples": { - "mainnet": { - "value": "mainnet" - }, - "mainnet-beta": { - "value": "mainnet-beta" - }, - "devnet": { - "value": "devnet" - } - }, + "example": "solana-mainnet-beta", "in": "query", - "name": "network", - "required": false, - "description": "Network name (e.g., mainnet, mainnet-beta)" + "name": "chainNetwork", + "required": true, + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" }, { "schema": { @@ -4855,23 +4829,33 @@ "schema": { "type": "object", "properties": { - "chain": { - "description": "Blockchain network (e.g., ethereum, solana)", - "type": "string", - "example": "ethereum" - }, - "network": { - "description": "Network name (e.g., mainnet, mainnet-beta)", + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "type": "string", - "example": "mainnet" + "example": "solana-mainnet-beta" }, "token": { "$ref": "#/components/schemas/Token" } }, "required": [ - "chain", - "network", + "chainNetwork", "token" ] } @@ -5027,40 +5011,29 @@ "parameters": [ { "schema": { + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "type": "string" }, - "examples": { - "ethereum": { - "value": "ethereum" - }, - "solana": { - "value": "solana" - } - }, - "in": "query", - "name": "chain", - "required": true, - "description": "Blockchain network (e.g., ethereum, solana)" - }, - { - "schema": { - "type": "string" - }, - "examples": { - "mainnet": { - "value": "mainnet" - }, - "mainnet-beta": { - "value": "mainnet-beta" - }, - "devnet": { - "value": "devnet" - } - }, + "example": "solana-mainnet-beta", "in": "query", - "name": "network", + "name": "chainNetwork", "required": true, - "description": "Network name (e.g., mainnet, mainnet-beta)" + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" }, { "schema": { @@ -5125,38 +5098,29 @@ "parameters": [ { "schema": { + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "type": "string" }, - "examples": { - "solana": { - "value": "solana" - }, - "ethereum": { - "value": "ethereum" - } - }, - "in": "query", - "name": "chain", - "required": true, - "description": "Blockchain chain (solana, ethereum)" - }, - { - "schema": { - "default": "mainnet-beta", - "type": "string" - }, - "examples": { - "mainnet-beta": { - "value": "mainnet-beta" - }, - "mainnet": { - "value": "mainnet" - } - }, + "example": "solana-mainnet-beta", "in": "query", - "name": "network", + "name": "chainNetwork", "required": true, - "description": "Network name (mainnet, mainnet-beta, etc)" + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" }, { "schema": { @@ -5666,43 +5630,29 @@ "parameters": [ { "schema": { + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "type": "string" }, - "examples": { - "solana": { - "value": "solana" - }, - "ethereum": { - "value": "ethereum" - } - }, - "in": "query", - "name": "chain", - "required": true, - "description": "Blockchain chain (solana, ethereum)" - }, - { - "schema": { - "type": "string" - }, - "examples": { - "mainnet-beta": { - "value": "mainnet-beta" - }, - "mainnet": { - "value": "mainnet" - }, - "base": { - "value": "base" - }, - "arbitrum": { - "value": "arbitrum" - } - }, + "example": "solana-mainnet-beta", "in": "query", - "name": "network", + "name": "chainNetwork", "required": true, - "description": "Network name (mainnet-beta, mainnet, base, etc)" + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" }, { "schema": { @@ -5855,10 +5805,26 @@ "schema": { "type": "object", "properties": { - "chain": { - "description": "Blockchain chain (solana, ethereum)", + "chainNetwork": { + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "type": "string", - "example": "solana" + "example": "solana-mainnet-beta" }, "connector": { "description": "Connector (raydium, meteora, uniswap, orca)", @@ -5874,12 +5840,6 @@ "type": "string", "example": "clmm" }, - "network": { - "description": "Network name (mainnet, mainnet-beta, etc)", - "default": "mainnet-beta", - "type": "string", - "example": "mainnet-beta" - }, "address": { "description": "Pool contract address", "type": "string" @@ -5914,10 +5874,9 @@ } }, "required": [ - "chain", + "chainNetwork", "connector", "type", - "network", "address", "baseTokenAddress", "quoteTokenAddress" @@ -6129,37 +6088,29 @@ "parameters": [ { "schema": { + "enum": [ + "ethereum-arbitrum", + "ethereum-avalanche", + "ethereum-base", + "ethereum-bsc", + "ethereum-celo", + "ethereum-mainnet", + "ethereum-optimism", + "ethereum-polygon", + "ethereum-robinhoodchain", + "ethereum-robinhoodchain-testnet", + "ethereum-sepolia", + "ethereum-unichain", + "solana-devnet", + "solana-mainnet-beta" + ], "type": "string" }, - "examples": { - "solana": { - "value": "solana" - }, - "ethereum": { - "value": "ethereum" - } - }, - "in": "query", - "name": "chain", - "required": true, - "description": "Blockchain chain (solana, ethereum)" - }, - { - "schema": { - "type": "string" - }, - "examples": { - "mainnet": { - "value": "mainnet" - }, - "mainnet-beta": { - "value": "mainnet-beta" - } - }, + "example": "solana-mainnet-beta", "in": "query", - "name": "network", + "name": "chainNetwork", "required": true, - "description": "Network name (mainnet, mainnet-beta, etc)" + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" }, { "schema": { diff --git a/services/gateway_client.py b/services/gateway_client.py index b0fc9801..ddc0c1c7 100644 --- a/services/gateway_client.py +++ b/services/gateway_client.py @@ -416,8 +416,7 @@ async def get_default_network(self, chain: str) -> Optional[str]: async def get_tokens(self, chain: str, network: str) -> Dict: """Get available tokens for a chain/network""" return await self._request("GET", "tokens", params={ - "chain": chain, - "network": network + "chainNetwork": f"{chain}-{network}" }) async def _get_token_symbols(self, chain: str, network: str) -> Dict[str, str]: @@ -448,8 +447,7 @@ async def resolve_token_symbol(self, chain: str, network: str, address: str) -> async def add_token(self, chain: str, network: str, address: str, symbol: str, name: str, decimals: int) -> Dict: """Add a custom token to Gateway's token list""" return await self._request("POST", "tokens", json={ - "chain": chain, - "network": network, + "chainNetwork": f"{chain}-{network}", "token": { "address": address, "symbol": symbol, @@ -461,8 +459,7 @@ async def add_token(self, chain: str, network: str, address: str, symbol: str, n async def delete_token(self, chain: str, network: str, token_address: str) -> Dict: """Delete a custom token from Gateway's token list""" return await self._request("DELETE", f"tokens/{token_address}", params={ - "chain": chain, - "network": network + "chainNetwork": f"{chain}-{network}" }) async def save_token(self, chain: str, network: str, token_address: str) -> Dict: @@ -519,8 +516,7 @@ async def get_pools( ) -> List[Dict]: """Get pools for a chain and network with optional filtering""" params = { - "chain": chain, - "network": network + "chainNetwork": f"{chain}-{network}" } if connector: params["connector"] = connector @@ -545,10 +541,9 @@ async def add_pool( ) -> Dict: """Add a new pool""" payload = { - "chain": chain, + "chainNetwork": f"{chain}-{network}", "connector": connector, "type": pool_type.lower(), # Gateway expects lowercase (amm, clmm) - "network": network, "address": address, "baseSymbol": base_symbol, "quoteSymbol": quote_symbol, @@ -568,8 +563,7 @@ async def save_pool(self, chain_network: str, address: str) -> Dict: async def delete_pool(self, chain: str, network: str, address: str) -> Dict: """Delete a pool from Gateway's pool list""" return await self._request("DELETE", f"pools/{address}", params={ - "chain": chain, - "network": network + "chainNetwork": f"{chain}-{network}" }) # ============================================ From a7187746ba910fa21ac90c2b552457dc3dbde894 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Thu, 20 Aug 2026 12:03:59 -0700 Subject: [PATCH 32/54] fix(swap): return what the swap did, not what was asked for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SwapExecuteResponse.amount` is the request echoed back, and its description said "Amount swapped". On a live BUY of 1000 DOGE-1 that delivered 951.682904159 it answered 1000. This is not specific to thin tokens or to BUYs — any swap fills at something other than the requested amount whenever slippage moves it, and this field reported the request every time, so an executor reconciling its position against it was reconciling against its own intent. None of it was unavailable: the same call computes input_amount, output_amount and price and writes them to the swap history two functions away. Learning what a swap did meant executing it, discarding the answer, and searching the history by transaction hash. The three fields are optional and stay None until the transaction confirms — a submitted swap has only placeholders, and publishing those would restate the request as the result, which is the defect they exist to end. `amount` now says it is the request. Spec and generated models refreshed for the gateway change to /trading/clmm/remove's slippagePct, which uniswap and pancakeswap now honour. GW-35. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK --- gateway-openapi.json | 2 +- models/gateway_generated.py | 2 +- models/gateway_trading.py | 30 ++++++++- routers/gateway_swap.py | 11 +++- test/test_swap_execute_reports_the_fill.py | 75 ++++++++++++++++++++++ 5 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 test/test_swap_execute_reports_the_fill.py diff --git a/gateway-openapi.json b/gateway-openapi.json index 97a65476..cf4cc231 100644 --- a/gateway-openapi.json +++ b/gateway-openapi.json @@ -2290,7 +2290,7 @@ "format": "decimal", "minimum": 0, "maximum": 100, - "description": "Maximum acceptable slippage percentage. Only applies to the Orca connector; defaults to Orca's configured slippagePct.", + "description": "Maximum acceptable slippage percentage. Honored by orca, uniswap and pancakeswap; the other connectors remove at their configured slippagePct. Defaults to the connector's configured slippagePct.", "type": "number", "example": 1 } diff --git a/models/gateway_generated.py b/models/gateway_generated.py index ee3dda69..e51e3ace 100644 --- a/models/gateway_generated.py +++ b/models/gateway_generated.py @@ -403,7 +403,7 @@ class ClmmRemoveRequest(BaseModel): wallet_address: str = Field(..., alias='walletAddress', description='Wallet address') position_address: str = Field(..., alias='positionAddress', description='Position address', examples=['']) percentage_to_remove: condecimal(ge=Decimal('0'), le=Decimal('100')) = Field(..., alias='percentageToRemove', description='Percentage of liquidity to remove', examples=[100]) - slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct', description="Maximum acceptable slippage percentage. Only applies to the Orca connector; defaults to Orca's configured slippagePct.", examples=[1]) + slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct', description="Maximum acceptable slippage percentage. Honored by orca, uniswap and pancakeswap; the other connectors remove at their configured slippagePct. Defaults to the connector's configured slippagePct.", examples=[1]) class ClmmCollectFeesRequest(BaseModel): diff --git a/models/gateway_trading.py b/models/gateway_trading.py index 7670dbb1..0b8a8db7 100644 --- a/models/gateway_trading.py +++ b/models/gateway_trading.py @@ -79,11 +79,37 @@ class SwapExecuteRequest(BaseModel): class SwapExecuteResponse(BaseModel): - """Response after executing swap""" + """Response after executing swap. + + `amount` is what was asked for; the three fill fields are what happened. They were + missing entirely, so a caller reconciling a position against this response was + reconciling against its own intent: a BUY of 1000 tokens that delivered 951.68 + answered `amount: 1000` under the description "Amount swapped". Every one of these + numbers was already in hand — the same call writes them to the swap history — so the + only way to learn what a swap did was to execute it, discard the answer, and search + the history by transaction hash. + """ transaction_hash: str = Field(description="Transaction hash") trading_pair: str = Field(description="Trading pair") side: str = Field(description="Trade side") - amount: Decimal = Field(description="Amount swapped") + amount: Decimal = Field( + description="Amount REQUESTED, denominated in the base token (SELL: base sold; BUY: base " + "wanted). This is the request echoed back, not the fill — see input_amount / " + "output_amount for what actually moved.") + # None until the transaction confirms: a submitted swap has no fill yet, and echoing + # the request into these would reintroduce the defect they exist to fix. + input_amount: Optional[Decimal] = Field( + default=None, + description="Amount actually spent, denominated in the input token (quote for BUY, base " + "for SELL). None until the transaction confirms.") + output_amount: Optional[Decimal] = Field( + default=None, + description="Amount actually received, denominated in the output token (base for BUY, " + "quote for SELL). None until the transaction confirms.") + price: Optional[Decimal] = Field( + default=None, + description="Executed price in quote per base, computed from the amounts that moved. " + "None until the transaction confirms.") status: str = Field(default="submitted", description="Transaction status") diff --git a/routers/gateway_swap.py b/routers/gateway_swap.py index 8e4ceab5..752d96ce 100644 --- a/routers/gateway_swap.py +++ b/routers/gateway_swap.py @@ -172,7 +172,11 @@ async def execute_swap( amount_out_raw = data.get("amountOut") side = request.side.upper() - if amount_in_raw is not None and amount_out_raw is not None: + # Whether the fill is known. A submitted-not-confirmed swap has placeholders + # below, and publishing those as the fill would restate the request as the + # result — the defect these fields were added to end. + fill_known = amount_in_raw is not None and amount_out_raw is not None + if fill_known: input_amount = Decimal(str(amount_in_raw)) output_amount = Decimal(str(amount_out_raw)) # Price in quote-per-base for both sides: SELL flows base->quote (out/in), @@ -249,6 +253,11 @@ async def execute_swap( trading_pair=request.trading_pair, side=side, amount=request.amount, + # What actually moved, alongside what was asked for. Same values the swap + # history row above records, so a caller no longer has to go and read it. + input_amount=input_amount if fill_known else None, + output_amount=output_amount if fill_known else None, + price=price if fill_known else None, # "confirmed" / "submitted" / "failed" — a failed EVM swap comes back as # status -1 with zeroed amounts, which must not read as in-flight. status=tx_status diff --git a/test/test_swap_execute_reports_the_fill.py b/test/test_swap_execute_reports_the_fill.py new file mode 100644 index 00000000..c2d0fc68 --- /dev/null +++ b/test/test_swap_execute_reports_the_fill.py @@ -0,0 +1,75 @@ +"""A swap execute response has to say what the swap did, not what was asked for. + +`SwapExecuteResponse.amount` is the request echoed back, and its description said +"Amount swapped". On a live BUY of 1000 DOGE-1 that delivered 951.682904159 it answered +1000. The fill was not unavailable — the same call writes input_amount, output_amount and +price to the swap history two functions away — so the only way to learn what a swap did +was to execute it, discard the answer, and search the history by transaction hash. + +An executor that trusts that field to reconcile its position is reconciling against its +own intent. +""" +from decimal import Decimal + +import pytest + +from models import SwapExecuteResponse + + +def test_the_fill_fields_exist_and_are_optional(): + # Optional because a submitted-not-confirmed swap has no fill yet. The alternative — + # echoing the request into them — is the defect they exist to end. + for field in ("input_amount", "output_amount", "price"): + assert field in SwapExecuteResponse.model_fields, f"{field} missing from SwapExecuteResponse" + assert SwapExecuteResponse.model_fields[field].default is None + + +def test_amount_is_documented_as_the_request_not_the_fill(): + described = SwapExecuteResponse.model_fields["amount"].description + assert "REQUESTED" in described + assert "not the fill" in described + + +def test_a_confirmed_swap_carries_what_moved(): + # The live BUY: 0.001491559 SOL paid for 951.682904159 DOGE-1, against a request + # for 1000. + response = SwapExecuteResponse( + transaction_hash="fTCcM4qr", + trading_pair="DOGE-1-SOL", + side="BUY", + amount=Decimal("1000"), + input_amount=Decimal("0.001491559"), + output_amount=Decimal("951.682904159"), + price=Decimal("1.567285693041e-06"), + status="confirmed", + ) + + assert response.amount == Decimal("1000") + assert response.output_amount == Decimal("951.682904159") + # The gap between the two is the whole point: the order was silently resized, and + # nothing in the old response said so. + assert response.output_amount != response.amount + + +def test_a_submitted_swap_reports_no_fill_rather_than_a_guess(): + response = SwapExecuteResponse( + transaction_hash="pending", + trading_pair="SOL-USDC", + side="SELL", + amount=Decimal("0.01"), + status="submitted", + ) + + assert response.input_amount is None + assert response.output_amount is None + assert response.price is None + + +@pytest.mark.parametrize("field", ["input_amount", "output_amount", "price"]) +def test_the_router_populates_each_field_only_when_the_fill_is_known(field): + # The handler guards all three on `fill_known`, set from Gateway's confirmed `data` + # block. Pinning it here keeps the two halves — model and handler — from drifting. + source = open("routers/gateway_swap.py").read() + value = "price" if field == "price" else field + + assert f"{field}={value} if fill_known else None" in source From 63f924677b140c635f8a71b9ece363118efdb3c2 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Thu, 20 Aug 2026 12:31:38 -0700 Subject: [PATCH 33/54] feat(clmm): pass a close's slippage through to Gateway MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/gateway/clmm/close` had no way to say what slippage the withdrawal would accept, so the LP executor's ramp had nothing to send. The field is optional and omitting it keeps the previous behaviour exactly — the connector's configured value. Enforced by orca, uniswap and pancakeswap; the other CLMM connectors close with no minimum-amount check, and the description says so rather than implying otherwise. Spec and generated models refreshed for the same Gateway change. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK --- gateway-openapi.json | 8 ++++++++ models/gateway_generated.py | 1 + models/gateway_trading.py | 8 ++++++++ routers/gateway_clmm.py | 3 ++- services/gateway_client.py | 11 +++++++++-- 5 files changed, 28 insertions(+), 3 deletions(-) diff --git a/gateway-openapi.json b/gateway-openapi.json index cf4cc231..f81b112e 100644 --- a/gateway-openapi.json +++ b/gateway-openapi.json @@ -2408,6 +2408,14 @@ "description": "Position address", "type": "string", "example": "" + }, + "slippagePct": { + "format": "decimal", + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage for the withdrawal. Enforced by orca, uniswap and pancakeswap; meteora, raydium and pancakeswap-sol close with no minimum-amount check at all, so it changes nothing there. Defaults to the connector's configured slippagePct.", + "type": "number", + "example": 1 } }, "required": [ diff --git a/models/gateway_generated.py b/models/gateway_generated.py index e51e3ace..743c2218 100644 --- a/models/gateway_generated.py +++ b/models/gateway_generated.py @@ -418,6 +418,7 @@ class ClmmCloseRequest(BaseModel): chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) wallet_address: str = Field(..., alias='walletAddress', description='Wallet address') position_address: str = Field(..., alias='positionAddress', description='Position address', examples=['']) + slippage_pct: condecimal(ge=Decimal('0'), le=Decimal('100')) | None = Field(None, alias='slippagePct', description="Maximum acceptable slippage percentage for the withdrawal. Enforced by orca, uniswap and pancakeswap; meteora, raydium and pancakeswap-sol close with no minimum-amount check at all, so it changes nothing there. Defaults to the connector's configured slippagePct.", examples=[1]) class ClmmCreatePoolRequest(BaseModel): diff --git a/models/gateway_trading.py b/models/gateway_trading.py index 0b8a8db7..74295466 100644 --- a/models/gateway_trading.py +++ b/models/gateway_trading.py @@ -196,6 +196,14 @@ class CLMMClosePositionRequest(BaseModel): connector: str = Field(description="CLMM connector (e.g., 'meteora', 'raydium', 'uniswap')") network: str = Field(description="Network ID in 'chain-network' format (e.g., 'solana-mainnet-beta')") position_address: str = Field(description="Position address to close") + slippage_pct: Optional[Decimal] = Field( + default=None, + description="Maximum acceptable slippage percentage for the withdrawal. Enforced by orca, " + "uniswap and pancakeswap; meteora, raydium and pancakeswap-sol close with no " + "minimum-amount check at all, so it changes nothing there. Omit to use the " + "connector's configured slippagePct. An executor widening this across retries " + "is what it exists for: a narrow in-range close can fail on slippage at the " + "configured value with no way to say \"accept more to get out\".") pool_address: Optional[str] = Field( default=None, description="Pool the position belongs to. Informational only — neither Gateway's call " diff --git a/routers/gateway_clmm.py b/routers/gateway_clmm.py index 38e6769a..131ad943 100644 --- a/routers/gateway_clmm.py +++ b/routers/gateway_clmm.py @@ -905,7 +905,8 @@ async def close_clmm_position( connector=request.connector, chain_network=request.network, wallet_address=wallet_address, - position_address=request.position_address + position_address=request.position_address, + slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else None, )) transaction_hash = result.get("signature") or result.get("txHash") or result.get("hash") diff --git a/services/gateway_client.py b/services/gateway_client.py index ddc0c1c7..06275d86 100644 --- a/services/gateway_client.py +++ b/services/gateway_client.py @@ -795,15 +795,22 @@ async def clmm_close_position( connector: str, chain_network: str, wallet_address: str, - position_address: str + position_address: str, + slippage_pct: Optional[float] = None, ) -> Dict: - """Close a CLMM position completely""" + """Close a CLMM position completely. + + `slippage_pct` is the withdrawal's tolerance; None uses the connector's + configured slippagePct. Enforced by orca, uniswap and pancakeswap — the other + CLMM connectors close with no minimum-amount check, so it changes nothing there. + """ return await self._request("POST", "trading/clmm/close", json=_body( ClmmCloseRequest( connector=connector, chainNetwork=chain_network, walletAddress=wallet_address, positionAddress=position_address, + slippagePct=slippage_pct, ) )) From 16322a0af4c06e2976ec0755f1c7373161f65caf Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Thu, 20 Aug 2026 14:56:35 -0700 Subject: [PATCH 34/54] =?UTF-8?q?feat(swap):=20expose=20the=20two-step=20f?= =?UTF-8?q?low=20=E2=80=94=20quote,=20decide,=20execute=20that=20quote?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gateway has had /trading/router/execute-quote all along and nothing downstream exposed it: not this API, not the client, not condor. So every swap on record went through the one-step execute, which re-prices at execution and discards the quote the caller saw — which is the entire value of a held quote on dflow, titan and 0x, and the case the route was built for. `quote_id` now reaches the caller on /swap/quote (routers return one; pool-scoped connectors do not, because they price against the pool at execution), and /swap/execute-quote commits to it. A pool-scoped connector is rejected with a 400 rather than quietly re-priced, which would hand back a swap at a price nobody was shown. Both execute paths book through one function. They differ in how the transaction was produced and not at all in what has to be recorded afterwards, and two copies of that accounting would drift. GW-27. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK --- models/__init__.py | 2 + models/gateway_trading.py | 24 +++ routers/gateway_swap.py | 317 ++++++++++++++++++++++---------- services/gateway_client.py | 30 +++ test/test_execute_quote_flow.py | 94 ++++++++++ 5 files changed, 366 insertions(+), 101 deletions(-) create mode 100644 test/test_execute_quote_flow.py diff --git a/models/__init__.py b/models/__init__.py index e7ef8444..da3dae61 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -119,6 +119,7 @@ CLMMQuotePositionRequest, CLMMQuotePositionResponse, CLMMRemoveLiquidityRequest, + SwapExecuteQuoteRequest, SwapExecuteRequest, SwapExecuteResponse, SwapQuoteRequest, @@ -326,6 +327,7 @@ "AMMCreatePoolRequest", "AMMCreatePoolResponse", "AMMPositionsOwnedRequest", + "SwapExecuteQuoteRequest", "SwapExecuteRequest", "SwapExecuteResponse", "CLMMOpenPositionRequest", diff --git a/models/gateway_trading.py b/models/gateway_trading.py index 74295466..2d48ca32 100644 --- a/models/gateway_trading.py +++ b/models/gateway_trading.py @@ -59,6 +59,30 @@ class SwapQuoteResponse(BaseModel): slippage_pct: Optional[Decimal] = Field( default=None, description="Slippage percentage Gateway applied to the quote (the request value when Gateway omits it)") + quote_id: Optional[str] = Field( + default=None, + description="Identifier for this quote, on the router connectors that hold a price. Pass it " + "to /swap/execute-quote to execute THIS quote instead of re-pricing. Absent on " + "pool-scoped connectors, which price against the pool at execution time.") + + +class SwapExecuteQuoteRequest(BaseModel): + """Request to execute a quote the caller already has. + + The two-step flow — quote, decide, then commit to that quote — is the reason dflow, + titan and 0x return a held price at all. Routing them through /swap/execute instead + throws the quote away and prices again, which is what every swap on record did, + because until now nothing downstream exposed Gateway's execute-quote route. + """ + connector: str = Field(description="Router connector the quote came from (e.g., 'jupiter', '0x')") + network: str = Field(description="Network ID in 'chain-network' format (e.g., 'solana-mainnet-beta')") + quote_id: str = Field(description="quote_id from a prior /swap/quote on the same connector") + trading_pair: str = Field( + description="Trading pair the quote was for (e.g., 'SOL-USDC'). Gateway identifies the swap " + "by quote_id alone; this is what the recorded trade is filed under.") + side: str = Field(description="Trade side the quote was for: 'BUY' or 'SELL'") + amount: Decimal = Field(description="Base-token amount the quote was for, recorded as the request") + wallet_address: Optional[str] = Field(default=None, description="Wallet address (optional, uses default if not provided)") class SwapExecuteRequest(BaseModel): diff --git a/routers/gateway_swap.py b/routers/gateway_swap.py index 752d96ce..9eac1095 100644 --- a/routers/gateway_swap.py +++ b/routers/gateway_swap.py @@ -14,7 +14,7 @@ from database import AsyncDatabaseManager from database.repositories import GatewaySwapRepository from deps import get_accounts_service, get_database_manager -from models import SwapExecuteRequest, SwapExecuteResponse, SwapQuoteRequest, SwapQuoteResponse +from models import SwapExecuteQuoteRequest, SwapExecuteRequest, SwapExecuteResponse, SwapQuoteRequest, SwapQuoteResponse from routers.gateway_extras import ExtraParamsSpec, get_transaction_status_from_response, validate_extra_params from services.accounts_service import AccountsService from services.gateway_client import GatewayError, check_gateway_error, get_native_gas_token @@ -92,6 +92,9 @@ def _dec(key): price_impact_pct=_dec("priceImpactPct"), pool_address=result.get("poolAddress"), route_path=result.get("routePath"), + # The handle for /swap/execute-quote. Routers that hold a price return one; + # pool-scoped connectors do not, because they price at execution. + quote_id=result.get("quoteId"), slippage_pct=(_dec("slippagePct") if result.get("slippagePct") is not None else request.slippage_pct), ) @@ -107,6 +110,132 @@ def _dec(key): raise HTTPException(status_code=500, detail=f"Error getting swap quote: {str(e)}") +async def _record_and_report_swap( + *, + result: dict, + db_manager: AsyncDatabaseManager, + accounts_service: AccountsService, + connector: str, + network: str, + wallet_address: str, + trading_pair: str, + base: str, + quote: str, + side: str, + requested_amount: Decimal, + requested_slippage_pct: Optional[Decimal], +) -> SwapExecuteResponse: + """Book a settled swap and answer with what it did. + + Shared by /swap/execute and /swap/execute-quote: those two differ only in how the + transaction was produced — priced at execution, or committed from a quote the caller + already saw — and not at all in what has to be recorded afterwards. One copy is what + stops the two-step flow from growing its own subtly different accounting. + """ + transaction_hash = result.get("signature") or result.get("txHash") or result.get("hash") + if not transaction_hash: + raise HTTPException(status_code=500, detail="No transaction hash returned from Gateway") + + # Gateway's `data` (present only when it confirmed the tx) speaks token flow: + # amountIn is the tokenIn amount — quote for BUY, base for SELL — and the DB + # columns keep that tokenIn/tokenOut denomination. + data = result.get("data", {}) + amount_in_raw = data.get("amountIn") + amount_out_raw = data.get("amountOut") + + side = side.upper() + # Whether the fill is known. A submitted-not-confirmed swap has placeholders + # below, and publishing those as the fill would restate the request as the + # result — the defect these fields were added to end. + fill_known = amount_in_raw is not None and amount_out_raw is not None + if fill_known: + input_amount = Decimal(str(amount_in_raw)) + output_amount = Decimal(str(amount_out_raw)) + # Price in quote-per-base for both sides: SELL flows base->quote (out/in), + # BUY flows quote->base (in/out). + if side == "SELL": + price = output_amount / input_amount if input_amount > 0 else Decimal("0") + else: + price = input_amount / output_amount if output_amount > 0 else Decimal("0") + else: + # Submitted-not-confirmed: only the request leg of the flow is known — + # requested_amount is base-denominated (SELL: base in, BUY: base out). + # The unknown leg and price stay 0 placeholders. + input_amount = requested_amount if side == "SELL" else Decimal("0") + output_amount = requested_amount if side == "BUY" else Decimal("0") + price = Decimal("0") + + # Gateway reports the gas it actually paid in the same confirmed `data` block. + # Record it here rather than leaving the columns null for the poller to fill — + # the poller only revisits swaps that were still pending, so a swap confirmed + # on the execute call was never getting its gas recorded at all. + fee_raw = data.get("fee") + gas_fee = Decimal(str(fee_raw)) if fee_raw is not None else None + chain, _ = accounts_service.gateway_client.parse_network_id(network) + gas_token = get_native_gas_token(chain) if gas_fee is not None else None + + # Prefer the slippage Gateway reports it actually applied over the one the + # caller asked for: omitting slippage_pct means "use the connector's configured + # value", and recording the request's None there loses what was really enforced. + applied_slippage = data.get("slippagePct") + slippage_pct = ( + Decimal(str(applied_slippage)) if applied_slippage is not None + else requested_slippage_pct + ) + + # Get transaction status from Gateway response + tx_status = get_transaction_status_from_response(result) + + # Store swap in database + try: + async with db_manager.get_session_context() as session: + swap_repo = GatewaySwapRepository(session) + + swap_data = { + "transaction_hash": transaction_hash, + "network": network, + # Store the base venue name: a swap on "jupiter" and one on + # "jupiter/router" are the same venue and must file together. + "connector": connector.split("/")[0], + "wallet_address": wallet_address, + "trading_pair": trading_pair, + "base_token": base, + "quote_token": quote, + "side": side, + "input_amount": float(input_amount), + "output_amount": float(output_amount), + "price": float(price), + "slippage_pct": float(slippage_pct) if slippage_pct is not None else None, + "gas_fee": float(gas_fee) if gas_fee is not None else None, + "gas_token": gas_token, + "status": tx_status, + # Set by the pool-scoped routes, which resolve exactly one pool; a + # router picks its own path across pools and leaves it unset. + "pool_address": data.get("poolAddress") + } + + await swap_repo.create_swap(swap_data) + logger.info(f"Recorded swap in database: {transaction_hash} (status: {tx_status})") + except Exception as db_error: + # Log but don't fail the swap - it was submitted successfully + logger.error(f"Error recording swap in database: {db_error}", exc_info=True) + + return SwapExecuteResponse( + transaction_hash=transaction_hash, + trading_pair=trading_pair, + side=side, + amount=requested_amount, + # What actually moved, alongside what was asked for. Same values the swap + # history row above records, so a caller no longer has to go and read it. + input_amount=input_amount if fill_known else None, + output_amount=output_amount if fill_known else None, + price=price if fill_known else None, + # "confirmed" / "submitted" / "failed" — a failed EVM swap comes back as + # status -1 with zeroed amounts, which must not read as in-flight. + status=tx_status + ) + + @router.post("/swap/execute", response_model=SwapExecuteResponse) async def execute_swap( request: SwapExecuteRequest, @@ -160,107 +289,19 @@ async def execute_swap( slippage_pct=float(request.slippage_pct) if request.slippage_pct is not None else None, extra_params=request.extra_params )) - transaction_hash = result.get("signature") or result.get("txHash") or result.get("hash") - if not transaction_hash: - raise HTTPException(status_code=500, detail="No transaction hash returned from Gateway") - - # Gateway's `data` (present only when it confirmed the tx) speaks token flow: - # amountIn is the tokenIn amount — quote for BUY, base for SELL — and the DB - # columns keep that tokenIn/tokenOut denomination. - data = result.get("data", {}) - amount_in_raw = data.get("amountIn") - amount_out_raw = data.get("amountOut") - - side = request.side.upper() - # Whether the fill is known. A submitted-not-confirmed swap has placeholders - # below, and publishing those as the fill would restate the request as the - # result — the defect these fields were added to end. - fill_known = amount_in_raw is not None and amount_out_raw is not None - if fill_known: - input_amount = Decimal(str(amount_in_raw)) - output_amount = Decimal(str(amount_out_raw)) - # Price in quote-per-base for both sides: SELL flows base->quote (out/in), - # BUY flows quote->base (in/out). - if side == "SELL": - price = output_amount / input_amount if input_amount > 0 else Decimal("0") - else: - price = input_amount / output_amount if output_amount > 0 else Decimal("0") - else: - # Submitted-not-confirmed: only the request leg of the flow is known — - # request.amount is base-denominated (SELL: base in, BUY: base out). - # The unknown leg and price stay 0 placeholders. - input_amount = request.amount if side == "SELL" else Decimal("0") - output_amount = request.amount if side == "BUY" else Decimal("0") - price = Decimal("0") - - # Gateway reports the gas it actually paid in the same confirmed `data` block. - # Record it here rather than leaving the columns null for the poller to fill — - # the poller only revisits swaps that were still pending, so a swap confirmed - # on the execute call was never getting its gas recorded at all. - fee_raw = data.get("fee") - gas_fee = Decimal(str(fee_raw)) if fee_raw is not None else None - chain, _ = accounts_service.gateway_client.parse_network_id(request.network) - gas_token = get_native_gas_token(chain) if gas_fee is not None else None - - # Prefer the slippage Gateway reports it actually applied over the one the - # caller asked for: omitting slippage_pct means "use the connector's configured - # value", and recording the request's None there loses what was really enforced. - applied_slippage = data.get("slippagePct") - slippage_pct = ( - Decimal(str(applied_slippage)) if applied_slippage is not None - else request.slippage_pct - ) - - # Get transaction status from Gateway response - tx_status = get_transaction_status_from_response(result) - - # Store swap in database - try: - async with db_manager.get_session_context() as session: - swap_repo = GatewaySwapRepository(session) - - swap_data = { - "transaction_hash": transaction_hash, - "network": request.network, - # Store the base venue name: a swap on "jupiter" and one on - # "jupiter/router" are the same venue and must file together. - "connector": request.connector.split("/")[0], - "wallet_address": wallet_address, - "trading_pair": request.trading_pair, - "base_token": base, - "quote_token": quote, - "side": side, - "input_amount": float(input_amount), - "output_amount": float(output_amount), - "price": float(price), - "slippage_pct": float(slippage_pct) if slippage_pct is not None else None, - "gas_fee": float(gas_fee) if gas_fee is not None else None, - "gas_token": gas_token, - "status": tx_status, - # Set by the pool-scoped routes, which resolve exactly one pool; a - # router picks its own path across pools and leaves it unset. - "pool_address": data.get("poolAddress") - } - - await swap_repo.create_swap(swap_data) - logger.info(f"Recorded swap in database: {transaction_hash} (status: {tx_status})") - except Exception as db_error: - # Log but don't fail the swap - it was submitted successfully - logger.error(f"Error recording swap in database: {db_error}", exc_info=True) - - return SwapExecuteResponse( - transaction_hash=transaction_hash, + return await _record_and_report_swap( + result=result, + db_manager=db_manager, + accounts_service=accounts_service, + connector=request.connector, + network=request.network, + wallet_address=wallet_address, trading_pair=request.trading_pair, - side=side, - amount=request.amount, - # What actually moved, alongside what was asked for. Same values the swap - # history row above records, so a caller no longer has to go and read it. - input_amount=input_amount if fill_known else None, - output_amount=output_amount if fill_known else None, - price=price if fill_known else None, - # "confirmed" / "submitted" / "failed" — a failed EVM swap comes back as - # status -1 with zeroed amounts, which must not read as in-flight. - status=tx_status + base=base, + quote=quote, + side=request.side, + requested_amount=request.amount, + requested_slippage_pct=request.slippage_pct, ) except HTTPException: @@ -305,6 +346,80 @@ async def get_swap_status( raise HTTPException(status_code=500, detail=f"Error getting swap status: {str(e)}") +@router.post("/swap/execute-quote", response_model=SwapExecuteResponse) +async def execute_swap_quote( + request: SwapExecuteQuoteRequest, + accounts_service: AccountsService = Depends(get_accounts_service), + db_manager: AsyncDatabaseManager = Depends(get_database_manager) +): + """ + Execute a quote returned by /swap/quote, by its quote_id. + + The two-step flow: quote, decide, then commit to THAT quote. /swap/execute prices + again at execution, which discards the price the caller saw — the whole reason dflow, + titan and 0x return a held quote. Router connectors only; a pool-scoped connector has + no cached quote to execute and is rejected rather than quietly re-priced. + + Example: + connector: 'jupiter' + network: 'solana-mainnet-beta' + quote_id: '' + trading_pair: 'SOL-USDC' + side: 'SELL' + amount: 0.01 + + Returns: + Transaction hash and what the swap actually moved + """ + try: + if not await accounts_service.gateway_client.ping(): + raise HTTPException(status_code=503, detail="Gateway service is not available") + + chain, network = accounts_service.gateway_client.parse_network_id(request.network) + wallet_address = await accounts_service.gateway_client.get_wallet_address_or_default( + chain=chain, + wallet_address=request.wallet_address + ) + base, quote = split_trading_pair(request.trading_pair) + + try: + result = check_gateway_error(await accounts_service.gateway_client.execute_quote( + connector=request.connector, + chain_network=request.network, + wallet_address=wallet_address, + quote_id=request.quote_id, + )) + except ValueError as e: + # A non-router connector, or an unusable pair — the caller's mistake, not a + # server fault. + raise HTTPException(status_code=400, detail=str(e)) + + return await _record_and_report_swap( + result=result, + db_manager=db_manager, + accounts_service=accounts_service, + connector=request.connector, + network=request.network, + wallet_address=wallet_address, + trading_pair=request.trading_pair, + base=base, + quote=quote, + side=request.side, + requested_amount=request.amount, + # The quote fixed the tolerance when it was taken; Gateway reports the value + # it applied in the response, which _record_and_report_swap prefers anyway. + requested_slippage_pct=None, + ) + + except HTTPException: + raise + except GatewayError as e: + raise HTTPException(status_code=e.status, detail=f"Gateway error executing quote: {e}") + except Exception as e: + logger.error(f"Error executing quote: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Error executing quote: {str(e)}") + + @router.post("/swaps/search") async def search_swaps( network: Optional[str] = None, diff --git a/services/gateway_client.py b/services/gateway_client.py index 06275d86..859b4d07 100644 --- a/services/gateway_client.py +++ b/services/gateway_client.py @@ -28,6 +28,7 @@ ClmmQuoteLiquidityRequest, ClmmQuoteSwapRequest, ClmmRemoveRequest, + RouterExecuteQuoteRequest, RouterExecuteSwapRequest, RouterQuoteSwapRequest, ) @@ -672,6 +673,35 @@ async def quote_swap( return await self._request("GET", f"trading/{trading_type}/quote-swap", params=params) + async def execute_quote( + self, + connector: str, + chain_network: str, + wallet_address: str, + quote_id: str, + ) -> Dict: + """Execute a quote the caller already holds, by its id. + + Router-only, and deliberately so: a quote id refers to route calldata Gateway + cached, which pool-scoped amm/clmm swaps have no equivalent of — they price + against a pool at execution time. Naming a non-router connector therefore fails + here rather than silently re-pricing, which would defeat the point of the flow. + """ + name, trading_type = await self.resolve_swap_route(connector) + if trading_type != "router": + raise ValueError( + f"Connector '{name}' is a {trading_type} connector: only routers hold a quote to " + "execute. Use /swap/execute for a pool-scoped swap, which prices at execution." + ) + return await self._request("POST", "trading/router/execute-quote", json=_body( + RouterExecuteQuoteRequest( + chainNetwork=chain_network, + connector=name, + walletAddress=wallet_address, + quoteId=quote_id, + ) + )) + async def execute_swap( self, connector: str, diff --git a/test/test_execute_quote_flow.py b/test/test_execute_quote_flow.py new file mode 100644 index 00000000..c0371e4c --- /dev/null +++ b/test/test_execute_quote_flow.py @@ -0,0 +1,94 @@ +"""The two-step swap flow has to be reachable, or the quote is decoration. + +Gateway has had /trading/router/execute-quote all along, and nothing downstream exposed +it: not hummingbot-api, not the client, not condor. So every swap on record went through +the one-step execute, which re-prices at execution and discards the quote the caller saw +— which is the entire value of a held quote on dflow, titan and 0x. +""" +from decimal import Decimal + +import pytest + +from models import SwapExecuteQuoteRequest, SwapQuoteResponse +from services.gateway_client import GatewayClient + + +def test_a_quote_carries_the_handle_needed_to_execute_it(): + # Without quote_id on the response, the flow cannot even start: the caller has + # nothing to pass back. + assert "quote_id" in SwapQuoteResponse.model_fields + assert SwapQuoteResponse.model_fields["quote_id"].default is None + + +def test_the_execute_quote_request_names_the_quote_and_what_it_was_for(): + request = SwapExecuteQuoteRequest( + connector="jupiter", + network="solana-mainnet-beta", + quote_id="q-123", + trading_pair="SOL-USDC", + side="SELL", + amount=Decimal("0.01"), + ) + + assert request.quote_id == "q-123" + # The pair and amount are not sent to Gateway — it identifies the swap by quote_id — + # but the recorded trade has to be filed under something. + assert request.trading_pair == "SOL-USDC" + assert request.amount == Decimal("0.01") + + +@pytest.mark.asyncio +async def test_the_client_posts_the_quote_id_to_gateways_router_route(monkeypatch): + client = GatewayClient() + calls = [] + + async def fake_request(method, path, **kwargs): + calls.append((method, path, kwargs.get("json"))) + return {"signature": "sig", "status": 1} + + async def fake_resolve(connector): + return ("jupiter", "router") + + monkeypatch.setattr(client, "_request", fake_request) + monkeypatch.setattr(client, "resolve_swap_route", fake_resolve) + + await client.execute_quote( + connector="jupiter", + chain_network="solana-mainnet-beta", + wallet_address="wallet", + quote_id="q-123", + ) + + method, path, body = calls[0] + assert (method, path) == ("POST", "trading/router/execute-quote") + assert body["quoteId"] == "q-123" + assert body["connector"] == "jupiter" + + +@pytest.mark.asyncio +async def test_a_pool_scoped_connector_is_refused_rather_than_re_priced(monkeypatch): + # meteora prices against a pool at execution, so it has no cached quote. Silently + # re-pricing would give the caller a swap at a price they never saw, which is the + # failure this whole route exists to avoid. + client = GatewayClient() + + async def fake_resolve(connector): + return ("meteora", "clmm") + + monkeypatch.setattr(client, "resolve_swap_route", fake_resolve) + + with pytest.raises(ValueError, match="only routers hold a quote"): + await client.execute_quote( + connector="meteora", + chain_network="solana-mainnet-beta", + wallet_address="wallet", + quote_id="q-123", + ) + + +def test_both_execute_paths_record_through_one_function(): + # /swap/execute and /swap/execute-quote differ in how the transaction was produced + # and not at all in what has to be booked afterwards. Two copies would drift. + source = open("routers/gateway_swap.py").read() + + assert source.count("return await _record_and_report_swap(") == 2 From 668321244bfc130418813756dea2034362aed4fc Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Thu, 20 Aug 2026 16:21:24 -0700 Subject: [PATCH 35/54] fix(gateway): record the writes that landed on-chain and reverted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every row in both event tables read CONFIRMED with no error_message. Not because nothing had ever failed — a close that reverted at slot 440494812, costing 0.000011772 SOL, left no row at all — but because a failure could not be written. The recording code runs only when Gateway returns; a transaction that lands and reverts makes Gateway raise, and control skips every create_event call to land in an except that persists nothing. Only failures carrying a transaction id are recorded. A pre-flight simulation failure never got a signature and cost nothing, so inventing an identifier for it would put a row in the table that no lookup by hash could ever match. Gateway names the id in the message either way it can, and it was reaching a log line and nowhere else. A failed CLMM open still writes no row: gateway_clmm_events keys every row to a position, and an open that reverted created none. wallet_address is bound before the try so the recorder cannot NameError over the top of Gateway's own error when the wallet lookup is what failed. Recording never masks the original failure. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK --- routers/gateway_amm.py | 73 ++++++++++++++++++++++++++++++++++++++- routers/gateway_clmm.py | 73 ++++++++++++++++++++++++++++++++++++++- routers/gateway_extras.py | 18 ++++++++++ 3 files changed, 162 insertions(+), 2 deletions(-) diff --git a/routers/gateway_amm.py b/routers/gateway_amm.py index 7298dabc..8cbb64e4 100644 --- a/routers/gateway_amm.py +++ b/routers/gateway_amm.py @@ -41,7 +41,12 @@ AMMRemoveLiquidityRequest, AMMTransactionResponse, ) -from routers.gateway_extras import ExtraParamsSpec, get_transaction_status_from_response, validate_extra_params +from routers.gateway_extras import ( + ExtraParamsSpec, + get_transaction_status_from_response, + transaction_id_from_error, + validate_extra_params, +) from services.accounts_service import AccountsService from services.gateway_client import GatewayError, check_gateway_error, get_native_gas_token @@ -206,6 +211,56 @@ async def _record_event( # ----------------------------- Reads ----------------------------- + +async def _record_failed_event( + db_manager: AsyncDatabaseManager, + error: Exception, + *, + event_type: str, + connector: str, + network: str, + wallet_address: str, + pool_address: str, + position_address: Optional[str] = None, +) -> None: + """Record a write that reached the chain and reverted, before the error is re-raised. + + `_record_event` above only runs when Gateway *returns*. A transaction that landed and + reverted does not return: Gateway raises, the client turns it into a GatewayError, and + control skips the whole recording block. That is why every row in both event tables + read CONFIRMED with no error_message — not because nothing had ever failed, but + because a failure could not be written. + + Only failures carrying a transaction id are recorded: a pre-flight simulation failure + never got one and cost nothing, while a landed revert has one and paid gas. Recording + never masks the original failure. + """ + transaction_hash = transaction_id_from_error(error) + if not transaction_hash: + return + + chain, _ = network.split("-", 1) if "-" in network else (network, "") + try: + async with db_manager.get_session_context() as session: + await GatewayAMMRepository(session).create_event({ + "transaction_hash": transaction_hash, + "connector": connector, + "network": network, + "wallet_address": wallet_address, + "pool_address": pool_address, + "position_address": position_address, + "event_type": event_type, + "status": "FAILED", + "error_message": str(error), + }) + logger.error( + f"AMM {event_type} {transaction_hash} landed on-chain and FAILED on {connector}/" + f"{network}; recorded. {error}" + ) + except Exception as db_error: + logger.error(f"Error recording failed AMM {event_type}: {db_error}", exc_info=True) + + @router.get("/amm/pool-info", response_model=AMMPoolInfoResponse, response_model_by_alias=False) async def get_amm_pool_info( connector: str, @@ -328,6 +383,9 @@ async def add_amm_liquidity( Meteora DAMM v2: pass position_address to add to that NFT position; omit it to open a new one. Fungible-LP AMMs ignore position_address. """ + # Bound before the try so the failure recorder in `except` cannot NameError + # over the top of Gateway's own error when the wallet lookup is what failed. + wallet_address = "" try: await _require_gateway(accounts_service) wallet_address = await _resolve_wallet(accounts_service, request.network, request.wallet_address) @@ -374,6 +432,11 @@ async def add_amm_liquidity( except HTTPException: raise except GatewayError as e: + await _record_failed_event( + db_manager, e, event_type="ADD_LIQUIDITY", connector=request.connector, + network=request.network, wallet_address=wallet_address, + pool_address=request.pool_address, position_address=request.position_address, + ) raise HTTPException(status_code=e.status, detail=f"Gateway error adding AMM liquidity: {e}") except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) @@ -394,6 +457,9 @@ async def remove_amm_liquidity( Meteora DAMM v2 requires position_address (positions are NFTs); Gateway rejects a missing one with a 400, surfaced here unchanged, so "remove 100%" is a true exit of the named position. """ + # Bound before the try so the failure recorder in `except` cannot NameError + # over the top of Gateway's own error when the wallet lookup is what failed. + wallet_address = "" try: await _require_gateway(accounts_service) wallet_address = await _resolve_wallet(accounts_service, request.network, request.wallet_address) @@ -446,6 +512,11 @@ async def remove_amm_liquidity( except HTTPException: raise except GatewayError as e: + await _record_failed_event( + db_manager, e, event_type="REMOVE_LIQUIDITY", connector=request.connector, + network=request.network, wallet_address=wallet_address, + pool_address=request.pool_address, position_address=request.position_address, + ) raise HTTPException(status_code=e.status, detail=f"Gateway error removing AMM liquidity: {e}") except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) diff --git a/routers/gateway_clmm.py b/routers/gateway_clmm.py index 131ad943..4346fcaa 100644 --- a/routers/gateway_clmm.py +++ b/routers/gateway_clmm.py @@ -31,7 +31,12 @@ CLMMQuotePositionResponse, CLMMRemoveLiquidityRequest, ) -from routers.gateway_extras import ExtraParamsSpec, get_transaction_status_from_response, validate_extra_params +from routers.gateway_extras import ( + ExtraParamsSpec, + get_transaction_status_from_response, + transaction_id_from_error, + validate_extra_params, +) from services.accounts_service import AccountsService from services.gateway_client import GatewayError, check_gateway_error, get_native_gas_token @@ -152,6 +157,56 @@ async def _refresh_position_data(position, accounts_service: AccountsService, cl raise +async def _record_failed_write( + db_manager: AsyncDatabaseManager, + error: Exception, + *, + event_type: str, + position_address: Optional[str], +) -> None: + """Record a write that reached the chain and reverted, before the error is re-raised. + + The recording code below only runs when Gateway *returns*. A transaction that landed + and reverted does not return: Gateway raises, the client turns it into a GatewayError, + and control skips every `create_event` call to land in an `except` that persists + nothing. So the database said every operation ever attempted had succeeded, while a + close that reverted at slot 440494812 — costing 0.000011772 SOL — left no row at all. + + Only failures carrying a transaction id are recorded. A pre-flight simulation failure + never got one and cost nothing, and inventing an identifier for it would put a row in + the table that no lookup by hash could ever match. + + Recording never masks the original failure: the caller still gets Gateway's error. + """ + transaction_hash = transaction_id_from_error(error) + if not transaction_hash or not position_address: + return + + try: + async with db_manager.get_session_context() as session: + repo = GatewayCLMMRepository(session) + position = await repo.get_position_by_address(position_address) + if position is None: + logger.warning( + f"CLMM {event_type} {transaction_hash} reverted on-chain for position " + f"{position_address}, which has no database record — no event written." + ) + return + await repo.create_event({ + "position_id": position.id, + "transaction_hash": transaction_hash, + "event_type": event_type, + "status": "FAILED", + "error_message": str(error), + }) + logger.error( + f"CLMM {event_type} {transaction_hash} landed on-chain and FAILED for position " + f"{position_address}; recorded. {error}" + ) + except Exception as db_error: + logger.error(f"Error recording failed CLMM {event_type}: {db_error}", exc_info=True) + + @router.get("/clmm/pool-info", response_model=CLMMPoolInfoResponse, response_model_by_alias=False) async def get_clmm_pool_info( connector: str, @@ -543,6 +598,10 @@ async def open_clmm_position( except HTTPException: raise except GatewayError as e: + # No row for a failed OPEN: gateway_clmm_events keys every row to a position, and + # an open that reverted created none. The transaction id is in the message and the + # error reaches the caller; inventing a position to hang it from would be worse + # than the gap. See GW-26. raise HTTPException(status_code=e.status, detail=f"Gateway error opening CLMM position: {e}") except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) @@ -698,6 +757,9 @@ async def add_liquidity_to_clmm_position( except HTTPException: raise except GatewayError as e: + await _record_failed_write( + db_manager, e, event_type="ADD_LIQUIDITY", position_address=request.position_address + ) raise HTTPException(status_code=e.status, detail=f"Gateway error adding liquidity: {e}") except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) @@ -822,6 +884,9 @@ async def remove_liquidity_from_clmm_position( except HTTPException: raise except GatewayError as e: + await _record_failed_write( + db_manager, e, event_type="REMOVE_LIQUIDITY", position_address=request.position_address + ) raise HTTPException(status_code=e.status, detail=f"Gateway error removing liquidity: {e}") except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) @@ -1055,6 +1120,9 @@ async def close_clmm_position( except HTTPException: raise except GatewayError as e: + await _record_failed_write( + db_manager, e, event_type="CLOSE", position_address=request.position_address + ) raise HTTPException(status_code=e.status, detail=f"Gateway error closing CLMM position: {e}") except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) @@ -1220,6 +1288,9 @@ async def collect_fees_from_clmm_position( except HTTPException: raise except GatewayError as e: + await _record_failed_write( + db_manager, e, event_type="COLLECT_FEES", position_address=request.position_address + ) raise HTTPException(status_code=e.status, detail=f"Gateway error collecting fees: {e}") except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) diff --git a/routers/gateway_extras.py b/routers/gateway_extras.py index f8ac83de..79ec660d 100644 --- a/routers/gateway_extras.py +++ b/routers/gateway_extras.py @@ -7,6 +7,7 @@ letting a typo'd key, a key sent to a connector that ignores it, or a value of the wrong type get silently dropped or misparsed downstream. """ +import re from typing import Any, Dict, Optional, Set, Tuple from fastapi import HTTPException @@ -32,6 +33,23 @@ def get_transaction_status_from_response(gateway_response: dict) -> str: return "SUBMITTED" +# A Solana signature or an EVM transaction hash, as they appear inside Gateway's +# landed-but-failed message: "Transaction landed on-chain but failed: ". +_TRANSACTION_ID = re.compile(r"[Tt]ransaction ([1-9A-HJ-NP-Za-km-z]{43,88}|0x[0-9a-fA-F]{64})") + + +def transaction_id_from_error(error: Exception) -> Optional[str]: + """The transaction a failed Gateway call actually sent, if it sent one. + + This is the distinction worth keeping: a pre-flight simulation failure never got a + signature and cost nothing, while a transaction that landed and reverted has one and + paid gas for the privilege. Gateway names it in the message either way it can, and it + was reaching a log line and nowhere else. + """ + match = _TRANSACTION_ID.search(str(error)) + return match.group(1) if match else None + + # Spec entry: key -> (allowed value types, connectors that honor the key). # Deliberate strictness (accepted residual): numeric keys are typed int even though # Gateway's TypeBox says Number (JS has one number type) — the domains are integral From ac1c3d4b0a508236a9ac6e45b39df2850cabcedb Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Thu, 20 Aug 2026 16:21:33 -0700 Subject: [PATCH 36/54] fix(swap): say when a BUY quote is an approximation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A BUY is an ExactOut order, and many thin tokens have no ExactOut route, so Gateway falls back to quoting the sell leg and quoting that input forward — paying the pool fee and crossing the spread twice. It flags that on the response as `approximation`, and this dropped the flag, so a quote whose amount_out was ~2.5% short of what was asked for looked identical to an exact one. The input half was already wired end to end: a caller could switch the behaviour off via extra_params={'approximateIfNoExactOut': false} but could not find out whether it had happened. Measured at a near-constant ~2.5% across eleven pools spanning $17 to $1,963 of liquidity, and it is reached for only on the thin, high-fee pools where it hurts most. The caller is not overcharged; the order is silently resized, which is what matters to a strategy that asked for a specific quantity. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK --- models/gateway_trading.py | 11 +++++++++++ routers/gateway_swap.py | 6 ++++++ 2 files changed, 17 insertions(+) diff --git a/models/gateway_trading.py b/models/gateway_trading.py index 2d48ca32..9ce64c0c 100644 --- a/models/gateway_trading.py +++ b/models/gateway_trading.py @@ -64,6 +64,17 @@ class SwapQuoteResponse(BaseModel): description="Identifier for this quote, on the router connectors that hold a price. Pass it " "to /swap/execute-quote to execute THIS quote instead of re-pricing. Absent on " "pool-scoped connectors, which price against the pool at execution time.") + approximation: Optional[bool] = Field( + default=None, + description="True when amount_out is an ESTIMATE rather than the exact-out amount asked " + "for. A BUY is an ExactOut order, and many thin tokens have no ExactOut route, " + "so Gateway falls back to quoting the sell leg and then quoting that input " + "forward — which pays the pool fee and crosses the spread twice. Measured at a " + "near-constant ~2.5% across eleven pools spanning $17 to $1,963 of liquidity, " + "and it is reached for ONLY on the thin, high-fee pools where it hurts most. " + "The caller is not overcharged; the order is silently resized, which is what " + "matters to a strategy that asked for a specific quantity. Set " + "extra_params={'approximateIfNoExactOut': false} to require an exact route.") class SwapExecuteQuoteRequest(BaseModel): diff --git a/routers/gateway_swap.py b/routers/gateway_swap.py index 9eac1095..b2f7bd85 100644 --- a/routers/gateway_swap.py +++ b/routers/gateway_swap.py @@ -95,6 +95,12 @@ def _dec(key): # The handle for /swap/execute-quote. Routers that hold a price return one; # pool-scoped connectors do not, because they price at execution. quote_id=result.get("quoteId"), + # Gateway flags an approximated BUY and this dropped it, so a quote whose + # amount_out was ~2.5% short of the request looked identical to an exact one. + # The input half of the feature was already wired end to end — + # approximateIfNoExactOut is accepted through extra_params — so a caller + # could switch the behaviour off but not find out whether it had happened. + approximation=result.get("approximation"), slippage_pct=(_dec("slippagePct") if result.get("slippagePct") is not None else request.slippage_pct), ) From d86823b59dcc5a76db0fe2ee059241db94657d89 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Thu, 20 Aug 2026 16:21:43 -0700 Subject: [PATCH 37/54] test(gateway): test the connector that exists, not the one that was renamed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five tests imported GatewayLp from hummingbot.connector.gateway.gateway_lp and had been failing on ModuleNotFoundError. The connector is now Gateway, and the rename carries a design change worth pinning: it is addressed by NETWORK ("solana-mainnet-beta") rather than by DEX-and-type ("meteora/clmm"), with the DEX and trading type travelling as arguments — which is what removed the KeyError this file is named for. Two of the tests read the source text of _create_trading_connector and asserted on substrings of it, including a "'/' in connector_name" branch that no longer exists. They now call the method. Added the other side of the branch: the Gateway path is reached by a name being ABSENT from _conn_settings, so an empty _conn_settings would send every exchange down it too, and nothing was holding that line. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK --- test/test_gateway_lp_executor.py | 135 +++++++++++++++++-------------- 1 file changed, 73 insertions(+), 62 deletions(-) diff --git a/test/test_gateway_lp_executor.py b/test/test_gateway_lp_executor.py index 944721e4..13507bbb 100644 --- a/test/test_gateway_lp_executor.py +++ b/test/test_gateway_lp_executor.py @@ -2,7 +2,8 @@ Tests for Gateway LP Executor functionality. Tests the following fixes: -1. KeyError: 'meteora/clmm' - Gateway connectors should use GatewayLp directly +1. KeyError: 'meteora/clmm' - the DEX and trading type belong in lp_provider, + not in connector_name, which names the network 2. Script config staging compatibility - candles_config and markets removed Run with: pytest test/test_gateway_lp_executor.py -v @@ -18,39 +19,48 @@ class TestGatewayConnectorFix: - """Tests for Fix 1: KeyError 'meteora/clmm' resolution.""" + """Tests for how a Gateway connector is created. + + The connector used to be GatewayLp, addressed per DEX-and-type ("meteora/clmm"), + which is what produced the KeyError this file was written for. It is now a single + Gateway connector addressed by NETWORK ("solana-mainnet-beta"): the DEX and the + trading type are arguments to its methods, not part of its identity. So the thing + worth pinning is no longer "does the slash get detected" but "does a name Gateway + owns produce a Gateway, and a name an exchange owns still produce that exchange". + """ - def test_gateway_lp_import(self): - """GatewayLp should be importable from hummingbot.""" - from hummingbot.connector.gateway.gateway_lp import GatewayLp - assert GatewayLp is not None + def test_gateway_import(self): + """Gateway should be importable from hummingbot.""" + from hummingbot.connector.gateway.gateway import Gateway + assert Gateway is not None - def test_gateway_lp_instantiation(self): - """GatewayLp should instantiate with meteora/clmm connector name.""" - from hummingbot.connector.gateway.gateway_lp import GatewayLp + def test_gateway_instantiation(self): + """Gateway should instantiate against a network name.""" + from hummingbot.connector.gateway.gateway import Gateway - connector = GatewayLp( - connector_name="meteora/clmm", + connector = Gateway( + connector_name="solana-mainnet-beta", trading_pairs=[], trading_required=True, ) - assert connector.connector_name == "meteora/clmm" - assert connector.name == "meteora/clmm" + assert connector.connector_name == "solana-mainnet-beta" + assert connector.name == "solana-mainnet-beta" - def test_gateway_lp_has_required_methods(self): - """GatewayLp should have methods required by LP executor.""" - from hummingbot.connector.gateway.gateway_lp import GatewayLp + def test_gateway_has_required_methods(self): + """Gateway should have the methods the LP executor calls.""" + from hummingbot.connector.gateway.gateway import Gateway - connector = GatewayLp( - connector_name="meteora/clmm", + connector = Gateway( + connector_name="solana-mainnet-beta", trading_pairs=[], trading_required=True, ) required_methods = [ "get_position_info", - "_clmm_add_liquidity", - "create_market_order_id", + "add_liquidity", + "remove_liquidity", + "get_pool_info", "start_network", "stop_network", ] @@ -58,62 +68,61 @@ def test_gateway_lp_has_required_methods(self): for method in required_methods: assert hasattr(connector, method), f"Missing method: {method}" - def test_gateway_detection_in_unified_connector_service(self): - """_create_trading_connector should detect gateway connectors.""" - from services.unified_connector_service import UnifiedConnectorService - - source = inspect.getsource(UnifiedConnectorService._create_trading_connector) + @pytest.mark.asyncio + async def test_create_trading_connector_for_gateway(self): + """A network name should build a Gateway connector.""" + from hummingbot.connector.gateway.gateway import Gateway - # Check gateway detection logic exists - assert "'/' in connector_name" in source, "Gateway detection condition not found" - assert "GatewayLp(" in source, "GatewayLp instantiation not found" + from services.unified_connector_service import UnifiedConnectorService - def test_gateway_connector_names_detected(self): - """Gateway connector names (with /) should be detected correctly.""" - gateway_connectors = [ - "meteora/clmm", - "raydium/clmm", - "uniswap/amm", - "jupiter/router", - "orca/whirlpool", - ] + # Create a minimal service instance + service = UnifiedConnectorService.__new__(UnifiedConnectorService) + service._conn_settings = {} + service.secrets_manager = MagicMock() - regular_connectors = [ - "binance", - "binance_perpetual", - "kucoin", - "gate_io", - ] + with patch("services.unified_connector_service.BackendAPISecurity") as mock_security: + mock_security.login_account = MagicMock() - for name in gateway_connectors: - assert "/" in name, f"{name} should be detected as gateway" + connector = service._create_trading_connector( + account_name="master_account", + connector_name="solana-mainnet-beta" + ) - for name in regular_connectors: - assert "/" not in name, f"{name} should NOT be detected as gateway" + assert isinstance(connector, Gateway) + assert connector.connector_name == "solana-mainnet-beta" @pytest.mark.asyncio - async def test_create_trading_connector_for_gateway(self): - """_create_trading_connector should return GatewayLp for gateway connectors.""" - from hummingbot.connector.gateway.gateway_lp import GatewayLp + async def test_exchange_name_does_not_build_a_gateway_connector(self): + """A name AllConnectorSettings knows must still build that exchange's connector. + + Guards the other side of the branch: the Gateway path is reached by a name being + ABSENT from _conn_settings, so an empty _conn_settings would send every exchange + down it too. + """ + from hummingbot.connector.gateway.gateway import Gateway from services.unified_connector_service import UnifiedConnectorService - # Create a minimal service instance service = UnifiedConnectorService.__new__(UnifiedConnectorService) - service._conn_settings = {} service.secrets_manager = MagicMock() - # Mock BackendAPISecurity - with patch("services.unified_connector_service.BackendAPISecurity") as mock_security: + conn_setting = MagicMock() + conn_setting.conn_init_parameters.return_value = {} + service._conn_settings = {"binance": conn_setting} + + with patch("services.unified_connector_service.BackendAPISecurity") as mock_security, \ + patch("services.unified_connector_service.get_connector_class") as mock_class: mock_security.login_account = MagicMock() + mock_security.api_keys = MagicMock(return_value={}) + mock_class.return_value = MagicMock(return_value="binance-connector") connector = service._create_trading_connector( account_name="master_account", - connector_name="meteora/clmm" + connector_name="binance" ) - assert isinstance(connector, GatewayLp) - assert connector.connector_name == "meteora/clmm" + assert not isinstance(connector, Gateway) + mock_class.assert_called_once_with("binance") class TestScriptConfigFix: @@ -246,11 +255,12 @@ async def test_lp_executor_types_available(self, api_url, api_auth): @pytest.mark.asyncio @pytest.mark.integration async def test_create_lp_executor_no_keyerror(self, api_url, api_auth): - """Creating LP executor should not raise KeyError for meteora/clmm. + """Creating an LP executor should not raise KeyError over the provider name. - This test verifies the fix for the KeyError: 'meteora/clmm' issue. - The request may fail due to Gateway not running, but should NOT fail - with KeyError. + The DEX and trading type now travel as lp_provider, while connector_name is the + network -- the split that removed the KeyError this test is named for. The + request may still fail because Gateway is not running; it must not fail with a + KeyError. """ import aiohttp @@ -258,7 +268,8 @@ async def test_create_lp_executor_no_keyerror(self, api_url, api_auth): "account_name": "master_account", "executor_config": { "type": "lp_executor", - "connector_name": "meteora/clmm", + "connector_name": "solana-mainnet-beta", + "lp_provider": "meteora/clmm", "trading_pair": "SOL-USDC", "pool_address": "BGm1av58oGcsQJehL9WXBFXF7D27vZsKefj4xJKD5Y", "lower_price": "84", From 47413d7ab7a21b94e94d85426d22278c2d996790 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Thu, 20 Aug 2026 16:25:55 -0700 Subject: [PATCH 38/54] chore(gateway): refresh the spec, and stop sending a key the router rejects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Money now crosses the wire as decimal strings and the routes reject undeclared keys. The generated money fields are unchanged — Decimal count holds at 140, because the generator maps `string` + `format: decimal` to the same Decimal as before — they simply stop receiving float noise. Rejecting undeclared keys immediately caught this client doing what its own comment warns about. quote_swap and execute_swap raise a clear ValueError when a pool_address is passed for a router, because "the router model has no poolAddress, and pydantic drops an unknown keyword silently — which would look like the pin applied". Both then passed `poolAddress=pool_address or None` unconditionally, router included. The guard covered the truthy case; the None sailed past it into the very silent drop the comment describes. It is now only passed to the models that declare it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK --- gateway-openapi.json | 250 +++++++++++++++++++----------------- models/gateway_generated.py | 101 ++++++++++++++- services/gateway_client.py | 9 +- 3 files changed, 238 insertions(+), 122 deletions(-) diff --git a/gateway-openapi.json b/gateway-openapi.json index f81b112e..9f76a841 100644 --- a/gateway-openapi.json +++ b/gateway-openapi.json @@ -30,19 +30,19 @@ }, "feePct": { "format": "decimal", - "type": "number" + "type": "string" }, "price": { "format": "decimal", - "type": "number" + "type": "string" }, "baseTokenAmount": { "format": "decimal", - "type": "number" + "type": "string" }, "quoteTokenAmount": { "format": "decimal", - "type": "number" + "type": "string" } }, "required": [ @@ -79,7 +79,7 @@ "properties": { "fee": { "format": "decimal", - "type": "number" + "type": "string" }, "poolAddress": { "description": "Pool this operation acted on", @@ -98,15 +98,15 @@ "x-connectors": [ "meteora" ], - "type": "number" + "type": "string" }, "baseTokenAmountAdded": { "format": "decimal", - "type": "number" + "type": "string" }, "quoteTokenAmountAdded": { "format": "decimal", - "type": "number" + "type": "string" } }, "required": [ @@ -127,19 +127,19 @@ }, "baseTokenAmount": { "format": "decimal", - "type": "number" + "type": "string" }, "quoteTokenAmount": { "format": "decimal", - "type": "number" + "type": "string" }, "baseTokenAmountMax": { "format": "decimal", - "type": "number" + "type": "string" }, "quoteTokenAmountMax": { "format": "decimal", - "type": "number" + "type": "string" } }, "required": [ @@ -174,7 +174,7 @@ "properties": { "fee": { "format": "decimal", - "type": "number" + "type": "string" }, "poolAddress": { "description": "Pool this operation acted on", @@ -193,15 +193,15 @@ "x-connectors": [ "meteora" ], - "type": "number" + "type": "string" }, "baseTokenAmountRemoved": { "format": "decimal", - "type": "number" + "type": "string" }, "quoteTokenAmountRemoved": { "format": "decimal", - "type": "number" + "type": "string" } }, "required": [ @@ -227,7 +227,7 @@ "price": { "format": "decimal", "description": "Initial price the pool was seeded at (quote per base)", - "type": "number" + "type": "string" }, "data": { "$ref": "#/components/schemas/AmmCreatePoolResponseData" @@ -244,15 +244,15 @@ "properties": { "fee": { "format": "decimal", - "type": "number" + "type": "string" }, "baseTokenAmountAdded": { "format": "decimal", - "type": "number" + "type": "string" }, "quoteTokenAmountAdded": { "format": "decimal", - "type": "number" + "type": "string" } }, "required": [ @@ -271,15 +271,15 @@ "lpTokenAmount": { "format": "decimal", "description": "Liquidity held by this position (LP units)", - "type": "number" + "type": "string" }, "baseTokenAmount": { "format": "decimal", - "type": "number" + "type": "string" }, "quoteTokenAmount": { "format": "decimal", - "type": "number" + "type": "string" } }, "required": [ @@ -306,19 +306,19 @@ }, "lpTokenAmount": { "format": "decimal", - "type": "number" + "type": "string" }, "baseTokenAmount": { "format": "decimal", - "type": "number" + "type": "string" }, "quoteTokenAmount": { "format": "decimal", - "type": "number" + "type": "string" }, "price": { "format": "decimal", - "type": "number" + "type": "string" }, "positions": { "type": "array", @@ -339,6 +339,7 @@ ] }, "EstimateGasRequest": { + "additionalProperties": false, "type": "object", "properties": { "network": { @@ -369,7 +370,7 @@ "properties": { "feePerComputeUnit": { "format": "decimal", - "type": "number" + "type": "string" }, "denomination": { "type": "string" @@ -382,7 +383,7 @@ }, "fee": { "format": "decimal", - "type": "number" + "type": "string" }, "timestamp": { "type": "number" @@ -392,18 +393,18 @@ }, "maxFeePerGas": { "format": "decimal", - "type": "number" + "type": "string" }, "maxPriorityFeePerGas": { "format": "decimal", - "type": "number" + "type": "string" }, "priorityFeeLevel": { "type": "string" }, "priorityFeePerCUEstimate": { "format": "decimal", - "type": "number" + "type": "string" } }, "required": [ @@ -416,6 +417,7 @@ ] }, "BalanceRequest": { + "additionalProperties": false, "type": "object", "properties": { "network": { @@ -470,6 +472,7 @@ ] }, "PollRequest": { + "additionalProperties": false, "type": "object", "properties": { "network": { @@ -569,6 +572,7 @@ ] }, "StatusRequest": { + "additionalProperties": false, "type": "object", "properties": { "network": { @@ -643,32 +647,32 @@ "amountIn": { "format": "decimal", "description": "Amount of tokenIn to be swapped", - "type": "number" + "type": "string" }, "amountOut": { "format": "decimal", "description": "Expected amount of tokenOut to receive", - "type": "number" + "type": "string" }, "price": { "format": "decimal", "description": "Exchange rate between tokenIn and tokenOut", - "type": "number" + "type": "string" }, "priceImpactPct": { "format": "decimal", "description": "Estimated price impact percentage (0-100)", - "type": "number" + "type": "string" }, "minAmountOut": { "format": "decimal", "description": "Minimum amount of tokenOut that will be accepted", - "type": "number" + "type": "string" }, "maxAmountIn": { "format": "decimal", "description": "Maximum amount of tokenIn that will be spent", - "type": "number" + "type": "string" }, "poolAddress": { "description": "Pool address for AMM/CLMM swaps", @@ -681,7 +685,7 @@ "slippagePct": { "format": "decimal", "description": "Slippage tolerance percentage", - "type": "number" + "type": "string" } }, "required": [ @@ -729,32 +733,32 @@ "amountIn": { "format": "decimal", "description": "Actual amount of tokenIn swapped", - "type": "number" + "type": "string" }, "amountOut": { "format": "decimal", "description": "Actual amount of tokenOut received", - "type": "number" + "type": "string" }, "fee": { "format": "decimal", "description": "Transaction fee paid", - "type": "number" + "type": "string" }, "baseTokenBalanceChange": { "format": "decimal", "description": "Change in base token balance (negative for decrease)", - "type": "number" + "type": "string" }, "quoteTokenBalanceChange": { "format": "decimal", "description": "Change in quote token balance (negative for decrease)", - "type": "number" + "type": "string" }, "slippagePct": { "format": "decimal", "description": "Slippage tolerance percentage actually applied to the swap", - "type": "number" + "type": "string" }, "poolAddress": { "description": "Pool the swap executed against. Set by the pool-scoped routes (/trading/clmm, /trading/amm), which resolve exactly one pool; a router picks its own path across pools and leaves this unset. Without it a settled fill cannot be reconciled to a venue without refetching the transaction.", @@ -772,6 +776,7 @@ ] }, "WrapRequest": { + "additionalProperties": false, "type": "object", "properties": { "network": { @@ -811,6 +816,7 @@ ] }, "UnwrapRequest": { + "additionalProperties": false, "type": "object", "properties": { "network": { @@ -912,32 +918,32 @@ "amountIn": { "format": "decimal", "description": "Amount of tokenIn to be swapped", - "type": "number" + "type": "string" }, "amountOut": { "format": "decimal", "description": "Expected amount of tokenOut to receive", - "type": "number" + "type": "string" }, "price": { "format": "decimal", "description": "Exchange rate between tokenIn and tokenOut", - "type": "number" + "type": "string" }, "priceImpactPct": { "format": "decimal", "description": "Estimated price impact percentage (0-100)", - "type": "number" + "type": "string" }, "minAmountOut": { "format": "decimal", "description": "Minimum amount of tokenOut that will be accepted", - "type": "number" + "type": "string" }, "maxAmountIn": { "format": "decimal", "description": "Maximum amount of tokenIn that will be spent", - "type": "number" + "type": "string" }, "poolAddress": { "description": "Pool address for AMM/CLMM swaps", @@ -950,7 +956,7 @@ "slippagePct": { "format": "decimal", "description": "Slippage tolerance percentage", - "type": "number" + "type": "string" }, "quoteId": { "description": "Identifier to pass to /trading/router/execute-quote", @@ -1007,37 +1013,37 @@ "baseFee": { "format": "decimal", "description": "Base fee percentage", - "type": "number" + "type": "string" }, "price": { "format": "decimal", "description": "Current price", - "type": "number" + "type": "string" }, "tvl": { "format": "decimal", "description": "Total value locked in USD", - "type": "number" + "type": "string" }, "apr": { "format": "decimal", "description": "Annual percentage rate", - "type": "number" + "type": "string" }, "apy": { "format": "decimal", "description": "Annual percentage yield", - "type": "number" + "type": "string" }, "volume24h": { "format": "decimal", "description": "24-hour trading volume", - "type": "number" + "type": "string" }, "fees24h": { "format": "decimal", "description": "24-hour fees collected", - "type": "number" + "type": "string" } }, "required": [ @@ -1090,15 +1096,15 @@ }, "price": { "format": "decimal", - "type": "number" + "type": "string" }, "baseTokenAmount": { "format": "decimal", - "type": "number" + "type": "string" }, "quoteTokenAmount": { "format": "decimal", - "type": "number" + "type": "string" } }, "required": [ @@ -1125,19 +1131,19 @@ }, "feePct": { "format": "decimal", - "type": "number" + "type": "string" }, "price": { "format": "decimal", - "type": "number" + "type": "string" }, "baseTokenAmount": { "format": "decimal", - "type": "number" + "type": "string" }, "quoteTokenAmount": { "format": "decimal", - "type": "number" + "type": "string" }, "activeBinId": { "type": "number" @@ -1177,19 +1183,19 @@ }, "baseTokenAmount": { "format": "decimal", - "type": "number" + "type": "string" }, "quoteTokenAmount": { "format": "decimal", - "type": "number" + "type": "string" }, "baseFeeAmount": { "format": "decimal", - "type": "number" + "type": "string" }, "quoteFeeAmount": { "format": "decimal", - "type": "number" + "type": "string" }, "lowerBinId": { "type": "number" @@ -1199,15 +1205,15 @@ }, "lowerPrice": { "format": "decimal", - "type": "number" + "type": "string" }, "upperPrice": { "format": "decimal", - "type": "number" + "type": "string" }, "price": { "format": "decimal", - "type": "number" + "type": "string" } }, "required": [ @@ -1250,7 +1256,7 @@ "properties": { "fee": { "format": "decimal", - "type": "number" + "type": "string" }, "poolAddress": { "description": "Pool this operation acted on", @@ -1261,15 +1267,15 @@ }, "positionRent": { "format": "decimal", - "type": "number" + "type": "string" }, "baseTokenAmountAdded": { "format": "decimal", - "type": "number" + "type": "string" }, "quoteTokenAmountAdded": { "format": "decimal", - "type": "number" + "type": "string" } }, "required": [ @@ -1304,7 +1310,7 @@ "properties": { "fee": { "format": "decimal", - "type": "number" + "type": "string" }, "poolAddress": { "description": "Pool this operation acted on", @@ -1316,11 +1322,11 @@ }, "baseTokenAmountAdded": { "format": "decimal", - "type": "number" + "type": "string" }, "quoteTokenAmountAdded": { "format": "decimal", - "type": "number" + "type": "string" } }, "required": [ @@ -1353,7 +1359,7 @@ "properties": { "fee": { "format": "decimal", - "type": "number" + "type": "string" }, "poolAddress": { "description": "Pool this operation acted on", @@ -1365,11 +1371,11 @@ }, "baseTokenAmountRemoved": { "format": "decimal", - "type": "number" + "type": "string" }, "quoteTokenAmountRemoved": { "format": "decimal", - "type": "number" + "type": "string" } }, "required": [ @@ -1402,7 +1408,7 @@ "properties": { "fee": { "format": "decimal", - "type": "number" + "type": "string" }, "poolAddress": { "description": "Pool this operation acted on", @@ -1414,11 +1420,11 @@ }, "baseFeeAmountCollected": { "format": "decimal", - "type": "number" + "type": "string" }, "quoteFeeAmountCollected": { "format": "decimal", - "type": "number" + "type": "string" } }, "required": [ @@ -1451,7 +1457,7 @@ "properties": { "fee": { "format": "decimal", - "type": "number" + "type": "string" }, "poolAddress": { "description": "Pool this operation acted on", @@ -1463,23 +1469,23 @@ }, "positionRentRefunded": { "format": "decimal", - "type": "number" + "type": "string" }, "baseTokenAmountRemoved": { "format": "decimal", - "type": "number" + "type": "string" }, "quoteTokenAmountRemoved": { "format": "decimal", - "type": "number" + "type": "string" }, "baseFeeAmountCollected": { "format": "decimal", - "type": "number" + "type": "string" }, "quoteFeeAmountCollected": { "format": "decimal", - "type": "number" + "type": "string" } }, "required": [ @@ -1508,7 +1514,7 @@ "price": { "format": "decimal", "description": "Initial price the pool was initialized at (quote per base)", - "type": "number" + "type": "string" }, "data": { "$ref": "#/components/schemas/ClmmCreatePoolResponseData" @@ -1525,7 +1531,7 @@ "properties": { "fee": { "format": "decimal", - "type": "number" + "type": "string" } }, "required": [ @@ -1544,19 +1550,19 @@ }, "baseTokenAmount": { "format": "decimal", - "type": "number" + "type": "string" }, "quoteTokenAmount": { "format": "decimal", - "type": "number" + "type": "string" }, "baseTokenAmountMax": { "format": "decimal", - "type": "number" + "type": "string" }, "quoteTokenAmountMax": { "format": "decimal", - "type": "number" + "type": "string" }, "liquidity": {} }, @@ -1569,6 +1575,7 @@ ] }, "AmmCreatePoolRequest": { + "additionalProperties": false, "type": "object", "properties": { "connector": { @@ -1579,7 +1586,6 @@ "uniswap", "pancakeswap" ], - "default": "meteora", "type": "string", "example": "meteora" }, @@ -1666,6 +1672,7 @@ ] }, "AmmAddRequest": { + "additionalProperties": false, "type": "object", "properties": { "connector": { @@ -1676,7 +1683,6 @@ "uniswap", "pancakeswap" ], - "default": "meteora", "type": "string", "example": "meteora" }, @@ -1747,6 +1753,7 @@ ] }, "AmmRemoveRequest": { + "additionalProperties": false, "type": "object", "properties": { "connector": { @@ -1757,7 +1764,6 @@ "uniswap", "pancakeswap" ], - "default": "meteora", "type": "string", "example": "meteora" }, @@ -1826,6 +1832,7 @@ ] }, "AmmPoolInfoRequest": { + "additionalProperties": false, "type": "object", "properties": { "connector": { @@ -1874,6 +1881,7 @@ ] }, "AmmPositionInfoRequest": { + "additionalProperties": false, "type": "object", "properties": { "connector": { @@ -1928,6 +1936,7 @@ ] }, "AmmPositionsOwnedRequest": { + "additionalProperties": false, "type": "object", "properties": { "connector": { @@ -1977,6 +1986,7 @@ ] }, "AmmQuoteLiquidityRequest": { + "additionalProperties": false, "type": "object", "properties": { "connector": { @@ -2045,6 +2055,7 @@ ] }, "ClmmOpenRequest": { + "additionalProperties": false, "type": "object", "properties": { "connector": { @@ -2057,7 +2068,6 @@ "uniswap", "pancakeswap" ], - "default": "meteora", "type": "string", "example": "meteora" }, @@ -2144,6 +2154,7 @@ ] }, "ClmmAddRequest": { + "additionalProperties": false, "type": "object", "properties": { "connector": { @@ -2156,7 +2167,6 @@ "uniswap", "pancakeswap" ], - "default": "meteora", "type": "string", "example": "meteora" }, @@ -2229,6 +2239,7 @@ ] }, "ClmmRemoveRequest": { + "additionalProperties": false, "type": "object", "properties": { "connector": { @@ -2241,7 +2252,6 @@ "uniswap", "pancakeswap" ], - "default": "meteora", "type": "string", "example": "meteora" }, @@ -2304,6 +2314,7 @@ ] }, "ClmmCollectFeesRequest": { + "additionalProperties": false, "type": "object", "properties": { "connector": { @@ -2316,7 +2327,6 @@ "uniswap", "pancakeswap" ], - "default": "meteora", "type": "string", "example": "meteora" }, @@ -2361,6 +2371,7 @@ ] }, "ClmmCloseRequest": { + "additionalProperties": false, "type": "object", "properties": { "connector": { @@ -2373,7 +2384,6 @@ "uniswap", "pancakeswap" ], - "default": "meteora", "type": "string", "example": "meteora" }, @@ -2426,6 +2436,7 @@ ] }, "ClmmCreatePoolRequest": { + "additionalProperties": false, "type": "object", "properties": { "connector": { @@ -2438,7 +2449,6 @@ "uniswap", "pancakeswap" ], - "default": "meteora", "type": "string", "example": "meteora" }, @@ -2515,6 +2525,7 @@ ] }, "ClmmFetchPoolsRequest": { + "additionalProperties": false, "type": "object", "properties": { "chainNetwork": { @@ -2606,6 +2617,7 @@ ] }, "ClmmPoolInfoRequest": { + "additionalProperties": false, "type": "object", "properties": { "connector": { @@ -2664,6 +2676,7 @@ ] }, "ClmmPositionInfoRequest": { + "additionalProperties": false, "type": "object", "properties": { "connector": { @@ -2715,6 +2728,7 @@ ] }, "ClmmPositionsOwnedRequest": { + "additionalProperties": false, "type": "object", "properties": { "connector": { @@ -2766,6 +2780,7 @@ ] }, "ClmmQuoteLiquidityRequest": { + "additionalProperties": false, "type": "object", "properties": { "connector": { @@ -2851,6 +2866,7 @@ ] }, "RouterExecuteQuoteRequest": { + "additionalProperties": false, "type": "object", "properties": { "chainNetwork": { @@ -2886,7 +2902,6 @@ "pancakeswap", "0x" ], - "default": "jupiter", "type": "string", "example": "jupiter" }, @@ -2907,6 +2922,7 @@ ] }, "RouterExecuteSwapRequest": { + "additionalProperties": false, "type": "object", "properties": { "chainNetwork": { @@ -2942,7 +2958,6 @@ "pancakeswap", "0x" ], - "default": "jupiter", "type": "string", "example": "jupiter" }, @@ -3006,6 +3021,7 @@ ] }, "RouterQuoteSwapRequest": { + "additionalProperties": false, "type": "object", "properties": { "chainNetwork": { @@ -3041,7 +3057,6 @@ "pancakeswap", "0x" ], - "default": "jupiter", "type": "string", "example": "jupiter" }, @@ -3458,6 +3473,7 @@ ] }, "AllowancesRequest": { + "additionalProperties": false, "type": "object", "properties": { "network": { @@ -3525,6 +3541,7 @@ ] }, "ApproveRequest": { + "additionalProperties": false, "type": "object", "properties": { "network": { @@ -3619,6 +3636,7 @@ ] }, "RemoveWalletRequest": { + "additionalProperties": false, "type": "object", "properties": { "chain": { @@ -3641,6 +3659,7 @@ ] }, "AddHardwareWalletRequest": { + "additionalProperties": false, "type": "object", "properties": { "chain": { @@ -5231,7 +5250,7 @@ }, "feePct": { "format": "decimal", - "type": "number" + "type": "string" }, "address": { "type": "string" @@ -5363,7 +5382,7 @@ }, "feePct": { "format": "decimal", - "type": "number" + "type": "string" }, "address": { "type": "string" @@ -5583,7 +5602,7 @@ }, "feePct": { "format": "decimal", - "type": "number" + "type": "string" }, "address": { "type": "string" @@ -5757,7 +5776,7 @@ }, "feePct": { "format": "decimal", - "type": "number" + "type": "string" }, "address": { "type": "string" @@ -6030,7 +6049,7 @@ }, "feePct": { "format": "decimal", - "type": "number" + "type": "string" }, "address": { "type": "string" @@ -6218,7 +6237,6 @@ "pancakeswap", "0x" ], - "default": "jupiter", "type": "string" }, "example": "jupiter", diff --git a/models/gateway_generated.py b/models/gateway_generated.py index 743c2218..399e807a 100644 --- a/models/gateway_generated.py +++ b/models/gateway_generated.py @@ -6,7 +6,7 @@ from decimal import Decimal from typing import Any -from pydantic import BaseModel, Field, condecimal, confloat, conint +from pydantic import BaseModel, ConfigDict, Field, condecimal, confloat, conint class AmmPoolInfo(BaseModel): @@ -72,6 +72,9 @@ class AmmPositionInfo(BaseModel): class EstimateGasRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) network: str | None = Field(None, description="Network to use. Defaults to the chain's configured default network.", examples=['mainnet-beta']) @@ -90,6 +93,9 @@ class EstimateGasResponse(BaseModel): class BalanceRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) network: str | None = Field(None, description="Network to use. Defaults to the chain's configured default network.", examples=['mainnet-beta']) address: str | None = None tokens: list[str] | None = Field(None, description='a list of token symbols or addresses') @@ -101,6 +107,9 @@ class BalanceResponse(BaseModel): class PollRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) network: str | None = Field(None, description="Network to use. Defaults to the chain's configured default network.", examples=['mainnet-beta']) signature: str = Field(..., description='Transaction signature/hash') @@ -116,6 +125,9 @@ class PollResponse(BaseModel): class StatusRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) network: str | None = Field(None, description="Network to use. Defaults to the chain's configured default network.", examples=['mainnet-beta']) @@ -156,12 +168,18 @@ class ChainExecuteSwapResponseData(BaseModel): class WrapRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) network: str | None = Field(None, description="Network to use. Defaults to the chain's configured default network.", examples=['mainnet-beta']) address: str = Field(..., description='Wallet address holding the native token') amount: str = Field(..., description='Amount of the native token to wrap, in whole units (not lamports/wei)', examples=['1.0']) class UnwrapRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) network: str | None = Field(None, description="Network to use. Defaults to the chain's configured default network.", examples=['mainnet-beta']) address: str = Field(..., description='Wallet address holding the wrapped token') amount: str | None = Field(None, description='Amount of the wrapped token to unwrap, in whole units. Solana unwraps the full balance when omitted; EVM chains require it.', examples=['1.0']) @@ -311,6 +329,9 @@ class ClmmQuoteLiquidityResponse(BaseModel): class AmmCreatePoolRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) connector: str = Field(..., description='AMM connector', examples=['meteora']) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) wallet_address: str = Field(..., alias='walletAddress', description='Wallet address (pool creator + payer)') @@ -325,6 +346,9 @@ class AmmCreatePoolRequest(BaseModel): class AmmAddRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) connector: str = Field(..., description='AMM connector', examples=['meteora']) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) wallet_address: str = Field(..., alias='walletAddress', description='Wallet address') @@ -336,6 +360,9 @@ class AmmAddRequest(BaseModel): class AmmRemoveRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) connector: str = Field(..., description='AMM connector', examples=['meteora']) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) wallet_address: str = Field(..., alias='walletAddress', description='Wallet address') @@ -346,12 +373,18 @@ class AmmRemoveRequest(BaseModel): class AmmPoolInfoRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) connector: str = Field(..., description='AMM connector', examples=['meteora']) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) pool_address: str = Field(..., alias='poolAddress', description='Pool contract address') class AmmPositionInfoRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) connector: str = Field(..., description='AMM connector', examples=['meteora']) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) pool_address: str = Field(..., alias='poolAddress', description='Pool contract address') @@ -359,12 +392,18 @@ class AmmPositionInfoRequest(BaseModel): class AmmPositionsOwnedRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) connector: str = Field(..., description='AMM connector (only non-fungible-LP AMMs supported: meteora)', examples=['meteora']) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) wallet_address: str = Field(..., alias='walletAddress', description='Wallet address to list positions for') class AmmQuoteLiquidityRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) connector: str = Field(..., description='AMM connector', examples=['meteora']) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) pool_address: str = Field(..., alias='poolAddress', description='Pool contract address') @@ -374,6 +413,9 @@ class AmmQuoteLiquidityRequest(BaseModel): class ClmmOpenRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) connector: str = Field(..., description='CLMM connector', examples=['meteora']) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) wallet_address: str = Field(..., alias='walletAddress', description='Wallet address') @@ -387,6 +429,9 @@ class ClmmOpenRequest(BaseModel): class ClmmAddRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) connector: str = Field(..., description='CLMM connector', examples=['meteora']) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) wallet_address: str = Field(..., alias='walletAddress', description='Wallet address') @@ -398,6 +443,9 @@ class ClmmAddRequest(BaseModel): class ClmmRemoveRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) connector: str = Field(..., description='CLMM connector', examples=['meteora']) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) wallet_address: str = Field(..., alias='walletAddress', description='Wallet address') @@ -407,6 +455,9 @@ class ClmmRemoveRequest(BaseModel): class ClmmCollectFeesRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) connector: str = Field(..., description='CLMM connector', examples=['meteora']) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) wallet_address: str = Field(..., alias='walletAddress', description='Wallet address') @@ -414,6 +465,9 @@ class ClmmCollectFeesRequest(BaseModel): class ClmmCloseRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) connector: str = Field(..., description='CLMM connector', examples=['meteora']) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) wallet_address: str = Field(..., alias='walletAddress', description='Wallet address') @@ -422,6 +476,9 @@ class ClmmCloseRequest(BaseModel): class ClmmCreatePoolRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) connector: str = Field(..., description='CLMM connector', examples=['meteora']) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) wallet_address: str = Field(..., alias='walletAddress', description='Wallet address (pool creator + payer)') @@ -434,6 +491,9 @@ class ClmmCreatePoolRequest(BaseModel): class ClmmFetchPoolsRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) connector: str = Field(..., description='CLMM connector whose pool-discovery API to query', examples=['meteora']) limit: confloat(ge=1.0, le=1000.0) | None = Field(50, description='Maximum number of pools to return') @@ -446,6 +506,9 @@ class ClmmFetchPoolsRequest(BaseModel): class ClmmPoolInfoRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) connector: str = Field(..., description='CLMM connector', examples=['meteora']) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) pool_address: str = Field(..., alias='poolAddress', description='Pool contract address', examples=['2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3']) @@ -453,18 +516,27 @@ class ClmmPoolInfoRequest(BaseModel): class ClmmPositionInfoRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) connector: str = Field(..., description='CLMM connector', examples=['meteora']) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) position_address: str = Field(..., alias='positionAddress', description='Position address or NFT token ID', examples=['']) class ClmmPositionsOwnedRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) connector: str = Field(..., description='CLMM connector', examples=['meteora']) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) wallet_address: str = Field(..., alias='walletAddress', description='Wallet address') class ClmmQuoteLiquidityRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) connector: str = Field(..., description='CLMM connector', examples=['meteora']) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) lower_price: Decimal = Field(..., alias='lowerPrice', description='Lower price bound for the position', examples=[150]) @@ -476,15 +548,21 @@ class ClmmQuoteLiquidityRequest(BaseModel): class RouterExecuteQuoteRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) - connector: str | None = Field('jupiter', description="Router connector. Defaults to the network's swapProvider", examples=['jupiter']) + connector: str | None = Field(None, description="Router connector. Defaults to the network's swapProvider", examples=['jupiter']) wallet_address: str = Field(..., alias='walletAddress', description='Wallet address that will execute the quote') quote_id: str = Field(..., alias='quoteId', description='ID of a quote returned by /trading/router/quote-swap') class RouterExecuteSwapRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) - connector: str | None = Field('jupiter', description="Router connector. Defaults to the network's swapProvider", examples=['jupiter']) + connector: str | None = Field(None, description="Router connector. Defaults to the network's swapProvider", examples=['jupiter']) wallet_address: str = Field(..., alias='walletAddress', description='Wallet address that will execute the swap') base_token: str = Field(..., alias='baseToken', description='Symbol or address of the base token') quote_token: str = Field(..., alias='quoteToken', description='Symbol or address of the quote token') @@ -495,8 +573,11 @@ class RouterExecuteSwapRequest(BaseModel): class RouterQuoteSwapRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) chain_network: str = Field(..., alias='chainNetwork', description='Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', examples=['solana-mainnet-beta']) - connector: str | None = Field('jupiter', description="Router connector. Defaults to the network's swapProvider", examples=['jupiter']) + connector: str | None = Field(None, description="Router connector. Defaults to the network's swapProvider", examples=['jupiter']) base_token: str = Field(..., alias='baseToken', description='Symbol or address of the base token') quote_token: str = Field(..., alias='quoteToken', description='Symbol or address of the quote token') amount: Decimal = Field(..., description='Amount of base token to trade') @@ -554,6 +635,9 @@ class ClmmExecuteSwapRequest(BaseModel): class AllowancesRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) network: str | None = Field('mainnet', description='The Ethereum network to use') address: str | None = Field('', description='Ethereum wallet address') spender: str = Field(..., description='Connector name (e.g., uniswap/clmm, uniswap/amm, 0x/router) or contract address', examples=['uniswap/router']) @@ -566,6 +650,9 @@ class AllowancesResponse(BaseModel): class ApproveRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) network: str | None = Field('mainnet', description='The Ethereum network to use') address: str | None = Field('', description='Ethereum wallet address') spender: str = Field(..., description='Connector name (e.g., uniswap/clmm, uniswap/amm, 0x/router) contract address', examples=['uniswap/router']) @@ -582,11 +669,17 @@ class ApproveResponseData(BaseModel): class RemoveWalletRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) chain: str = Field(..., description='Blockchain to remove wallet from', examples=['solana']) address: str = Field(..., description='Wallet address to remove') class AddHardwareWalletRequest(BaseModel): + model_config = ConfigDict( + extra='forbid', + ) chain: str = Field(..., description='Blockchain for hardware wallet', examples=['solana']) address: str = Field(..., description='Hardware wallet address to add (must exist on connected Ledger device)') set_default: bool | None = Field(False, alias='setDefault', description='Set this wallet as the default for the chain') diff --git a/services/gateway_client.py b/services/gateway_client.py index 859b4d07..e5c842d2 100644 --- a/services/gateway_client.py +++ b/services/gateway_client.py @@ -651,6 +651,9 @@ async def quote_swap( "across pools rather than executing against one. Name an amm or clmm " "connector to pin a pool." ) + # poolAddress only for the pool-scoped models: the router model does not declare + # it, and Gateway now rejects an undeclared key rather than dropping it. + pool_kwargs = {} if trading_type == "router" else {"poolAddress": pool_address or None} params = _query( request_model( chainNetwork=chain_network, @@ -660,7 +663,7 @@ async def quote_swap( amount=amount, side=side.upper(), slippagePct=slippage_pct, - poolAddress=pool_address or None, + **pool_kwargs, ) ) if extra_params: @@ -731,6 +734,8 @@ async def execute_swap( "across pools rather than executing against one. Name an amm or clmm " "connector to pin a pool." ) + # See quote_swap: the router model does not declare poolAddress. + pool_kwargs = {} if trading_type == "router" else {"poolAddress": pool_address or None} payload = _body( _EXECUTE_SWAP_REQUESTS[trading_type]( chainNetwork=chain_network, @@ -741,7 +746,7 @@ async def execute_swap( amount=amount, side=side.upper(), slippagePct=slippage_pct, - poolAddress=pool_address or None, + **pool_kwargs, ) ) if extra_params: From 086720b2850dc7d03d6bdbfb77815e8a7ad840a6 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Thu, 20 Aug 2026 16:47:40 -0700 Subject: [PATCH 39/54] fix(clmm): record the rent an executor's position locks, and gets back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gateway_clmm_positions.position_rent is written by the OPEN route and position_rent_refunded by CLOSE. An executor holds its position through the wheel, talking to Gateway directly, so neither route runs: the poller discovers the position and files it with both columns NULL, and the close leaves no refund behind either. The table answered a rent question correctly for the hand-driven path and not at all for the recommended one — on ~0.0100572 SOL per Orca position, more than the liquidity in a small one. The executor knew both figures the whole time; nothing was asking it. Locked rent is now recorded from the control loop as soon as the position row exists, not at completion, so a position held for days is answerable while it is held. The refund is recorded at completion, because that is when the close confirms — and a successful close clears position_address from custom_info first, so the address is remembered while the executor is live or there is nothing left to file the refund under. Zero is never stored. The executor defaults both figures to 0.0, so zero means "never measured" far more often than "measured and empty": an open position has no refund yet, and an EVM CLMM has no rent at all. A stored 0.0 claims an observation that nothing downstream can tell from the real thing, which is exactly GW-18's defect. NULL is the honest answer. Only NULL columns are filled, so a figure a route read off its own transaction is never replaced, and the write is idempotent. The control loop now iterates a snapshot of _active_executors: recording awaits a database round trip, and create_executor runs in a request task that can add to the dict meanwhile. Iterating it live raises "dictionary changed size during iteration" — which the loop's own broad except swallows into a log line, silently skipping completion handling for every executor after the one that raced. Residual: a position created outside the API entirely has no executor to ask, and its locked rent is in an open transaction nothing kept. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK --- .../repositories/gateway_clmm_repository.py | 37 ++ services/executor_service.py | 134 +++++- test/test_executor_position_rent.py | 383 ++++++++++++++++++ 3 files changed, 551 insertions(+), 3 deletions(-) create mode 100644 test/test_executor_position_rent.py diff --git a/database/repositories/gateway_clmm_repository.py b/database/repositories/gateway_clmm_repository.py index 9003cfb2..2861fb3b 100644 --- a/database/repositories/gateway_clmm_repository.py +++ b/database/repositories/gateway_clmm_repository.py @@ -103,6 +103,43 @@ async def close_position( await self.session.flush() return position + async def record_position_rent( + self, + position_address: str, + position_rent: Optional[Decimal] = None, + position_rent_refunded: Optional[Decimal] = None, + ) -> Optional[GatewayCLMMPosition]: + """Fill in rent figures the write routes never saw, without disturbing ones they did. + + Both columns are written by hummingbot-api's own routes: `position_rent` by OPEN, + `position_rent_refunded` by CLOSE. A position an executor opened talks to Gateway + directly through the wheel, so neither route ran and both columns stay NULL — on + the workflow that is actually recommended for holding a position. Rent is + ~0.0100572 SOL on Orca, larger than the liquidity on a small position. + + Only NULL columns are filled. A figure already recorded came from the transaction + that produced it and is not replaced by one carried in from elsewhere, and a + second call cannot rewrite what the first stored. + + Callers must not pass zero for an unmeasured figure. NULL means "no rent was + observed", which is the truth both for a reading that never happened and for a + chain without rent at all; a stored 0.0 claims a measurement was taken and came + back empty, which nothing downstream can tell from the real thing (see GW-18). + """ + result = await self.session.execute( + select(GatewayCLMMPosition).where(GatewayCLMMPosition.position_address == position_address) + ) + position = result.scalar_one_or_none() + if position is None: + return None + + if position_rent is not None and position.position_rent is None: + position.position_rent = position_rent + if position_rent_refunded is not None and position.position_rent_refunded is None: + position.position_rent_refunded = position_rent_refunded + await self.session.flush() + return position + async def reopen_position(self, position_address: str) -> Optional[GatewayCLMMPosition]: """ Reopen a position that was incorrectly marked as closed. diff --git a/services/executor_service.py b/services/executor_service.py index ef91ff7a..eac4211e 100644 --- a/services/executor_service.py +++ b/services/executor_service.py @@ -6,6 +6,7 @@ import asyncio import json import logging +import time from datetime import datetime, timezone from decimal import Decimal from enum import Enum @@ -32,7 +33,7 @@ from hummingbot.strategy_v2.executors.xemm_executor.xemm_executor import XEMMExecutor from hummingbot.strategy_v2.models.executors import CloseType, TrackedOrder -from database import AsyncDatabaseManager, ExecutorRepository +from database import AsyncDatabaseManager, ExecutorRepository, GatewayCLMMRepository from models.executors import PositionHold from services.trading_service import AccountTradingInterface, TradingService from utils.executor_log_capture import ExecutorLogCapture, current_executor_id @@ -110,6 +111,10 @@ class ExecutorService: - Database persistence of executor state and history """ + # How long to wait before looking again for a position row that was not there. The + # poller creates it on discovery, which is far slower than the control loop's tick. + LP_RENT_RETRY_SECONDS = 30.0 + # Mapping of executor type strings to (executor_class, config_class) EXECUTOR_REGISTRY: Dict[str, tuple[Type[ExecutorBase], Type[ExecutorConfigBase]]] = { "position_executor": (PositionExecutor, PositionExecutorConfig), @@ -163,6 +168,19 @@ def __init__( self._log_capture = ExecutorLogCapture() self._log_capture.install() + # An LP executor's position address, learned while the executor is live. A + # successful close clears it from custom_info before the executor terminates, so + # by completion — which is when the rent refund is finally known — there is + # nothing left to file it under. See _record_lp_position_rent. + self._lp_position_addresses: Dict[str, str] = {} + # Executors whose locked rent is already stored, so the control loop stops + # re-reading and re-writing a figure that cannot change. + self._lp_rent_recorded: set = set() + # Earliest monotonic time to retry an executor whose position row did not exist + # yet. Discovery runs on its own schedule, so retrying at the control loop's 1 Hz + # would be a query a second against a row that appears about once a minute. + self._lp_rent_retry_after: Dict[str, float] = {} + # Control loop task self._control_loop_task: Optional[asyncio.Task] = None self._is_running = False @@ -298,11 +316,23 @@ async def _control_loop(self): # Update timestamps for all trading interfaces via TradingService self._trading_service.update_all_timestamps() - # Check for completed executors + # Check for completed executors. Iterate a snapshot: the rent recording + # below awaits, and create_executor runs in a request task that can add to + # or remove from _active_executors while this loop is suspended. completed_ids = [] - for executor_id, executor in self._active_executors.items(): + for executor_id, executor in list(self._active_executors.items()): if executor.is_closed: completed_ids.append(executor_id) + elif ( + self._executor_metadata.get(executor_id, {}).get("executor_type") == "lp_executor" + and executor_id not in self._lp_rent_recorded + and time.monotonic() >= self._lp_rent_retry_after.get(executor_id, 0.0) + ): + # Locked rent, stored while the position is still held rather than + # at completion — a position open for days should be answerable + # for the whole time it is open. Drops out of this branch as soon + # as it lands, so it is one read per executor, not one per tick. + await self._record_lp_position_rent(executor_id, executor) # Handle completed executors for executor_id in completed_ids: @@ -313,6 +343,95 @@ async def _control_loop(self): await asyncio.sleep(self.update_interval) + @staticmethod + def _measured_rent(custom_info: Dict[str, Any], key: str) -> Optional[Decimal]: + """A rent figure the executor actually observed, or None. + + The LP executor reports these as plain floats and defaults them to 0.0, so zero + means "never measured" far more often than it means "measured and empty": a + position still open has no refund yet, and an EVM CLMM has no rent at all. Rent + that was genuinely locked is never zero. Storing the 0.0 would put a figure in + the table that reads as an observation and is not one — the mistake GW-18 made, + where a hardcoded 0 refund was indistinguishable from a position that earned + nothing. + """ + value = custom_info.get(key) + if value is None: + return None + try: + amount = Decimal(str(value)) + except (ArithmeticError, ValueError): + return None + return amount if amount > 0 else None + + async def _record_lp_position_rent(self, executor_id: str, executor: ExecutorBase) -> None: + """Carry an LP executor's rent figures into the CLMM position table. + + `gateway_clmm_positions.position_rent` is written by hummingbot-api's OPEN route + and `position_rent_refunded` by its CLOSE route. An executor holds its position + through the wheel, talking to Gateway directly, so neither route runs: the poller + discovers the position and files it with both columns NULL, and the close leaves + no refund behind either. That is the recommended way to hold a CLMM position and + the one path whose rent went unrecorded — ~0.0100572 SOL on Orca, more than the + liquidity in a small position. + + The executor knows both figures; nothing was asking it. Locked rent is written as + soon as the position row exists, rather than at completion, so a position held for + days is answerable while it is held. The row may not exist yet on an early tick — + discovery runs on its own schedule — in which case this is a no-op and the next + tick tries again. + """ + try: + custom_info = executor.get_custom_info() + except Exception as e: + logger.debug(f"Could not read custom_info for {executor_id} while recording rent: {e}") + return + + position_address = custom_info.get("position_address") + if position_address: + self._lp_position_addresses[executor_id] = position_address + else: + # Cleared by a successful close. The refund is only known now, so fall back to + # the address this executor was holding. + position_address = self._lp_position_addresses.get(executor_id) + if not position_address: + return + + position_rent = self._measured_rent(custom_info, "position_rent") + position_rent_refunded = self._measured_rent(custom_info, "position_rent_refunded") + if position_rent is None and position_rent_refunded is None: + return + + try: + async with self.db_manager.get_session_context() as session: + position = await GatewayCLMMRepository(session).record_position_rent( + position_address, + position_rent=position_rent, + position_rent_refunded=position_rent_refunded, + ) + except Exception as e: + logger.error(f"Error recording rent for LP position {position_address}: {e}", exc_info=True) + return + + if position is None: + self._lp_rent_retry_after[executor_id] = time.monotonic() + self.LP_RENT_RETRY_SECONDS + # Only worth a warning once the figure is final: while the executor runs, the + # poller has simply not discovered the position yet and a later tick retries. + if position_rent_refunded is not None: + logger.warning( + f"LP executor {executor_id} closed position {position_address} with a rent " + f"refund of {position_rent_refunded}, but no row exists for it — the poller " + "never discovered the position, so the refund has nowhere to be recorded." + ) + return + + if position_rent is not None: + self._lp_rent_recorded.add(executor_id) + logger.debug( + f"Recorded rent for LP position {position_address}: locked={position_rent}, " + f"refunded={position_rent_refunded}" + ) + def _get_trading_interface(self, account_name: str) -> AccountTradingInterface: """Get or create an AccountTradingInterface for the account.""" if account_name not in self._trading_interfaces: @@ -816,6 +935,15 @@ async def _handle_executor_completion(self, executor_id: str): # Persist final state to database await self._persist_executor_completed(executor_id, executor) + # The rent refund is only known once the close confirms, which is here. A + # successful close has already cleared position_address from custom_info, so this + # relies on the address remembered while the executor was live. + if metadata.get("executor_type") == "lp_executor": + await self._record_lp_position_rent(executor_id, executor) + self._lp_position_addresses.pop(executor_id, None) + self._lp_rent_recorded.discard(executor_id) + self._lp_rent_retry_after.pop(executor_id, None) + # Active executor already claimed via pop above; drop its metadata last # (metadata is read above and re-fetched inside the persist/aggregate # helpers, so it must stay until after those awaits complete). diff --git a/test/test_executor_position_rent.py b/test/test_executor_position_rent.py new file mode 100644 index 00000000..62a3c6de --- /dev/null +++ b/test/test_executor_position_rent.py @@ -0,0 +1,383 @@ +"""An LP executor's rent must reach the position table (GW-41). + +`gateway_clmm_positions.position_rent` is written by hummingbot-api's OPEN route and +`position_rent_refunded` by its CLOSE route. An executor holds its position through the +wheel, talking to Gateway directly, so neither route runs: the poller discovers the +position and files it with both columns NULL, and the close leaves no refund behind +either. Live table before the fix: + + position events rent refunded + B7nHjtVByQ DISCOVERED NULL NULL <- executor + 4G5GyCPi9U CLOSE,COLLECT_FEES,DISCOVERED NULL 0.0100572 + 9RdCMFFvFU ADD_LIQUIDITY,CLOSE,COLLECT_FEES,OPEN 0.0100572 0.0100572 <- routes + +So the table answered a rent question correctly for the hand-driven path and not at all +for the recommended one, on a figure (~0.0100572 SOL per Orca position) that is larger +than the liquidity in a small position. +""" +from contextlib import asynccontextmanager +from decimal import Decimal +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +pytest.importorskip("hummingbot") + +from database.repositories.gateway_clmm_repository import GatewayCLMMRepository # noqa: E402 +from services.executor_service import ExecutorService # noqa: E402 + +ORCA_RENT = Decimal("0.0100572") + + +# -------------------------------------------------------------------------------------- +# The repository: fill a NULL, never overwrite a measurement +# -------------------------------------------------------------------------------------- + +class _Session: + """Just enough AsyncSession for record_position_rent: one lookup and a flush.""" + + def __init__(self, position): + self._position = position + self.flushed = False + + async def execute(self, _statement): + return SimpleNamespace(scalar_one_or_none=lambda: self._position) + + async def flush(self): + self.flushed = True + + +def _position(rent=None, refunded=None): + return SimpleNamespace(position_rent=rent, position_rent_refunded=refunded) + + +@pytest.mark.asyncio +async def test_it_fills_both_columns_when_the_row_has_neither(): + """The executor-lifecycle row: DISCOVERED, both columns NULL.""" + position = _position() + repo = GatewayCLMMRepository(_Session(position)) + + await repo.record_position_rent("B7nHjtVByQ", position_rent=ORCA_RENT, position_rent_refunded=ORCA_RENT) + + assert position.position_rent == ORCA_RENT + assert position.position_rent_refunded == ORCA_RENT + + +@pytest.mark.asyncio +async def test_it_does_not_overwrite_a_figure_the_routes_already_recorded(): + """A route read its figure off the transaction that produced it. That one wins. + + Not a stylistic preference: it also makes the call idempotent, which matters because + the control loop can reach the same position more than once. + """ + from_the_transaction = Decimal("0.00337584") + position = _position(rent=from_the_transaction, refunded=from_the_transaction) + repo = GatewayCLMMRepository(_Session(position)) + + await repo.record_position_rent("9RdCMFFvFU", position_rent=ORCA_RENT, position_rent_refunded=ORCA_RENT) + + assert position.position_rent == from_the_transaction + assert position.position_rent_refunded == from_the_transaction + + +@pytest.mark.asyncio +async def test_it_fills_the_missing_half_of_a_partly_route_driven_position(): + """4G5GyCPi9U: discovered, then closed through the route. Refund known, rent not.""" + position = _position(refunded=ORCA_RENT) + repo = GatewayCLMMRepository(_Session(position)) + + await repo.record_position_rent("4G5GyCPi9U", position_rent=ORCA_RENT) + + assert position.position_rent == ORCA_RENT + assert position.position_rent_refunded == ORCA_RENT + + +@pytest.mark.asyncio +async def test_a_row_that_does_not_exist_yet_is_not_an_error(): + """Discovery runs on its own schedule; the position may simply not be filed yet.""" + repo = GatewayCLMMRepository(_Session(None)) + + assert await repo.record_position_rent("nothere", position_rent=ORCA_RENT) is None + + +# -------------------------------------------------------------------------------------- +# The zero trap +# -------------------------------------------------------------------------------------- + +@pytest.mark.parametrize("value", [0, 0.0, "0", Decimal("0"), None, "", "not-a-number"]) +def test_an_unmeasured_figure_is_never_stored(value): + """The LP executor defaults both to 0.0, so zero means "never measured" far more + often than "measured and empty" — a position still open has no refund yet, and an EVM + CLMM has no rent at all. Storing the 0.0 is precisely GW-18's defect: a hardcoded zero + is worse than a NULL, because nothing downstream can tell it from an observation. + """ + assert ExecutorService._measured_rent({"position_rent": value}, "position_rent") is None + + +def test_a_real_reading_survives_the_float_it_arrives_as(): + assert ExecutorService._measured_rent({"position_rent": 0.0100572}, "position_rent") == ORCA_RENT + + +# -------------------------------------------------------------------------------------- +# The service: getting the figures from a live executor to the row +# -------------------------------------------------------------------------------------- + +class _RecordingRepo: + """Captures record_position_rent calls; stands in for the whole DB layer.""" + + def __init__(self, found=True): + self.calls = [] + self._found = found + + async def record_position_rent(self, position_address, position_rent=None, position_rent_refunded=None): + self.calls.append((position_address, position_rent, position_rent_refunded)) + return object() if self._found else None + + +def _service(repo): + service = ExecutorService.__new__(ExecutorService) + service._lp_position_addresses = {} + service._lp_rent_recorded = set() + service._lp_rent_retry_after = {} + + db_manager = MagicMock() + + @asynccontextmanager + async def session_context(): + yield MagicMock() + + db_manager.get_session_context = session_context + service.db_manager = db_manager + + import services.executor_service as module + module.GatewayCLMMRepository = lambda _session: repo + return service + + +@pytest.fixture(autouse=True) +def _restore_repository(): + import services.executor_service as module + original = module.GatewayCLMMRepository + yield + module.GatewayCLMMRepository = original + + +def _executor(**custom_info): + executor = MagicMock() + executor.get_custom_info = MagicMock(return_value=custom_info) + return executor + + +@pytest.mark.asyncio +async def test_a_live_executors_locked_rent_reaches_the_row(): + repo = _RecordingRepo() + service = _service(repo) + + await service._record_lp_position_rent( + "e-1", _executor(position_address="B7nHjtVByQ", position_rent=0.0100572, position_rent_refunded=0.0) + ) + + assert repo.calls == [("B7nHjtVByQ", ORCA_RENT, None)] + # Recorded, so the control loop stops asking. + assert "e-1" in service._lp_rent_recorded + + +@pytest.mark.asyncio +async def test_the_refund_is_filed_under_the_address_the_close_cleared(): + """A successful close sets position_address to None BEFORE the executor terminates, + and the refund is only known at that point. Without the address remembered while the + executor was live there is nothing to file the refund under — which is the second + half of GW-41, not a detail. + """ + repo = _RecordingRepo() + service = _service(repo) + + live = _executor(position_address="B7nHjtVByQ", position_rent=0.0100572, position_rent_refunded=0.0) + await service._record_lp_position_rent("e-1", live) + + closed = _executor(position_address=None, position_rent=0.0100572, position_rent_refunded=0.0100572) + await service._record_lp_position_rent("e-1", closed) + + assert repo.calls[-1] == ("B7nHjtVByQ", ORCA_RENT, ORCA_RENT) + + +@pytest.mark.asyncio +async def test_an_executor_that_never_opened_writes_nothing(): + repo = _RecordingRepo() + service = _service(repo) + + await service._record_lp_position_rent( + "e-1", _executor(position_address=None, position_rent=0.0, position_rent_refunded=0.0) + ) + + assert repo.calls == [] + + +@pytest.mark.asyncio +async def test_an_evm_position_with_no_rent_concept_writes_nothing(): + """uniswap has a position and no rent. NULL is the right answer, not 0.0.""" + repo = _RecordingRepo() + service = _service(repo) + + await service._record_lp_position_rent( + "e-1", _executor(position_address="0xabc", position_rent=0.0, position_rent_refunded=0.0) + ) + + assert repo.calls == [] + + +@pytest.mark.asyncio +async def test_a_missing_row_backs_off_instead_of_querying_every_tick(): + """The control loop ticks at 1 Hz and discovery files the row about once a minute.""" + repo = _RecordingRepo(found=False) + service = _service(repo) + + await service._record_lp_position_rent( + "e-1", _executor(position_address="B7nHjtVByQ", position_rent=0.0100572) + ) + + assert repo.calls == [("B7nHjtVByQ", ORCA_RENT, None)] + assert "e-1" not in service._lp_rent_recorded + assert service._lp_rent_retry_after["e-1"] > 0 + + +@pytest.mark.asyncio +async def test_a_refund_with_nowhere_to_go_is_reported(caplog): + """A position the poller never discovered: the refund is final and unrecordable, so + it has to be said out loud rather than dropped. + """ + repo = _RecordingRepo(found=False) + service = _service(repo) + service._lp_position_addresses["e-1"] = "B7nHjtVByQ" + + with caplog.at_level("WARNING"): + await service._record_lp_position_rent( + "e-1", _executor(position_address=None, position_rent_refunded=0.0100572) + ) + + assert "B7nHjtVByQ" in caplog.text + assert "0.0100572" in caplog.text + + +# -------------------------------------------------------------------------------------- +# The wiring: the control loop is what makes any of the above run +# -------------------------------------------------------------------------------------- + +async def _one_control_loop_tick(service): + """Run the loop body exactly once.""" + service._is_running = True + + def stop_after_this_tick(): + service._is_running = False + + service._trading_service = MagicMock() + service._trading_service.update_all_timestamps = MagicMock(side_effect=stop_after_this_tick) + service.update_interval = 0 + await service._control_loop() + + +@pytest.mark.asyncio +async def test_the_control_loop_records_a_live_lp_executor(): + repo = _RecordingRepo() + service = _service(repo) + service._active_executors = { + "e-1": _executor(position_address="B7nHjtVByQ", position_rent=0.0100572) + } + service._active_executors["e-1"].is_closed = False + service._executor_metadata = {"e-1": {"executor_type": "lp_executor"}} + + await _one_control_loop_tick(service) + + assert repo.calls == [("B7nHjtVByQ", ORCA_RENT, None)] + + +@pytest.mark.asyncio +async def test_the_control_loop_leaves_other_executor_types_alone(): + """Only lp_executor owns an on-chain position account with rent locked in it.""" + repo = _RecordingRepo() + service = _service(repo) + service._active_executors = {"e-1": _executor(position_address="B7nHjtVByQ", position_rent=0.0100572)} + service._active_executors["e-1"].is_closed = False + service._executor_metadata = {"e-1": {"executor_type": "position_executor"}} + + await _one_control_loop_tick(service) + + assert repo.calls == [] + + +@pytest.mark.asyncio +async def test_the_control_loop_stops_asking_once_the_rent_is_stored(): + """Otherwise this is a database round trip per executor per tick, forever.""" + repo = _RecordingRepo() + service = _service(repo) + service._active_executors = {"e-1": _executor(position_address="B7nHjtVByQ", position_rent=0.0100572)} + service._active_executors["e-1"].is_closed = False + service._executor_metadata = {"e-1": {"executor_type": "lp_executor"}} + + await _one_control_loop_tick(service) + await _one_control_loop_tick(service) + + assert len(repo.calls) == 1 + + +@pytest.mark.asyncio +async def test_completion_records_the_refund_the_close_produced(): + """The refund exists only after the close confirms, which is after the last control + loop tick that could see it — so completion has to record it, or the second half of + GW-41 stays open even with the first half fixed. + """ + from unittest.mock import AsyncMock + + repo = _RecordingRepo() + service = _service(repo) + service._lp_position_addresses["e-1"] = "B7nHjtVByQ" + + # A close that succeeded: position_address cleared, refund known. + executor = _executor(position_address=None, position_rent=0.0100572, position_rent_refunded=0.0100572) + executor.close_type = None + service._active_executors = {"e-1": executor} + service._executor_metadata = {"e-1": {"executor_type": "lp_executor"}} + service._persist_executor_completed = AsyncMock() + service._log_capture = MagicMock() + + await service._handle_executor_completion("e-1") + + assert repo.calls == [("B7nHjtVByQ", ORCA_RENT, ORCA_RENT)] + # And the per-executor bookkeeping does not outlive the executor. + assert "e-1" not in service._lp_position_addresses + assert "e-1" not in service._lp_rent_retry_after + + +@pytest.mark.asyncio +async def test_the_loop_survives_an_executor_appearing_while_it_awaits(caplog): + """Recording rent awaits a database round trip, and create_executor runs in a request + task that can add to _active_executors while the loop is suspended. Iterating the live + dict raises "dictionary changed size during iteration" — and the loop's own broad + except swallows it into a log line, so the tick reports success while having skipped + completion handling for every executor after the one that raced. + """ + repo = _RecordingRepo() + service = _service(repo) + + live = _executor(position_address="B7nHjtVByQ", position_rent=0.0100572) + live.is_closed = False + # A second executor so the iterator has to advance after the racing await. + other = _executor() + other.is_closed = False + service._active_executors = {"e-1": live, "e-2": other} + service._executor_metadata = { + "e-1": {"executor_type": "lp_executor"}, + "e-2": {"executor_type": "position_executor"}, + } + + async def record_and_race(*_args): + service._active_executors["e-3"] = _executor() + service._lp_rent_recorded.add("e-1") + + service._record_lp_position_rent = record_and_race + + with caplog.at_level("ERROR"): + await _one_control_loop_tick(service) + + assert "changed size during iteration" not in caplog.text From 30651436b8de62d3724c2f7c7f1c04e98a190cd7 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Thu, 20 Aug 2026 17:19:39 -0700 Subject: [PATCH 40/54] fix(executors): store and aggregate volume generated, not capital deployed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three aggregates summed ExecutorRecord.filled_amount_quote and called the result volume — the performance report's volume_total_quote, its per-type volume_quote, and the active-executor summary's total_volume_quote. For an executor that places orders that is right, because the amount it filled IS its volume. For an LP executor it is the capital it put up, and putting up capital trades nothing. The wheel now derives an LP position's real volume from the fees it earned and reports it as executor_info.volume_traded_quote. This stores that figure on the row and sums it instead. The migration backfills existing rows from filled_amount_quote for every executor type EXCEPT lp_executor, so an order-placing executor's history stays intact — the two are the same number for it by definition. Historical LP rows keep 0: their real volume is unrecoverable, because the fees were never stored, and copying the deposit across would re-enter the exact number this change removes, now looking deliberate. A migration entry can now carry several statements. create_all only creates missing TABLES, so a model gaining a column reaches a real deployment only through that list — and this one needs the backfill beside the ALTER. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK --- database/connection.py | 20 +- database/models.py | 4 + database/repositories/executor_repository.py | 11 +- services/executor_service.py | 8 +- ...cutor_volume_is_generated_not_deposited.py | 209 ++++++++++++++++++ 5 files changed, 246 insertions(+), 6 deletions(-) create mode 100644 test/test_executor_volume_is_generated_not_deposited.py diff --git a/database/connection.py b/database/connection.py index 2546ac85..45fbb308 100644 --- a/database/connection.py +++ b/database/connection.py @@ -94,6 +94,21 @@ async def _run_migrations(self, conn): "gateway_amm_positions", "position_rent_refunded", "ALTER TABLE gateway_amm_positions ADD COLUMN position_rent_refunded NUMERIC(30,18)" ), + # Volume generated, split from capital deployed. Existing rows are backfilled + # from filled_amount_quote for every executor type EXCEPT lp_executor, because + # for an executor that places orders the amount it filled IS the volume it + # traded, while an LP position's filled amount is the capital it put up. The + # real volume of a historical LP position is not recoverable — its fees were + # never stored — so those rows keep 0 rather than a number that was wrong. + ( + "executors", "volume_traded_quote", + ( + "ALTER TABLE executors ADD COLUMN volume_traded_quote " + "NUMERIC(30,18) NOT NULL DEFAULT 0", + "UPDATE executors SET volume_traded_quote = filled_amount_quote " + "WHERE executor_type <> 'lp_executor'", + ), + ), ] for table, column, sql in migrations: try: @@ -106,7 +121,10 @@ async def _run_migrations(self, conn): {"table": table, "column": column} ) if result.fetchone() is None: - await conn.execute(text(sql)) + # A migration may need more than the ALTER — a backfill, say — so an + # entry can carry several statements, run in order. + for statement in ((sql,) if isinstance(sql, str) else sql): + await conn.execute(text(statement)) logger.info(f"Migration: added {column} to {table}") except Exception as e: # Column-already-exists is expected on repeat startups diff --git a/database/models.py b/database/models.py index aae54c56..55f97c5d 100644 --- a/database/models.py +++ b/database/models.py @@ -482,6 +482,10 @@ class ExecutorRecord(Base): net_pnl_pct = Column(Numeric(precision=10, scale=6), nullable=False, default=0) cum_fees_quote = Column(Numeric(precision=30, scale=18), nullable=False, default=0) filled_amount_quote = Column(Numeric(precision=30, scale=18), nullable=False, default=0) + # Trading volume generated. The same number as filled_amount_quote for any executor + # that places orders, and deliberately not for an LP executor, whose filled amount is + # the capital it deposited — depositing capital trades nothing. + volume_traded_quote = Column(Numeric(precision=30, scale=18), nullable=False, default=0) # Error tracking error_log = Column(Text, nullable=True) # JSON: last errors captured during execution diff --git a/database/repositories/executor_repository.py b/database/repositories/executor_repository.py index 5ebdd4ae..6430e343 100644 --- a/database/repositories/executor_repository.py +++ b/database/repositories/executor_repository.py @@ -58,6 +58,7 @@ async def update_executor( net_pnl_pct: Optional[Decimal] = None, cum_fees_quote: Optional[Decimal] = None, filled_amount_quote: Optional[Decimal] = None, + volume_traded_quote: Optional[Decimal] = None, final_state: Optional[str] = None, error_log: Optional[str] = None ) -> Optional[ExecutorRecord]: @@ -80,6 +81,8 @@ async def update_executor( executor.cum_fees_quote = cum_fees_quote if filled_amount_quote is not None: executor.filled_amount_quote = filled_amount_quote + if volume_traded_quote is not None: + executor.volume_traded_quote = volume_traded_quote if final_state is not None: executor.final_state = final_state if error_log is not None: @@ -334,8 +337,8 @@ async def get_executor_stats(self) -> Dict[str, Any]: pnl_result = await self.session.execute(pnl_stmt) total_pnl = pnl_result.scalar() or Decimal("0") - # Total volume - volume_stmt = select(func.sum(ExecutorRecord.filled_amount_quote)) + # Total volume — the volume generated, not the capital deployed. + volume_stmt = select(func.sum(ExecutorRecord.volume_traded_quote)) volume_result = await self.session.execute(volume_stmt) total_volume = volume_result.scalar() or Decimal("0") @@ -406,7 +409,7 @@ async def get_performance_report( agg_stmt = select( func.coalesce(func.sum(ExecutorRecord.net_pnl_quote), Decimal(0)).label("pnl"), func.coalesce(func.sum(ExecutorRecord.cum_fees_quote), Decimal(0)).label("fees"), - func.coalesce(func.sum(ExecutorRecord.filled_amount_quote), Decimal(0)).label("vol"), + func.coalesce(func.sum(ExecutorRecord.volume_traded_quote), Decimal(0)).label("vol"), func.coalesce(func.avg(ExecutorRecord.net_pnl_pct), Decimal(0)).label("pnl_pct_avg"), func.count(ExecutorRecord.id).label("completed_count"), func.sum(case( @@ -440,7 +443,7 @@ async def get_performance_report( else_=0, )).label("running"), func.coalesce(func.sum(ExecutorRecord.net_pnl_quote), Decimal(0)).label("pnl"), - func.coalesce(func.sum(ExecutorRecord.filled_amount_quote), Decimal(0)).label("vol"), + func.coalesce(func.sum(ExecutorRecord.volume_traded_quote), Decimal(0)).label("vol"), func.coalesce(func.sum(ExecutorRecord.cum_fees_quote), Decimal(0)).label("fees"), ).where( and_(*completed_filter) diff --git a/services/executor_service.py b/services/executor_service.py index eac4211e..77526f5c 100644 --- a/services/executor_service.py +++ b/services/executor_service.py @@ -1065,6 +1065,7 @@ def _format_db_record(self, record) -> Dict[str, Any]: "net_pnl_pct": float(record.net_pnl_pct) if record.net_pnl_pct else 0.0, "cum_fees_quote": float(record.cum_fees_quote) if record.cum_fees_quote else 0.0, "filled_amount_quote": float(record.filled_amount_quote) if record.filled_amount_quote else 0.0, + "volume_traded_quote": float(record.volume_traded_quote) if record.volume_traded_quote else 0.0, "config": json.loads(record.config) if record.config else None, "custom_info": self._strip_heavy_fields( json.loads(record.final_state), record.executor_type @@ -1088,7 +1089,9 @@ def get_summary(self) -> Dict[str, Any]: active_count = len(executors) total_pnl = sum(e.get("net_pnl_quote", 0) for e in executors) - total_volume = sum(e.get("filled_amount_quote", 0) for e in executors) + # Volume generated, not capital deployed: an LP executor's filled amount is the + # money it put up, and putting up money trades nothing. + total_volume = sum(e.get("volume_traded_quote", 0) for e in executors) by_type: Dict[str, int] = {} by_connector: Dict[str, int] = {} @@ -1283,12 +1286,14 @@ async def _persist_executor_completed(self, executor_id: str, executor: Executor net_pnl_pct = executor_info.net_pnl_pct cum_fees_quote = executor_info.cum_fees_quote filled_amount_quote = executor_info.filled_amount_quote + volume_traded_quote = executor_info.volume_traded_quote except Exception as e: logger.debug(f"Error accessing executor_info for persistence: {e}") net_pnl_quote = Decimal("0") net_pnl_pct = Decimal("0") cum_fees_quote = Decimal("0") filled_amount_quote = Decimal("0") + volume_traded_quote = Decimal("0") # Get custom_info directly from executor to avoid Pydantic serialization issues # with TrackedOrder and other complex types @@ -1357,6 +1362,7 @@ async def _persist_executor_completed(self, executor_id: str, executor: Executor net_pnl_pct=net_pnl_pct, cum_fees_quote=cum_fees_quote, filled_amount_quote=filled_amount_quote, + volume_traded_quote=volume_traded_quote, final_state=final_state_json, error_log=error_log_json ) diff --git a/test/test_executor_volume_is_generated_not_deposited.py b/test/test_executor_volume_is_generated_not_deposited.py new file mode 100644 index 00000000..e7773498 --- /dev/null +++ b/test/test_executor_volume_is_generated_not_deposited.py @@ -0,0 +1,209 @@ +"""Volume is what an executor traded, not what it deposited. + +`ExecutorRecord.filled_amount_quote` was summed as `volume_total_quote`. For every +executor that places orders that is the same number — the amount it filled IS the volume. +For an LP executor it is not: its filled amount is the capital it put up, and putting up +capital trades nothing. A position that deposited $100 and never saw a swap reported $100 +of volume, and the round trip in and back out read as more. + +The volume an LP position DOES generate is derived in the wheel, from the fees it earned +(fees are a fixed fraction of the flow that paid them). This side's job is to store that +figure and aggregate it, rather than reaching for the deposit. +""" +import inspect +import re +from decimal import Decimal +from unittest.mock import MagicMock + +import pytest + +pytest.importorskip("hummingbot") + +from database.connection import AsyncDatabaseManager # noqa: E402 +from database.models import ExecutorRecord # noqa: E402 +from database.repositories.executor_repository import ExecutorRepository # noqa: E402 +from services.executor_service import ExecutorService # noqa: E402 + + +def test_the_record_has_a_column_for_volume_separate_from_filled_amount(): + columns = {c.name for c in ExecutorRecord.__table__.columns} + + assert "volume_traded_quote" in columns + # Both, not one renamed into the other: capital deployed is still a fact worth having. + assert "filled_amount_quote" in columns + + +def test_every_aggregate_sums_volume_rather_than_the_filled_amount(): + """Three places summed the wrong column; a fourth added later would too.""" + source = inspect.getsource(ExecutorRepository.get_performance_report) + summed = set(re.findall(r"func\.sum\(ExecutorRecord\.(\w+)\)", source)) + + assert "volume_traded_quote" in summed + assert "filled_amount_quote" not in summed, ( + "an aggregate is still summing the capital deployed and calling it volume" + ) + + +def _service_with(executor_info_fields): + """An ExecutorService wired to one fake executor, with nothing else running.""" + service = ExecutorService.__new__(ExecutorService) + service._executor_metadata = {"e-1": {"executor_type": "lp_executor"}} + service._log_capture = MagicMock() + service._log_capture.get_error_count.return_value = 0 + service._log_capture.get_last_error.return_value = None + + executor = MagicMock() + info = MagicMock() + dumped = {"custom_info": {}, **executor_info_fields} + info.model_dump.return_value = dumped + info.side = None + executor.executor_info = info + executor.status.name = "TERMINATED" + executor.close_type = None + executor.is_closed = True + service._active_executors = {"e-1": executor} + return service + + +def test_the_active_summary_counts_volume_generated_not_capital_deposited(): + """An LP position holding $200 of capital that has traded $2,500 through its range.""" + service = _service_with({"filled_amount_quote": 200.0, "volume_traded_quote": 2500.0}) + + summary = service.get_summary() + + assert summary["total_volume_quote"] == 2500.0 + + +def test_a_funded_position_that_traded_nothing_summarises_as_no_volume(): + service = _service_with({"filled_amount_quote": 200.0, "volume_traded_quote": 0.0}) + + summary = service.get_summary() + + assert summary["total_volume_quote"] == 0.0 + + +@pytest.mark.asyncio +async def test_completion_persists_the_volume_the_executor_reported(): + """The figure the wheel derived has to reach the row it is later summed from.""" + from contextlib import asynccontextmanager + + recorded = {} + + class _Repo: + def __init__(self, _session): + pass + + async def update_executor(self, **kwargs): + recorded.update(kwargs) + + service = ExecutorService.__new__(ExecutorService) + service._executor_metadata = {"e-1": {"executor_type": "lp_executor"}} + service._log_capture = MagicMock() + service._log_capture.get_error_count.return_value = 0 + + db_manager = MagicMock() + + @asynccontextmanager + async def session_context(): + yield MagicMock() + + db_manager.get_session_context = session_context + service.db_manager = db_manager + + executor = MagicMock() + executor.status.name = "TERMINATED" + executor.close_type = None + executor.get_custom_info.return_value = {} + info = MagicMock() + info.net_pnl_quote = Decimal("3") + info.net_pnl_pct = Decimal("0.01") + info.cum_fees_quote = Decimal("1") + info.filled_amount_quote = Decimal("200") + info.volume_traded_quote = Decimal("2500") + executor.executor_info = info + + import services.executor_service as module + original = module.ExecutorRepository + module.ExecutorRepository = _Repo + try: + await service._persist_executor_completed("e-1", executor) + finally: + module.ExecutorRepository = original + + assert recorded["volume_traded_quote"] == Decimal("2500") + # Capital deployed still stored, under its own name. + assert recorded["filled_amount_quote"] == Decimal("200") + + +def test_update_executor_accepts_it(): + parameters = inspect.signature(ExecutorRepository.update_executor).parameters + + assert "volume_traded_quote" in parameters + + +class TestTheMigration: + """create_all only creates MISSING tables, so an existing database gains a column + only through the migration list. Without the entry the column exists in the model, + every write names it, and every one of them fails against a real deployment.""" + + def _entry(self): + source = inspect.getsource(AsyncDatabaseManager._run_migrations) + match = re.search( + r'\(\s*"executors",\s*"volume_traded_quote",\s*\((.*?)\),\s*\),', source, re.DOTALL + ) + assert match, "no migration adds volume_traded_quote to executors" + return match.group(1) + + def test_it_adds_the_column(self): + assert "ALTER TABLE executors ADD COLUMN volume_traded_quote" in self._entry() + + def test_it_backfills_the_executors_whose_filled_amount_was_their_volume(self): + """For an order-placing executor the two are the same number by definition, so + history stays intact rather than resetting to zero.""" + entry = self._entry() + + assert "UPDATE executors SET volume_traded_quote = filled_amount_quote" in entry + + def test_it_leaves_lp_rows_at_zero_rather_than_backfilling_the_deposit(self): + """The one thing the backfill must NOT do. A historical LP position's real volume + is unrecoverable — its fees were never stored — and copying the deposit across + would re-enter exactly the number this change exists to remove, now looking + migrated and deliberate.""" + entry = self._entry() + + assert "executor_type <> 'lp_executor'" in entry + + def test_a_multi_statement_migration_runs_every_statement(self): + source = inspect.getsource(AsyncDatabaseManager._run_migrations) + + assert "for statement in ((sql,) if isinstance(sql, str) else sql)" in source, ( + "the runner executes a single string, so the backfill beside the ALTER never runs" + ) + + +def test_a_completed_executors_api_row_carries_the_volume(): + """What the API returns for a completed executor, read back from its row.""" + service = ExecutorService.__new__(ExecutorService) + + record = MagicMock( + executor_id="e-1", executor_type="lp_executor", account_name="master_account", + connector_name="solana-mainnet-beta", trading_pair="SOL-USDC", status="TERMINATED", + close_type="EARLY_STOP", controller_id="main", error_log=None, config=None, + final_state=None, created_at=None, closed_at=None, + net_pnl_quote=Decimal("3"), net_pnl_pct=Decimal("0.01"), cum_fees_quote=Decimal("1"), + filled_amount_quote=Decimal("200"), volume_traded_quote=Decimal("2500"), + ) + + row = service._format_db_record(record) + + assert row["volume_traded_quote"] == 2500.0 + assert row["filled_amount_quote"] == 200.0 + + +def test_decimal_precision_matches_the_filled_amount_column(): + """Same scale, because they measure the same kind of quantity.""" + volume = ExecutorRecord.__table__.columns["volume_traded_quote"].type + filled = ExecutorRecord.__table__.columns["filled_amount_quote"].type + + assert (volume.precision, volume.scale) == (filled.precision, filled.scale) + assert Decimal(10) ** -volume.scale > 0 From 7e007282b313d942906ea0af37b2188672b0736c Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Thu, 20 Aug 2026 17:26:52 -0700 Subject: [PATCH 41/54] fix(gateway): record the swaps an executor made, not only the hand-driven ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gateway_swaps is written by hummingbot-api's own /gateway/swap/* routes. An executor holds its connector through the wheel and talks to Gateway directly, so hummingbot-api never saw the call and had nothing to record. Two MARKET swaps ran through order_executor on 2026-08-21 at 00:13 and 00:15 UTC, both CONFIRMED on chain and both reconciling exactly against the wallet, and neither reached the table: newest row in gateway_swaps 2026-08-20T18:37:24 (a hand-driven sell) executor swaps 2026-08-21T00:13, 00:15 SOL-USDC rows 9, newest 2026-08-20T01:35 — all by hand The table was not wrong, it was silently partial. /gateway/swaps/search and the swap summary described only swaps made by hand, with no marker saying so, so a caller reading "9 SOL-USDC swaps" had no way to learn that the most recent ones were missing — on the path that is actually recommended. Same shape as the rent fix: ask the executor at completion rather than wait for a route that will never be called. Keyed on the transaction hash the wheel now reports, because order_id is internal and appears nowhere on chain. The recorded slippage_pct is the LIVE tolerance, which is not the configured one when earlier attempts failed and widened it. A swap with no realized amounts is reported rather than recorded: a row of zeroes would read as a swap that moved nothing, when the amounts are simply unknown. Non-Gateway executors carry no hash and fall straight back out. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK --- services/executor_service.py | 103 +++++++++- test/test_executor_swaps_are_recorded.py | 227 +++++++++++++++++++++++ 2 files changed, 329 insertions(+), 1 deletion(-) create mode 100644 test/test_executor_swaps_are_recorded.py diff --git a/services/executor_service.py b/services/executor_service.py index 77526f5c..fed8a957 100644 --- a/services/executor_service.py +++ b/services/executor_service.py @@ -33,8 +33,9 @@ from hummingbot.strategy_v2.executors.xemm_executor.xemm_executor import XEMMExecutor from hummingbot.strategy_v2.models.executors import CloseType, TrackedOrder -from database import AsyncDatabaseManager, ExecutorRepository, GatewayCLMMRepository +from database import AsyncDatabaseManager, ExecutorRepository, GatewayCLMMRepository, GatewaySwapRepository from models.executors import PositionHold +from services.gateway_client import get_native_gas_token from services.trading_service import AccountTradingInterface, TradingService from utils.executor_log_capture import ExecutorLogCapture, current_executor_id from utils.trading_pair import InvalidTradingPair, split_trading_pair @@ -432,6 +433,101 @@ async def _record_lp_position_rent(self, executor_id: str, executor: ExecutorBas f"refunded={position_rent_refunded}" ) + async def _record_executor_swap(self, executor_id: str, executor: ExecutorBase) -> None: + """Record a swap an executor made, so the swap history covers the recommended path. + + `gateway_swaps` is written by hummingbot-api's own /gateway/swap/* routes. An + executor holds its connector through the wheel and talks to Gateway directly, so + hummingbot-api never sees the call and had nothing to record. The table was not + wrong, it was silently partial: POST /gateway/swaps/search and the swap summary + described only swaps made by hand, and a caller reading "9 SOL-USDC swaps" had no + way to learn that the most recent ones were missing. + + Same shape as _record_lp_position_rent: the executor knows what it did, so ask it + at completion rather than waiting for a route that will never be called. Keyed on + the transaction hash, which is what GW-43 added to custom_info — `order_id` is + internal (buy-SOL-USDC-1787271213996599) and appears nowhere on chain, so before + that there was nothing to key a row on. + + Skipped silently when there is no hash: a Gateway swap that never reached the + chain has nothing to record, and an executor on a CEX is not a Gateway swap at all. + """ + try: + custom_info = executor.get_custom_info() + except Exception as e: + logger.debug(f"Could not read custom_info for {executor_id} while recording its swap: {e}") + return + + transaction_hash = custom_info.get("transaction_hash") + swap_provider = custom_info.get("swap_provider") + if not transaction_hash or not swap_provider: + return + + metadata = self._executor_metadata.get(executor_id, {}) + network = metadata.get("connector_name") or "" + trading_pair = metadata.get("trading_pair") or "" + if not network or "-" not in trading_pair: + logger.warning( + f"Executor {executor_id} swapped in {transaction_hash} but reports " + f"network={network!r} pair={trading_pair!r}; not recorded." + ) + return + + base_token, quote_token = split_trading_pair(trading_pair) + side = str(custom_info.get("side") or "").upper() + side = "BUY" if "BUY" in side else "SELL" + + amount_base = Decimal(str(custom_info.get("executed_amount_base") or 0)) + price = Decimal(str(custom_info.get("average_executed_price") or 0)) + if amount_base <= 0 or price <= 0: + logger.warning( + f"Executor {executor_id} swapped in {transaction_hash} but reports no " + f"realized amounts (base={amount_base}, price={price}); not recorded." + ) + return + amount_quote = amount_base * price + + # A BUY spends quote to receive base; a SELL is the mirror image. + input_amount, output_amount = ( + (amount_quote, amount_base) if side == "BUY" else (amount_base, amount_quote) + ) + + chain = network.split("-", 1)[0] + # The provider travels as "jupiter/router"; the table stores the bare DEX name, + # which is what the /gateway/swap routes write. + connector = swap_provider.split("/", 1)[0] + + try: + async with self.db_manager.get_session_context() as session: + repo = GatewaySwapRepository(session) + if await repo.get_swap_by_tx_hash(transaction_hash): + return + await repo.create_swap({ + "transaction_hash": transaction_hash, + "network": network, + "connector": connector, + "wallet_address": custom_info.get("wallet_address") or "", + "trading_pair": trading_pair, + "base_token": base_token, + "quote_token": quote_token, + "side": side, + "input_amount": input_amount, + "output_amount": output_amount, + "price": price, + # The LIVE tolerance the swap went out with, which is not + # config.slippage_pct when earlier attempts failed and widened it. + "slippage_pct": (Decimal(str(custom_info["slippage_pct"])) + if custom_info.get("slippage_pct") is not None else None), + "gas_token": get_native_gas_token(chain), + "status": "CONFIRMED", + }) + logger.info( + f"Recorded executor swap {transaction_hash}: {side} {amount_base} " + f"{base_token} @ {price} on {connector}/{network}" + ) + except Exception as e: + logger.error(f"Error recording executor swap {transaction_hash}: {e}", exc_info=True) + def _get_trading_interface(self, account_name: str) -> AccountTradingInterface: """Get or create an AccountTradingInterface for the account.""" if account_name not in self._trading_interfaces: @@ -940,6 +1036,11 @@ async def _handle_executor_completion(self, executor_id: str): # relies on the address remembered while the executor was live. if metadata.get("executor_type") == "lp_executor": await self._record_lp_position_rent(executor_id, executor) + # A Gateway swap an executor made, which no /gateway/swap route ever saw. See + # _record_executor_swap; non-Gateway executors carry no transaction hash and fall + # straight back out. + if self.db_manager: + await self._record_executor_swap(executor_id, executor) self._lp_position_addresses.pop(executor_id, None) self._lp_rent_recorded.discard(executor_id) self._lp_rent_retry_after.pop(executor_id, None) diff --git a/test/test_executor_swaps_are_recorded.py b/test/test_executor_swaps_are_recorded.py new file mode 100644 index 00000000..ba49c34d --- /dev/null +++ b/test/test_executor_swaps_are_recorded.py @@ -0,0 +1,227 @@ +"""A swap an executor made must reach gateway_swaps (GW-42). + +`gateway_swaps` is written by hummingbot-api's own /gateway/swap/* routes. An executor +holds its connector through the wheel and talks to Gateway directly, so hummingbot-api +never saw the call and had nothing to record. Two MARKET swaps ran through order_executor +on 2026-08-21 at 00:13 and 00:15 UTC, both CONFIRMED on chain and both reconciling exactly +against the wallet, and neither is in the table: + + newest row in gateway_swaps 2026-08-20T18:37:24 (a hand-driven DOGE-1 sell) + executor swaps 2026-08-21T00:13, 00:15 + SOL-USDC rows 9, newest 2026-08-20T01:35 -- all hand-driven + +The table was not wrong, it was silently partial. POST /gateway/swaps/search and the swap +summary described only swaps made by hand, with no marker saying so, so a caller reading +"9 SOL-USDC swaps" had no way to learn the recent ones were missing. +""" +from contextlib import asynccontextmanager +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock + +import pytest + +pytest.importorskip("hummingbot") + +from services.executor_service import ExecutorService # noqa: E402 + +SIGNATURE = "5xLmQ5s5xZ9jTqk3Y8bNvW2pR7cH4dF6gJ1kM3nP9qS8tU4vX6yZ2aB5cD7eF9gH1jK3lM5nP7qR9sT" + +# The BUY leg of the live 2026-08-21 round trip: 0.010000000 SOL in for 0.878444 USDC. +A_LIVE_BUY = { + "transaction_hash": SIGNATURE, + "swap_provider": "jupiter/router", + "wallet_address": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "side": "BUY", + "executed_amount_base": Decimal("0.01"), + "average_executed_price": Decimal("87.8444"), + "slippage_pct": Decimal("0.05"), +} + + +class _Repo: + def __init__(self, existing=None): + self.created = [] + self._existing = existing + + async def get_swap_by_tx_hash(self, transaction_hash): + return self._existing + + async def create_swap(self, swap_data): + self.created.append(swap_data) + return MagicMock() + + +def _service(repo, custom_info, metadata=None): + service = ExecutorService.__new__(ExecutorService) + service._executor_metadata = {"e-1": metadata if metadata is not None else { + "executor_type": "order_executor", + "connector_name": "solana-mainnet-beta", + "trading_pair": "SOL-USDC", + }} + + db_manager = MagicMock() + + @asynccontextmanager + async def session_context(): + yield MagicMock() + + db_manager.get_session_context = session_context + service.db_manager = db_manager + + executor = MagicMock() + executor.get_custom_info.return_value = custom_info + + import services.executor_service as module + module.GatewaySwapRepository = lambda _session: repo + return service, executor + + +@pytest.fixture(autouse=True) +def _restore_repository(): + import services.executor_service as module + original = module.GatewaySwapRepository + yield + module.GatewaySwapRepository = original + + +@pytest.mark.asyncio +async def test_a_live_buy_is_recorded(): + repo = _Repo() + service, executor = _service(repo, dict(A_LIVE_BUY)) + + await service._record_executor_swap("e-1", executor) + + assert len(repo.created) == 1 + row = repo.created[0] + assert row["transaction_hash"] == SIGNATURE + assert row["connector"] == "jupiter" # bare DEX, as the routes write it + assert row["network"] == "solana-mainnet-beta" + assert row["trading_pair"] == "SOL-USDC" + assert row["side"] == "BUY" + assert row["status"] == "CONFIRMED" + assert row["gas_token"] == "SOL" + + +@pytest.mark.asyncio +async def test_a_buy_spends_quote_to_receive_base(): + """Direction is the thing a swap row gets wrong most easily.""" + repo = _Repo() + service, executor = _service(repo, dict(A_LIVE_BUY)) + + await service._record_executor_swap("e-1", executor) + + row = repo.created[0] + assert row["output_amount"] == Decimal("0.01") # base received + assert row["input_amount"] == Decimal("0.878444") # quote spent + assert row["price"] == Decimal("87.8444") + + +@pytest.mark.asyncio +async def test_a_sell_is_the_mirror_image(): + repo = _Repo() + service, executor = _service(repo, {**A_LIVE_BUY, "side": "SELL"}) + + await service._record_executor_swap("e-1", executor) + + row = repo.created[0] + assert row["input_amount"] == Decimal("0.01") # base sent + assert row["output_amount"] == Decimal("0.878444") # quote received + + +@pytest.mark.asyncio +async def test_it_records_the_tolerance_the_swap_actually_used(): + """Not config.slippage_pct: after a widening the two differ, and the one that was + paid for is the live one.""" + repo = _Repo() + service, executor = _service(repo, {**A_LIVE_BUY, "slippage_pct": Decimal("1.25")}) + + await service._record_executor_swap("e-1", executor) + + assert repo.created[0]["slippage_pct"] == Decimal("1.25") + + +@pytest.mark.asyncio +async def test_a_side_arriving_as_an_enum_string_still_reads_as_a_side(): + """custom_info carries whatever the executor put there; TradeType renders as + 'TradeType.BUY' rather than 'BUY'.""" + repo = _Repo() + service, executor = _service(repo, {**A_LIVE_BUY, "side": "TradeType.BUY"}) + + await service._record_executor_swap("e-1", executor) + + assert repo.created[0]["side"] == "BUY" + + +@pytest.mark.asyncio +async def test_recording_twice_does_not_duplicate_the_row(): + """The hash is unique in the table, so a second write would raise rather than no-op.""" + repo = _Repo(existing=MagicMock()) + service, executor = _service(repo, dict(A_LIVE_BUY)) + + await service._record_executor_swap("e-1", executor) + + assert repo.created == [] + + +@pytest.mark.asyncio +async def test_an_executor_that_is_not_a_gateway_swap_records_nothing(): + """A CEX order executor has no transaction hash and no swap provider.""" + repo = _Repo() + service, executor = _service(repo, { + "transaction_hash": None, "swap_provider": None, "side": "BUY", + "executed_amount_base": Decimal("0.01"), "average_executed_price": Decimal("87"), + }) + + await service._record_executor_swap("e-1", executor) + + assert repo.created == [] + + +@pytest.mark.asyncio +async def test_a_swap_with_no_realized_amounts_is_reported_not_recorded(caplog): + """A row of zeroes would read as a swap that moved nothing, which is not what + happened — the amounts are simply unknown.""" + repo = _Repo() + service, executor = _service(repo, { + **A_LIVE_BUY, "executed_amount_base": Decimal("0"), "average_executed_price": Decimal("0"), + }) + + with caplog.at_level("WARNING"): + await service._record_executor_swap("e-1", executor) + + assert repo.created == [] + assert SIGNATURE in caplog.text + + +@pytest.mark.asyncio +async def test_a_missing_pair_is_reported_not_guessed(caplog): + repo = _Repo() + service, executor = _service(repo, dict(A_LIVE_BUY), metadata={ + "executor_type": "order_executor", "connector_name": "solana-mainnet-beta", + "trading_pair": "", + }) + + with caplog.at_level("WARNING"): + await service._record_executor_swap("e-1", executor) + + assert repo.created == [] + assert SIGNATURE in caplog.text + + +@pytest.mark.asyncio +async def test_completion_records_the_swap(): + """The wiring, without which none of the above ever runs.""" + repo = _Repo() + service, executor = _service(repo, dict(A_LIVE_BUY)) + executor.close_type = None + service._active_executors = {"e-1": executor} + service._lp_position_addresses = {} + service._lp_rent_recorded = set() + service._lp_rent_retry_after = {} + service._persist_executor_completed = AsyncMock() + service._log_capture = MagicMock() + + await service._handle_executor_completion("e-1") + + assert len(repo.created) == 1 + assert repo.created[0]["transaction_hash"] == SIGNATURE From 09b4150c9b207bdf4c613e60dc9080c9e0e92ff2 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Thu, 20 Aug 2026 20:05:58 -0700 Subject: [PATCH 42/54] fix(executors): let the volume figure reach the caller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The executor derives its volume, the row stores it, and the aggregates sum it — and ExecutorResponse did not declare the field, so FastAPI filtered it out at the boundary and no caller ever saw it. The same shape as GW-33: a value computed correctly all the way to the last step, then dropped in silence. The three volume descriptions said "total filled volume", which is the phrasing that made depositing capital look like trading in the first place. They now say what the number is: volume GENERATED, with an LP position's deposit excluded and its real volume derived from the fees it earned. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK --- models/executors.py | 25 ++++++++++++++--- ...cutor_volume_is_generated_not_deposited.py | 27 +++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/models/executors.py b/models/executors.py index c1e5f774..ed03b771 100644 --- a/models/executors.py +++ b/models/executors.py @@ -368,7 +368,8 @@ class ExecutorResponse(BaseModel): "net_pnl_quote": 125.50, "net_pnl_pct": 2.5, "cum_fees_quote": 1.25, - "filled_amount_quote": 5000.0 + "filled_amount_quote": 5000.0, + "volume_traded_quote": 5000.0 } } ) @@ -391,6 +392,15 @@ class ExecutorResponse(BaseModel): net_pnl_pct: float = Field(description="Net PnL percentage") cum_fees_quote: float = Field(description="Cumulative fees in quote currency") filled_amount_quote: float = Field(description="Total filled amount in quote currency") + volume_traded_quote: float = Field( + default=0.0, + description="Trading volume generated, in quote currency. The same number as " + "filled_amount_quote for any executor that places orders — the amount " + "it filled IS its volume. Deliberately different for an LP executor, " + "whose filled amount is the capital it deposited: depositing capital " + "trades nothing. An LP position's volume is derived from the fees it " + "earned, which are a fixed fraction of the swaps that crossed its " + "range, and is 0 while it has earned none.") error_count: int = Field(default=0, description="Number of ERROR-level log entries captured") last_error: Optional[str] = Field(default=None, description="Most recent error message, if any") @@ -500,7 +510,9 @@ class ExecutorsSummaryResponse(BaseModel): total_active: int = Field(description="Number of active executors") total_pnl_quote: float = Field(description="Total PnL across active executors") - total_volume_quote: float = Field(description="Total volume across active executors") + total_volume_quote: float = Field( + description="Total volume traded across active executors. Volume GENERATED, not " + "capital deployed — see volume_traded_quote on an executor.") by_type: Dict[str, int] = Field(description="Executor count by type") by_connector: Dict[str, int] = Field(description="Executor count by connector") by_status: Dict[str, int] = Field(description="Executor count by status") @@ -513,7 +525,9 @@ class ExecutorTypeBreakdown(BaseModel): completed: int = Field(description="Completed executors") running: int = Field(description="Currently running executors") pnl_quote: float = Field(description="Net PnL in quote currency") - volume_quote: float = Field(description="Total filled volume in quote currency") + volume_quote: float = Field( + description="Total volume traded in quote currency for this executor type. Volume " + "GENERATED, not capital deployed.") fees_quote: float = Field(description="Cumulative fees in quote currency") @@ -527,7 +541,10 @@ class PerformanceReportResponse(BaseModel): global_pnl_quote: float = Field(description="Global PnL (realized + unrealized)") pnl_pct_avg: float = Field(description="Average PnL percentage across completed executors") fees_total_quote: float = Field(description="Total cumulative fees in quote currency") - volume_total_quote: float = Field(description="Total filled volume in quote currency") + volume_total_quote: float = Field( + description="Total volume traded in quote currency. Volume GENERATED, not capital " + "deployed: an LP position's deposit is excluded, and the volume its " + "range actually saw is derived from the fees it earned.") win_rate: float = Field(description="Win rate: fraction of completed executors with positive PnL") sharpe_ratio: Optional[float] = Field(None, description="Sharpe ratio of PnL returns (null if <2 executors)") by_type: List[ExecutorTypeBreakdown] = Field(description="Performance breakdown by executor type") diff --git a/test/test_executor_volume_is_generated_not_deposited.py b/test/test_executor_volume_is_generated_not_deposited.py index e7773498..a907756d 100644 --- a/test/test_executor_volume_is_generated_not_deposited.py +++ b/test/test_executor_volume_is_generated_not_deposited.py @@ -207,3 +207,30 @@ def test_decimal_precision_matches_the_filled_amount_column(): assert (volume.precision, volume.scale) == (filled.precision, filled.scale) assert Decimal(10) ** -volume.scale > 0 + + +def test_the_api_response_declares_volume_so_it_reaches_a_caller(): + """FastAPI filters a response to the fields its model declares, so a figure the + service computes and the model omits is silently dropped at the boundary — the + executor knows its volume, the row stores it, and the caller never sees it. + """ + from models.executors import ExecutorResponse + + assert "volume_traded_quote" in ExecutorResponse.model_fields + assert "filled_amount_quote" in ExecutorResponse.model_fields + + +def test_a_response_carries_a_volume_that_differs_from_the_capital(): + """The pair a defect would collapse back into one number.""" + from models.executors import ExecutorResponse + + response = ExecutorResponse( + executor_id="e-1", executor_type="lp_executor", account_name="master_account", + connector_name="solana-mainnet-beta", trading_pair="SOL-USDC", status="RUNNING", + is_active=True, is_trading=True, net_pnl_quote=3.0, net_pnl_pct=0.01, + cum_fees_quote=1.0, filled_amount_quote=200.0, volume_traded_quote=2500.0, + ) + + dumped = response.model_dump() + assert dumped["volume_traded_quote"] == 2500.0 + assert dumped["filled_amount_quote"] == 200.0 From 44f18912361a961cfe809153cc98838c2107776d Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Thu, 20 Aug 2026 20:35:22 -0700 Subject: [PATCH 43/54] fix(controllers): lp_rebalancer must not pin itself to one package root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /controllers/generic/lp_rebalancer/config/template returned 404 "Controller configuration class for 'lp_rebalancer' not found", while every other generic controller returned 200. Without a template nothing can build a config, so GET /controllers/ listed the controller and it was then unusable — the worse of the two failure modes, because it advertises itself first. Its __init__.py imported `from controllers.generic.lp_rebalancer...`. That absolute path is the layout inside a bot container; hummingbot-api mounts the same tree one level deeper and imports it as bots.controllers.*, where a top-level `controllers` package does not exist. lp_rebalancer is the tree's only package-style controller, which is why the damage was total rather than partial: load_controller_config_class tries `...lp_rebalancer` and then `...lp_rebalancer.lp_rebalancer`, and the second has to import the parent package first — so the broken __init__ ran either way and both candidates failed. A relative import resolves under both layouts. The accompanying test is the lint rule: any absolute `from controllers.…` under bots/controllers/ is this bug, and today no file has one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK --- .../generic/lp_rebalancer/__init__.py | 8 ++- test/test_controllers_import_relatively.py | 72 +++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 test/test_controllers_import_relatively.py diff --git a/bots/controllers/generic/lp_rebalancer/__init__.py b/bots/controllers/generic/lp_rebalancer/__init__.py index 49fdc1e2..81646300 100644 --- a/bots/controllers/generic/lp_rebalancer/__init__.py +++ b/bots/controllers/generic/lp_rebalancer/__init__.py @@ -1,3 +1,9 @@ -from controllers.generic.lp_rebalancer.lp_rebalancer import LPRebalancer, LPRebalancerConfig +# Relative, not `from controllers.generic....`. The same tree is imported under two +# different package roots: `controllers.*` inside a bot container, and +# `bots.controllers.*` by hummingbot-api, which mounts it one level deeper. An absolute +# path pins the module to one of them and breaks under the other — and because this +# package's __init__ runs before either candidate module path can be reached, the failure +# took the whole controller down rather than one import of it. +from .lp_rebalancer import LPRebalancer, LPRebalancerConfig __all__ = ["LPRebalancer", "LPRebalancerConfig"] diff --git a/test/test_controllers_import_relatively.py b/test/test_controllers_import_relatively.py new file mode 100644 index 00000000..9b10c949 --- /dev/null +++ b/test/test_controllers_import_relatively.py @@ -0,0 +1,72 @@ +"""A controller must not pin itself to one package root (GW-45). + +`bots/controllers/` is imported under two different roots: `controllers.*` inside a bot +container, and `bots.controllers.*` by hummingbot-api, which mounts the same tree one level +deeper. An absolute `from controllers.…` import resolves under the first and raises under +the second. + +`lp_rebalancer/__init__.py` had one, and because it is the tree's only package-style +controller the damage was total rather than partial: `load_controller_config_class` tries +`…lp_rebalancer` and then `…lp_rebalancer.lp_rebalancer`, and the second has to import the +parent package first — so the broken `__init__` ran either way and both candidates failed. + + GET /controllers/generic/lp_rebalancer/config/template + 404 Controller configuration class for 'lp_rebalancer' not found + +Which is the worse of the two failure modes: `GET /controllers/` still listed the +controller, so it advertised itself and was then unusable. +""" +import ast +from pathlib import Path + +import pytest + +CONTROLLERS = Path(__file__).resolve().parent.parent / "bots" / "controllers" + + +def _controller_modules(): + return sorted(CONTROLLERS.rglob("*.py")) + + +def _absolute_controller_imports(path: Path): + """`from controllers.x import y` and `import controllers.x`, ignoring relative ones.""" + tree = ast.parse(path.read_text()) + offenders = [] + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom): + # node.level > 0 is a relative import, which is the correct form. + if node.level == 0 and (node.module or "").split(".")[0] == "controllers": + offenders.append(f"from {node.module} import ...") + elif isinstance(node, ast.Import): + for alias in node.names: + if alias.name.split(".")[0] == "controllers": + offenders.append(f"import {alias.name}") + return offenders + + +@pytest.mark.parametrize("path", _controller_modules(), ids=lambda p: str(p.name)) +def test_no_controller_pins_itself_to_one_package_root(path): + offenders = _absolute_controller_imports(path) + + assert offenders == [], ( + f"{path.relative_to(CONTROLLERS)} imports {offenders} absolutely. Under " + "hummingbot-api this tree is bots.controllers.*, so a top-level `controllers` " + "package does not exist and the import raises. Use a relative import." + ) + + +def test_the_sweep_actually_looked_at_something(): + """A glob that silently matches nothing would make every assertion above vacuous.""" + assert len(_controller_modules()) > 5 + + +def test_the_package_style_controller_resolves_its_config_class(): + """lp_rebalancer is the tree's only package-style controller, and the reason the + import style matters at all. Behavioural, because the import rule above is a proxy + for this.""" + from utils.file_system import fs_util + + config_class = fs_util.load_controller_config_class("generic", "lp_rebalancer") + + assert config_class is not None, "lp_rebalancer resolves to no config class — /config/template 404s" + assert config_class.__name__ == "LPRebalancerConfig" From 3d3b46903031cd4be5b57647359282910716ea1d Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Thu, 20 Aug 2026 20:35:35 -0700 Subject: [PATCH 44/54] fix(pools): rank by depth, and refuse a sort key the DEX will not take MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects on the same endpoint, both found on UMBRA-USDC/meteora. **The default was `volume`.** On a token whose DLMM pools are all idle every row ties at volume_24h = 0.00, so the order is arbitrary and liquidity is never consulted. Of 73 pools, the one holding 15.34K ranked 68th and the one holding $1.07 ranked 47th — so reading top-down, an agent picked the $1.07 pool and separately reported the deep one as "not found". It was at row 68. Volume ranks pools by how much OTHERS traded; the LP question is how much depth is there. It is also the field most likely to be uniformly zero, and a sort key that collapses to noise is worse than one that merely ranks differently than you wanted. **The documented keys did not all work.** "volume, tvl, feetvlratio, etc." advertised two that 400'd. Probed against the live upstreams: meteora tvl, volume_24h, fee_tvl_ratio_24h OK fees_24h, apr, liquidity, volume 400 orca tvl, volume, fees, rewards, yieldovertvl OK liquidity 400 So feetvlratio was real under another name, and this router's own _24h suffixing turned `fees` into `fees_24h`, which Meteora rejects outright. Both reached the DEX, came back a bare 400 and surfaced as an opaque hapi 500 — reading as a server fault rather than a wrong field name. Keys are now translated per connector and anything else is refused here, naming the ones that work. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK --- routers/gateway_clmm.py | 51 ++++++++++++++-- test/test_pool_sort_keys.py | 113 ++++++++++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+), 5 deletions(-) create mode 100644 test/test_pool_sort_keys.py diff --git a/routers/gateway_clmm.py b/routers/gateway_clmm.py index 4346fcaa..fb0b3858 100644 --- a/routers/gateway_clmm.py +++ b/routers/gateway_clmm.py @@ -262,6 +262,39 @@ async def get_clmm_pool_info( raise HTTPException(status_code=500, detail=f"Error getting CLMM pool info: {str(e)}") +# Sort keys each connector's upstream actually accepts, mapped to the field name it wants. +# Probed against the live APIs rather than transcribed from a docstring, because the +# docstring was wrong: "volume, tvl, feetvlratio, etc." advertised two keys that 400'd. +# Meteora (dlmm.datapi.meteora.ag) takes exactly these three — bare `volume`, `fees`, +# `fees_24h`, `apr` and `liquidity` are all rejected — and wants "field:direction". +_METEORA_SORT_KEYS = { + "tvl": "tvl", + "volume": "volume_24h", + "feetvlratio": "fee_tvl_ratio_24h", +} +# Orca takes the field alone plus a separate sortDirection. +_ORCA_SORT_KEYS = {"volume", "tvl", "fees", "rewards", "yieldovertvl"} + + +def _sort_field(connector: str, sort_key: Optional[str]) -> Optional[str]: + """Translate a sort key to what this connector's upstream accepts, or reject it here. + + An unsupported key used to travel all the way to the DEX's API, come back a bare 400, + and surface as an opaque hapi 500 — so `feetvlratio`, which the tool documented, + looked like a server fault rather than a wrong field name. + """ + if not sort_key: + return None + legal = _METEORA_SORT_KEYS if connector == "meteora" else {k: k for k in _ORCA_SORT_KEYS} + if sort_key not in legal: + raise HTTPException( + status_code=400, + detail=f"sort_key '{sort_key}' is not supported by {connector}. " + f"Supported: {', '.join(sorted(legal))}.", + ) + return legal[sort_key] + + @router.get("/clmm/pools", response_model=CLMMPoolListResponse) async def get_clmm_pools( connector: str, @@ -271,7 +304,13 @@ async def get_clmm_pools( page: int = Query(0, ge=0, description="Page number"), limit: int = Query(50, ge=1, le=100, description="Results per page (max 100)"), search_term: Optional[str] = Query(None, description="Search query to filter pools"), - sort_key: Optional[str] = Query("volume", description="Sort key (volume, tvl, etc.)"), + sort_key: Optional[str] = Query( + "tvl", + description="Sort key. Defaults to tvl: volume ranks pools by how much others " + "traded, while the LP question is how much depth is there — and on a " + "quiet pair every pool ties at zero volume, making that order " + "arbitrary. meteora: tvl, volume, feetvlratio. " + "orca: tvl, volume, fees, rewards, yieldovertvl."), order_by: Optional[str] = Query("desc", description="Sort order (asc, desc)"), include_unknown: bool = Query(True, description="Include pools with unverified tokens"), accounts_service: AccountsService = Depends(get_accounts_service) @@ -287,7 +326,8 @@ async def get_clmm_pools( page: Page number (default: 0) limit: Results per page (default: 50, max: 100) search_term: Search query to filter pools (optional) - sort_key: Sort by field (volume, tvl, etc.) + sort_key: Sort by field. Defaults to tvl — see the parameter description for + why, and for the keys each connector accepts. order_by: Sort order (asc, desc) include_unknown: Include pools with unverified tokens @@ -313,15 +353,16 @@ async def get_clmm_pools( # The two fetch-pools routes take different params: meteora paginates and # filters via page/includeUnverified with "field:direction" sortBy; orca does # not paginate and uses sortBy + sortDirection + verifiedOnly. + sort_field = _sort_field(connector.lower(), sort_key) + if connector.lower() == "meteora": - time_suffix = "_24h" if sort_key in ["volume", "fees"] else "" direction = order_by if order_by else "desc" gateway_data = check_gateway_error(await accounts_service.gateway_client.clmm_fetch_pools( connector="meteora", chain_network=f"solana-{network}", limit=limit, query=search_term, - sort_by=f"{sort_key}{time_suffix}:{direction}" if sort_key else None, + sort_by=f"{sort_field}:{direction}" if sort_field else None, page=page, include_unverified=include_unknown )) @@ -337,7 +378,7 @@ async def get_clmm_pools( chain_network=f"solana-{network}", limit=limit, query=search_term, - sort_by=sort_key, + sort_by=sort_field, sort_direction=order_by, verified_only=not include_unknown )) diff --git a/test/test_pool_sort_keys.py b/test/test_pool_sort_keys.py new file mode 100644 index 00000000..6491ea1b --- /dev/null +++ b/test/test_pool_sort_keys.py @@ -0,0 +1,113 @@ +"""Pool discovery must rank by depth, and must reject a key the DEX will not take (GW-46). + +Two defects, both found on UMBRA-USDC/meteora on 2026-08-20. + +**The default was `volume`.** On a token whose DLMM pools are all idle every row ties at +volume_24h = 0.00, so the order is arbitrary and liquidity is never consulted. Of 73 pools: + + pool TVL rank under volume rank under tvl + 3WLPDnHp... 15.34K 68 of 73 1 + HHHKtpPp... 1.07 47 of 73 5 + +Reading top-down, an agent picked the pool holding $1.07 over one holding four orders of +magnitude more, and reported the deep one as "not found" — it was at row 68. Volume ranks +pools by how much OTHERS traded; the LP question is how much depth is there. It is also +the field most likely to be uniformly zero, and a sort key that collapses to noise is +worse than one that merely ranks differently than you wanted. + +**The documented keys did not all work.** The tool advertised "volume, tvl, feetvlratio, +etc.". Probed against the live upstreams: + + meteora (dlmm.datapi.meteora.ag) tvl OK volume_24h OK fee_tvl_ratio_24h OK + fees_24h 400 apr 400 liquidity 400 volume 400 + orca tvl, volume, fees, rewards, yieldovertvl OK + liquidity 400 + +So `feetvlratio` was real but under another name, and hapi's own `_24h` suffixing turned +`fees` into `fees_24h`, which Meteora rejects outright. Both surfaced as an opaque hapi +500, which reads as a server fault rather than a wrong field name. +""" +import inspect + +import pytest +from fastapi import HTTPException + +from routers.gateway_clmm import _METEORA_SORT_KEYS, _ORCA_SORT_KEYS, _sort_field, get_clmm_pools + + +class TestTheDefault: + def test_pools_are_ranked_by_depth_not_by_what_others_traded(self): + default = inspect.signature(get_clmm_pools).parameters["sort_key"].default + + assert default.default == "tvl", ( + "ranking by volume buries the deepest pool whenever a pair is quiet" + ) + + +class TestMeteora: + def test_volume_asks_the_upstream_for_the_field_it_actually_has(self): + """Bare `volume` is a 400 from Meteora; the field is volume_24h.""" + assert _sort_field("meteora", "volume") == "volume_24h" + + def test_tvl_passes_through(self): + assert _sort_field("meteora", "tvl") == "tvl" + + def test_the_documented_ratio_key_now_resolves_to_a_real_field(self): + """feetvlratio was advertised and 400'd. The field exists under another name.""" + assert _sort_field("meteora", "feetvlratio") == "fee_tvl_ratio_24h" + + def test_fees_is_refused_here_rather_than_400ing_upstream(self): + """Meteora has no fees sort, and hapi's own _24h suffixing made it `fees_24h`, + which the API rejects. That arrived as an opaque 500.""" + with pytest.raises(HTTPException) as raised: + _sort_field("meteora", "fees") + + assert raised.value.status_code == 400 + assert "fees" in raised.value.detail + + @pytest.mark.parametrize("key", ["apr", "liquidity", "lm_apr", "fees_24h", "nonsense"]) + def test_every_other_key_the_upstream_rejects_is_refused_here(self, key): + with pytest.raises(HTTPException) as raised: + _sort_field("meteora", key) + + assert raised.value.status_code == 400 + + def test_the_refusal_names_the_keys_that_do_work(self): + """A 400 that does not say what IS legal just moves the guessing.""" + with pytest.raises(HTTPException) as raised: + _sort_field("meteora", "liquidity") + + for legal in _METEORA_SORT_KEYS: + assert legal in raised.value.detail + assert "meteora" in raised.value.detail + + +class TestOrca: + @pytest.mark.parametrize("key", sorted(_ORCA_SORT_KEYS)) + def test_every_key_orca_accepts_passes_through_unchanged(self, key): + """Orca takes the field alone, with direction as a separate parameter.""" + assert _sort_field("orca", key) == key + + def test_liquidity_is_refused(self): + """Probed: orca 400s on it, same as meteora.""" + with pytest.raises(HTTPException) as raised: + _sort_field("orca", "liquidity") + + assert raised.value.status_code == 400 + + def test_a_meteora_only_key_is_not_silently_accepted(self): + """feetvlratio is real on meteora and not on orca; the sets are per connector.""" + with pytest.raises(HTTPException): + _sort_field("orca", "feetvlratio") + + +class TestNoSortRequested: + def test_no_key_means_no_sort_parameter_rather_than_an_error(self): + assert _sort_field("meteora", None) is None + assert _sort_field("orca", "") is None + + +def test_the_two_connectors_do_not_share_one_list(): + """They genuinely differ — orca has fees and rewards, meteora has feetvlratio — and + merging them would re-create the failure this fixes, in the other direction.""" + assert set(_METEORA_SORT_KEYS) != _ORCA_SORT_KEYS From dd9abcf624449ce572b60e7246f47200c3f67651 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Thu, 20 Aug 2026 21:06:05 -0700 Subject: [PATCH 45/54] fix(lp_rebalancer): stop passing an argument the wheel deliberately removed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deploying the controller produced, once, in the bot's own log file: ERROR - Error adding controller: parse_provider() got an unexpected keyword argument 'default_trading_type' and then the bot came up looking healthy with no controller at all — status "stopped", controller "N/A", four cheerful INFO lines about the network connecting. strategy_v2_base catches the failure and carries on, so nothing said the strategy was empty. The wheel dropped that argument on purpose in 5406f6e26: "the trading type is never defaulted — Gateway rejects a guessed one with a 400, so an untyped provider must fail here rather than mid-operation". This caller was not updated with it. The default was doing nothing anyway: lp_provider defaults to "orca/clmm" and the deployed value was "meteora/clmm", both already typed. The copy in the hummingbot repo had been updated and this one had not — and this is the copy that runs, because hummingbot-api bind-mounts bots/controllers over /home/hummingbot/controllers, shadowing the image's. The test constructs every package-style controller. Importing was never enough to catch this: the module imported fine and the config class resolved fine. Only construction runs __init__, where a caller and a signature meet. bots/archived/ joins bots/instances/ in .gitignore. hummingbot-api moves a stopped bot's whole working directory there, credentials included — conf/connectors/*.yml carries encrypted API keys and conf/.password_verification the password check — so `git add -A` sweeps the lot without that line. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK --- .gitignore | 5 + .../generic/lp_rebalancer/lp_rebalancer.py | 4 +- test/test_controllers_instantiate.py | 95 +++++++++++++++++++ 3 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 test/test_controllers_instantiate.py diff --git a/.gitignore b/.gitignore index dcfc78e0..f2a42748 100644 --- a/.gitignore +++ b/.gitignore @@ -171,6 +171,11 @@ gateway-files/ # Hummingbot credentials and local data bots/credentials/ bots/instances/ +# Archived bot instances: hummingbot-api moves a stopped bot's whole working +# directory here, credentials included — conf/connectors/*.yml holds encrypted +# API keys and conf/.password_verification the password check. Nothing under it +# belongs in git, and `git add -A` will sweep the lot without this line. +bots/archived/ bots/conf/ # Local MCP configuration (project-specific overrides) diff --git a/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py b/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py index c8cd6a6b..dabec86e 100644 --- a/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py +++ b/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py @@ -174,9 +174,7 @@ def __init__(self, config: LPRebalancerConfig, *args, **kwargs): self.config: LPRebalancerConfig = config # Parse lp_provider into dex_name and trading_type for gateway calls - self.lp_dex_name, self.lp_trading_type = parse_provider( - config.lp_provider, default_trading_type="clmm" - ) + self.lp_dex_name, self.lp_trading_type = parse_provider(config.lp_provider) # Parse token symbols from trading pair parts = config.trading_pair.split("-") diff --git a/test/test_controllers_instantiate.py b/test/test_controllers_instantiate.py new file mode 100644 index 00000000..b65a9543 --- /dev/null +++ b/test/test_controllers_instantiate.py @@ -0,0 +1,95 @@ +"""A controller must actually construct against the wheel it runs on. + +`lp_rebalancer.__init__` called `parse_provider(config.lp_provider, +default_trading_type="clmm")`. The wheel had dropped that argument on purpose — +"the trading type is never defaulted: Gateway rejects a guessed one with a 400, so an +untyped provider must fail here rather than mid-operation" — and this caller was not +updated with it. Deploying the bot produced: + + ERROR - Error adding controller: + parse_provider() got an unexpected keyword argument 'default_trading_type' + +and then, because strategy_v2_base catches that and carries on, the bot came up healthy +with no controller at all: status "stopped", controller "N/A", and four cheerful INFO +lines about the network connecting. Nothing said the strategy was empty. + +Importing the module is not enough to catch it — the module imported fine and the config +class resolved fine. Only construction runs `__init__`, which is where a caller and a +signature meet. +""" +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +pytest.importorskip("hummingbot") + +from utils.file_system import fs_util # noqa: E402 + +CONTROLLERS = Path(__file__).resolve().parent.parent / "bots" / "controllers" + +# Enough of a config for each controller type to construct. Only controllers that need +# more than the base fields need an entry. +EXTRA_FIELDS = { + "lp_rebalancer": { + "connector_name": "solana-mainnet-beta", + "trading_pair": "ANSEM-USDC", + "lp_provider": "meteora/clmm", + "pool_address": "BetLT47eFXDZnjM1cmZhQ4oNJkYaPZYH5yv6atfPfAri", + "total_amount_quote": 100, + }, +} + + +def _package_controllers(): + """Controllers shipped as a package: a directory holding __init__.py AND a module of + its own name, which is what makes it importable as `.`. `examples/` has an + __init__.py and no such module, so it is a folder of controllers rather than one. + lp_rebalancer is the only package controller today.""" + return sorted( + (path.parent.parent.name, path.parent.name) + for path in CONTROLLERS.rglob("*/__init__.py") + if path.parent.parent.name in {"generic", "directional_trading", "market_making"} + and (path.parent / f"{path.parent.name}.py").exists() + ) + + +def test_the_discovery_finds_the_package_controller(): + """A glob that quietly matched nothing would make the parametrised test vacuous.""" + assert ("generic", "lp_rebalancer") in _package_controllers() + + +@pytest.mark.parametrize("controller_type,name", _package_controllers()) +def test_a_controller_constructs_against_the_installed_wheel(controller_type, name): + config_class = fs_util.load_controller_config_class(controller_type, name) + assert config_class is not None, f"{name} resolves to no config class" + + fields = {"id": "test", "controller_name": name, **EXTRA_FIELDS.get(name, {})} + config = config_class(**fields) + + # market_data_provider and actions_queue are the two the base class takes. + controller = config.get_controller_class()(config, MagicMock(), MagicMock()) + + assert controller is not None + + +def test_lp_rebalancer_reads_the_provider_it_was_given(): + """The specific call that broke: the type comes from lp_provider, never a default.""" + config_class = fs_util.load_controller_config_class("generic", "lp_rebalancer") + config = config_class(id="test", controller_name="lp_rebalancer", + **EXTRA_FIELDS["lp_rebalancer"]) + + controller = config.get_controller_class()(config, MagicMock(), MagicMock()) + + assert (controller.lp_dex_name, controller.lp_trading_type) == ("meteora", "clmm") + + +def test_an_untyped_provider_is_refused_rather_than_guessed(): + """Why the wheel dropped the default: Gateway 400s on a guessed trading type, so a + provider with no type has to fail at construction, not mid-operation.""" + config_class = fs_util.load_controller_config_class("generic", "lp_rebalancer") + fields = {**EXTRA_FIELDS["lp_rebalancer"], "lp_provider": "meteora"} + config = config_class(id="test", controller_name="lp_rebalancer", **fields) + + with pytest.raises(ValueError, match="expected 'name/type'"): + config.get_controller_class()(config, MagicMock(), MagicMock()) From 9f1be34f26f5929ea94704a6aa7b7eaed5680df1 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Thu, 20 Aug 2026 21:12:19 -0700 Subject: [PATCH 46/54] chore: untrack every bot conf directory, and ignore them at any depth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bot's conf/ holds credentials whatever state the bot is in: conf/connectors/ *.yml carries encrypted API keys and conf/.password_verification the password check. bots/instances/ was ignored and bots/archived/ was not, so 61 files from six archived bots — including XRPL and Gate.io connector configs — were tracked, and are on the public remote. Naming parent directories one at a time is how that gap opened. The rule is now `conf/`, which matches at any depth and covers a layout that does not exist yet. bots/conf/ and bots/archived/ are untracked here with `rm --cached`, so the files stay on disk and simply stop being version-controlled. This does NOT remove them from history — already-pushed commits still carry them. Rotating the affected keys is the step that does not depend on a history rewrite. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK --- .gitignore | 8 +++++++- bots/conf/controllers/.gitignore | 0 bots/conf/scripts/.gitignore | 0 3 files changed, 7 insertions(+), 1 deletion(-) delete mode 100644 bots/conf/controllers/.gitignore delete mode 100644 bots/conf/scripts/.gitignore diff --git a/.gitignore b/.gitignore index f2a42748..baf21b4a 100644 --- a/.gitignore +++ b/.gitignore @@ -176,7 +176,13 @@ bots/instances/ # API keys and conf/.password_verification the password check. Nothing under it # belongs in git, and `git add -A` will sweep the lot without this line. bots/archived/ -bots/conf/ +# Any bot's conf directory, at any depth and whatever its state — active, +# archived, or a layout that does not exist yet. A running bot's conf/ holds the +# same credentials an archived one does: conf/connectors/*.yml carries encrypted +# API keys and conf/.password_verification the password check. Naming the parent +# directories one at a time is how bots/archived/ was missed while +# bots/instances/ was covered. +conf/ # Local MCP configuration (project-specific overrides) .mcp.json diff --git a/bots/conf/controllers/.gitignore b/bots/conf/controllers/.gitignore deleted file mode 100644 index e69de29b..00000000 diff --git a/bots/conf/scripts/.gitignore b/bots/conf/scripts/.gitignore deleted file mode 100644 index e69de29b..00000000 From 6d39715c4ddfe6c6b81398c5e12a850cc457157c Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Fri, 21 Aug 2026 15:12:24 -0700 Subject: [PATCH 47/54] fix(executors): stop arming an LP rent retry the next line throws away _record_lp_position_rent scheduled a retry whenever the CLMM position row did not exist yet: self._lp_rent_retry_after[executor_id] = time.monotonic() + LP_RENT_RETRY_SECONDS That is right on the live path -- discovery runs on its own schedule, so an early tick finding no row just means "not filed yet", and a later tick lands it. It is a lie on the completion path. _handle_executor_completion calls this and then, three lines later, clears the very state it just set: self._lp_rent_retry_after.pop(executor_id, None) So for a position that opened and closed inside a single discovery sweep -- routine for a rebalancer against a 5-minute position_poll_interval -- the rent and refund figures had nowhere to go, and the "retry" that looked like it would catch them could never run. The warning that was supposed to cover the case was also conditional on the refund being present, so the locked-rent half went out silently. Take the pretence out: pass final=True from completion, and on that path report instead of pretending. Both figures, the position address, and why the row is missing, at ERROR -- the log is the only place they now survive, so it says so. The live path keeps backing off exactly as before. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK --- services/executor_service.py | 34 ++++++++++++++++-------- test/test_executor_position_rent.py | 40 +++++++++++++++++++++++++++-- 2 files changed, 61 insertions(+), 13 deletions(-) diff --git a/services/executor_service.py b/services/executor_service.py index fed8a957..057b596a 100644 --- a/services/executor_service.py +++ b/services/executor_service.py @@ -365,9 +365,15 @@ def _measured_rent(custom_info: Dict[str, Any], key: str) -> Optional[Decimal]: return None return amount if amount > 0 else None - async def _record_lp_position_rent(self, executor_id: str, executor: ExecutorBase) -> None: + async def _record_lp_position_rent( + self, executor_id: str, executor: ExecutorBase, *, final: bool = False + ) -> None: """Carry an LP executor's rent figures into the CLMM position table. + ``final`` marks the call made from executor completion, which is the last one + this executor will ever get: its retry state is torn down immediately after, + so there is no later tick to hand the work to. + `gateway_clmm_positions.position_rent` is written by hummingbot-api's OPEN route and `position_rent_refunded` by its CLOSE route. An executor holds its position through the wheel, talking to Gateway directly, so neither route runs: the poller @@ -415,15 +421,21 @@ async def _record_lp_position_rent(self, executor_id: str, executor: ExecutorBas return if position is None: - self._lp_rent_retry_after[executor_id] = time.monotonic() + self.LP_RENT_RETRY_SECONDS - # Only worth a warning once the figure is final: while the executor runs, the - # poller has simply not discovered the position yet and a later tick retries. - if position_rent_refunded is not None: - logger.warning( - f"LP executor {executor_id} closed position {position_address} with a rent " - f"refund of {position_rent_refunded}, but no row exists for it — the poller " - "never discovered the position, so the refund has nowhere to be recorded." - ) + if not final: + # The poller has simply not discovered the position yet; back off and let a + # later tick try again. Not worth a warning while the executor still runs. + self._lp_rent_retry_after[executor_id] = time.monotonic() + self.LP_RENT_RETRY_SECONDS + return + # Last call: scheduling a retry here would be theatre, since the caller clears + # this executor's retry state on the next line. Say plainly what was lost and + # what it was worth, so the figures can be recovered from the log. + logger.error( + f"LP executor {executor_id} finished holding position {position_address} " + f"(rent={position_rent}, refund={position_rent_refunded}), but no row exists " + "for it: the position was opened and closed inside a single discovery " + "sweep, so it was never filed and these figures are not recorded anywhere " + "but this log line." + ) return if position_rent is not None: @@ -1035,7 +1047,7 @@ async def _handle_executor_completion(self, executor_id: str): # successful close has already cleared position_address from custom_info, so this # relies on the address remembered while the executor was live. if metadata.get("executor_type") == "lp_executor": - await self._record_lp_position_rent(executor_id, executor) + await self._record_lp_position_rent(executor_id, executor, final=True) # A Gateway swap an executor made, which no /gateway/swap route ever saw. See # _record_executor_swap; non-Gateway executors carry no transaction hash and fall # straight back out. diff --git a/test/test_executor_position_rent.py b/test/test_executor_position_rent.py index 62a3c6de..5732c30d 100644 --- a/test/test_executor_position_rent.py +++ b/test/test_executor_position_rent.py @@ -251,15 +251,51 @@ async def test_a_refund_with_nowhere_to_go_is_reported(caplog): service = _service(repo) service._lp_position_addresses["e-1"] = "B7nHjtVByQ" - with caplog.at_level("WARNING"): + with caplog.at_level("ERROR"): await service._record_lp_position_rent( - "e-1", _executor(position_address=None, position_rent_refunded=0.0100572) + "e-1", + _executor(position_address=None, position_rent_refunded=0.0100572), + final=True, ) assert "B7nHjtVByQ" in caplog.text assert "0.0100572" in caplog.text +@pytest.mark.asyncio +async def test_the_last_call_does_not_schedule_a_retry_it_cannot_run(caplog): + """A position opened and closed inside one discovery sweep has no row to write to, + and completion tears this executor's retry state down on the very next line. Arming + a back-off here would read as a safety net while being unreachable code, so the + figures are reported instead — the log is the only place they survive. + """ + repo = _RecordingRepo(found=False) + service = _service(repo) + + with caplog.at_level("ERROR"): + await service._record_lp_position_rent( + "e-1", + _executor(position_address="B7nHjtVByQ", position_rent_refunded=0.0100572), + final=True, + ) + + assert "e-1" not in service._lp_rent_retry_after + assert "0.0100572" in caplog.text + + +@pytest.mark.asyncio +async def test_a_live_executor_still_backs_off_rather_than_shouting(): + """The same missing row mid-flight means only that discovery has not run yet.""" + repo = _RecordingRepo(found=False) + service = _service(repo) + + await service._record_lp_position_rent( + "e-1", _executor(position_address="B7nHjtVByQ", position_rent=0.0100572) + ) + + assert service._lp_rent_retry_after["e-1"] > 0 + + # -------------------------------------------------------------------------------------- # The wiring: the control loop is what makes any of the above run # -------------------------------------------------------------------------------------- From ee2ab6f95f0cef92526e2d7c64ef32d948abbbcf Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Fri, 21 Aug 2026 15:12:44 -0700 Subject: [PATCH 48/54] fix(startup): refuse to boot against a core older than what this API reads environment.yml installs hummingbot unpinned, because the fields this version reads -- ExecutorInfo.volume_traded_quote and the order-executor custom_info additions -- are not in a PyPI release yet. There is no version to pin to, which makes install order load-bearing: an image built before the core ships comes up against a core that does not have them. Nothing complains when that happens, which is the actual problem. The read in _persist_executor_completed wraps every figure in one broad except that logs at DEBUG and substitutes zeros: except Exception as e: logger.debug(f"Error accessing executor_info for persistence: {e}") net_pnl_quote = Decimal("0") ... So a missing attribute does not fail the API. It books every executor's pnl, fees, filled amount and volume as zero, for every executor, and says nothing above DEBUG. A deployment quietly writing zeroed accounting is worse than one that will not start. Check the surface once at startup and raise naming the field that is missing, next to the CONFIG_PASSWORD check that already fails this way. environment.yml now says why the dependency is unpinned and what the merge order has to be. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK --- environment.yml | 7 ++++ main.py | 7 ++++ test/test_core_compatibility.py | 70 +++++++++++++++++++++++++++++++++ utils/core_compatibility.py | 63 +++++++++++++++++++++++++++++ 4 files changed, 147 insertions(+) create mode 100644 test/test_core_compatibility.py create mode 100644 utils/core_compatibility.py diff --git a/environment.yml b/environment.yml index a1986575..bb1b924e 100644 --- a/environment.yml +++ b/environment.yml @@ -20,6 +20,13 @@ dependencies: - docker-py - pip - pip: + # Unpinned deliberately: the core changes this API reads (ExecutorInfo's + # volume_traded_quote, and the order-executor custom_info fields) are not in a + # PyPI release yet, so there is no version to pin to. That makes install order + # load-bearing — merge the hummingbot PR and let it ship before an image built + # from this file is expected to record volume. utils/core_compatibility.py + # refuses to start against a core that is missing them, so a wrong install is a + # startup error rather than a deployment that books zeros in silence. - hummingbot - geckoterminal-py>=0.3.1 - msgpack>=1.0.5 diff --git a/main.py b/main.py index 7aed5fac..bc493f8c 100644 --- a/main.py +++ b/main.py @@ -74,6 +74,7 @@ def patched_save_to_yml(yml_path, cm): from services.unified_connector_service import UnifiedConnectorService # noqa: E402 from services.websocket_manager import WebSocketManager # noqa: E402 from utils.bot_archiver import BotArchiver # noqa: E402 +from utils.core_compatibility import require_core_surface # noqa: E402 from utils.security import BackendAPISecurity # noqa: E402 # Set up logging configuration @@ -99,6 +100,12 @@ async def lifespan(app: FastAPI): Lifespan context manager for the FastAPI application. Handles startup and shutdown events. """ + # The installed core is unpinned (see environment.yml), so check it carries the + # fields this API reads before anything reads them — the read path substitutes + # zeros for a missing attribute and logs at DEBUG, which turns a wrong install + # into silently zeroed accounting rather than a failure. + require_core_surface() + # SEC-018: warn loudly if USERNAME/PASSWORD/CONFIG_PASSWORD are still the insecure defaults warn_if_insecure_security_defaults(settings.security) diff --git a/test/test_core_compatibility.py b/test/test_core_compatibility.py new file mode 100644 index 00000000..aa6aab1b --- /dev/null +++ b/test/test_core_compatibility.py @@ -0,0 +1,70 @@ +"""A core older than this API must stop the boot, not quietly zero the books. + +``environment.yml`` installs hummingbot unpinned because the fields this API reads are +not in a release yet, so a machine can very reasonably end up with an older core. The +read path in ``_persist_executor_completed`` wraps every figure in one broad +``except Exception`` that logs at DEBUG and substitutes ``Decimal("0")`` — so on an old +core the API does not fail, it books every executor's pnl, fees, filled amount and +volume as zero and never says why. That is the failure these tests exist to prevent. +""" +from types import SimpleNamespace + +import pytest + +pytest.importorskip("hummingbot") + +from utils.core_compatibility import REQUIRED_CORE_SURFACE, require_core_surface # noqa: E402 + + +def test_the_installed_core_carries_everything_this_api_reads(): + """Guards the real dependency: fails if the environment resolves an old core.""" + require_core_surface() + + +def test_the_list_names_the_field_the_volume_work_added(): + checked = {(path, attribute) for path, attribute, _ in REQUIRED_CORE_SURFACE} + assert ( + "hummingbot.strategy_v2.models.executors_info:ExecutorInfo", + "volume_traded_quote", + ) in checked + + +def test_a_missing_field_is_a_startup_error_naming_it(monkeypatch): + """The whole point: loud, and specific enough to act on.""" + monkeypatch.setattr( + "utils.core_compatibility.REQUIRED_CORE_SURFACE", + [("hummingbot.strategy_v2.models.executors_info:ExecutorInfo", "not_a_field", "a field from the future")], + ) + + with pytest.raises(RuntimeError) as excinfo: + require_core_surface() + + message = str(excinfo.value) + assert "not_a_field" in message + assert "older than this API requires" in message + + +def test_an_unimportable_target_is_reported_rather_than_raised_raw(monkeypatch): + monkeypatch.setattr( + "utils.core_compatibility.REQUIRED_CORE_SURFACE", + [("hummingbot.strategy_v2.models.nope:Gone", "anything", "a module that is not there")], + ) + + with pytest.raises(RuntimeError) as excinfo: + require_core_surface() + + assert "could not be imported" in str(excinfo.value) + + +def test_a_plain_class_is_checked_by_attribute_not_model_fields(monkeypatch): + """ExecutorBase is not a pydantic model, so `model_fields` must not be assumed.""" + monkeypatch.setattr( + "utils.core_compatibility._resolve", + lambda path: SimpleNamespace(volume_traded_quote=property(lambda self: None)), + ) + monkeypatch.setattr( + "utils.core_compatibility.REQUIRED_CORE_SURFACE", + [("anything:AtAll", "volume_traded_quote", "present as an attribute")], + ) + + require_core_surface() diff --git a/utils/core_compatibility.py b/utils/core_compatibility.py new file mode 100644 index 00000000..7cd476ea --- /dev/null +++ b/utils/core_compatibility.py @@ -0,0 +1,63 @@ +"""Refuse to start against a hummingbot core that predates what this API reads. + +``environment.yml`` installs ``hummingbot`` unpinned from PyPI, because the changes this +version depends on are not in a release yet. That makes the install order load-bearing: +build the image before the core ships and the API comes up against a core without the +fields it reads. + +Nothing complains when that happens. ``_persist_executor_completed`` reads the executor's +figures inside a broad ``except Exception`` that logs at DEBUG and substitutes zeros, so a +missing attribute does not fail — it books every executor's pnl, fees, filled amount and +volume as 0 and says nothing. A whole deployment of silently zeroed accounting is a worse +outcome than not booting, so this check turns that into a startup error naming the field. + +Drop a name from here once the release carrying it is the oldest one this API supports. +""" +from typing import List, Tuple + +# (import path, attribute, what it is for) — each one a field this API reads off the +# core and cannot substitute. +REQUIRED_CORE_SURFACE: List[Tuple[str, str, str]] = [ + ( + "hummingbot.strategy_v2.models.executors_info:ExecutorInfo", + "volume_traded_quote", + "volume an executor generated, as distinct from the capital it deposited", + ), + ( + "hummingbot.strategy_v2.executors.executor_base:ExecutorBase", + "volume_traded_quote", + "the executor-side source of that figure", + ), +] + + +def _resolve(path: str): + module_path, _, name = path.partition(":") + module = __import__(module_path, fromlist=[name]) + return getattr(module, name) + + +def require_core_surface() -> None: + """Raise if the installed hummingbot is missing anything this API reads.""" + missing = [] + for path, attribute, purpose in REQUIRED_CORE_SURFACE: + try: + owner = _resolve(path) + except (ImportError, AttributeError) as e: + missing.append(f" - {path} could not be imported ({e})") + continue + fields = getattr(owner, "model_fields", None) + present = attribute in fields if fields is not None else hasattr(owner, attribute) + if not present: + missing.append(f" - {path}.{attribute} — {purpose}") + + if missing: + import hummingbot + + raise RuntimeError( + "The installed hummingbot core is older than this API requires. Missing:\n" + + "\n".join(missing) + + f"\n\nInstalled hummingbot: {getattr(hummingbot, '__version__', 'unknown')}. " + "Install a core that carries these fields (see environment.yml) and rebuild. " + "Starting anyway would record every executor's pnl, fees and volume as zero." + ) From 5d8bedf4718ac322bcd3be7abaf081ccc880bc31 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Fri, 21 Aug 2026 15:18:01 -0700 Subject: [PATCH 49/54] fix(gateway): token search matches address, not just symbol and name GET /gateway/networks/{network_id}/tokens?search= filtered on symbol and name only, so searching by the address returned nothing for a token that is registered at exactly that address: search=AVICI -> AVICI, address BANKJmvh... search=BANKJmvh -> {"tokens": []} search=DpBzjtg -> {"tokens": []} (DOGE-1 is registered there) The address is what a caller usually holds: a user pastes a mint. The false negative reads as "Gateway does not know this token", which invites a duplicate add -- and an add needs decimals, so it invites guessing them too. That is how an agent came to decide it had to register a token that was already present. Gateway itself matches all three (token-service.ts listTokens: symbol, name, address), and its deployed build does too. This endpoint does not forward the search term -- it fetches the full list and re-filters -- so the two have to agree by hand, and this half had drifted. Address matching restores parity; the comment says why the duplication exists so the next edit keeps them in step. --- routers/gateway.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/routers/gateway.py b/routers/gateway.py index cab5cbc7..80242cf5 100644 --- a/routers/gateway.py +++ b/routers/gateway.py @@ -530,7 +530,7 @@ async def get_network_tokens( Args: network_id: Network ID in format 'chain-network' (e.g., 'solana-mainnet-beta') - search: Filter tokens by symbol or name + search: Filter tokens by symbol, name, or address (case-insensitive substring) Example: GET /gateway/networks/solana-mainnet-beta/tokens?search=USDC """ @@ -546,13 +546,20 @@ async def get_network_tokens( chain, network = parts result = check_gateway_error(await accounts_service.gateway_client.get_tokens(chain, network)) - # Apply search filter + # Apply search filter. Address is matched as well as symbol and name, because + # the address is what a caller usually has: a user pastes a mint, and a search + # that only knows symbols answers "no such token" for one that is registered. + # That false negative reads as "not in Gateway" and invites a duplicate add. + # Gateway's own /tokens filter already matches all three (token-service.ts + # listTokens); this re-implements it here because the search term is not + # forwarded, so the two have to agree by hand. if search and "tokens" in result: search_lower = search.lower() result["tokens"] = [ token for token in result["tokens"] if (search_lower in token.get("symbol", "").lower() or - search_lower in token.get("name", "").lower()) + search_lower in token.get("name", "").lower() or + search_lower in token.get("address", "").lower()) ] return result From 4e5b910f514931c6f7556ebab91ac0e22aa00b2c Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Fri, 21 Aug 2026 15:20:38 -0700 Subject: [PATCH 50/54] test(controllers): ask the filesystem which controllers exist test_controller_config_class_loading pinned a hardcoded list of controllers, and the list had drifted from the repo. It named ema_trend_v1, which is not a controller here and never has been: $ git log --all -- '*ema_trend_v1*' (nothing) So two tests failed on a clean checkout -- one parametrised case and the end-to-end instantiation, which hardcoded the same name -- for a reason that had nothing to do with the resolver they exist to cover. hapi's CI only builds images, so nothing caught it. The bug is a property of the resolution mechanism, not of any one controller, so discover them the way /controllers/ does and cover whatever is present -- 20 today, whatever tomorrow. Two guards keep that from going quietly vacuous: one asserts discovery found anything at all, and one asserts at least one config class still sorts after its base, since those are the only ones the name-ordering bug ever bit. The instantiation test is replaced rather than re-pointed. Constructing 20 controllers means satisfying 20 sets of validators -- a default of None on a non-Optional bool, a list whose length must match a sibling's -- which tests those validators, not resolution. What it was really demonstrating is that a base class rejects controller-specific fields, so that is now asserted directly against both bases, and per controller the resolved class is asserted to declare fields its base does not. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GuR4iDtH4ZfT51rJfs3DhK --- test/test_controller_config_class_loading.py | 159 ++++++++++++++----- 1 file changed, 117 insertions(+), 42 deletions(-) diff --git a/test/test_controller_config_class_loading.py b/test/test_controller_config_class_loading.py index 16c368c0..03b32899 100644 --- a/test/test_controller_config_class_loading.py +++ b/test/test_controller_config_class_loading.py @@ -8,8 +8,10 @@ members sorted by name, whichever name sorted first won. That silently resolved every controller whose config class sorts after its base: -ema_trend_v1 and supertrend_v1 and macd_bb_v1 -> DirectionalTradingControllerConfigBase, -pmm_simple and pmm_dynamic -> MarketMakingControllerConfigBase. Since the bases set +supertrend_v1 and macd_bb_v1 -> DirectionalTradingControllerConfigBase, pmm_simple and +pmm_dynamic -> MarketMakingControllerConfigBase. (The report also named ema_trend_v1, +which is not a controller in this repo -- it is left out here so nobody adds it back to +a fixture list.) Since the bases set extra="forbid", /config/validate then rejected every controller-specific field (ema_fast, ema_slow, adx_period, ...) as "Extra inputs are not permitted", and /config/template advertised only base fields. Controllers whose names happen to sort @@ -21,7 +23,9 @@ from hummingbot.strategy_v2.controllers.controller_base import ControllerConfigBase from hummingbot.strategy_v2.controllers.directional_trading_controller_base import DirectionalTradingControllerConfigBase from hummingbot.strategy_v2.controllers.market_making_controller_base import MarketMakingControllerConfigBase +from pydantic import ValidationError +from models import ControllerType from utils.file_system import fs_util BASE_CLASSES = { @@ -30,29 +34,55 @@ MarketMakingControllerConfigBase.__name__, } -# Controllers whose config class name sorts AFTER its own base class name -- the exact -# set that the name-ordering bug used to resolve to a base class. -SORTS_AFTER_ITS_BASE = [ - ("directional_trading", "ema_trend_v1", "EmaTrendV1Config"), - ("directional_trading", "macd_bb_v1", "MACDBBV1ControllerConfig"), - ("directional_trading", "supertrend_v1", "SuperTrendConfig"), - ("market_making", "pmm_simple", "PMMSimpleConfig"), - ("market_making", "pmm_dynamic", "PMMDynamicControllerConfig"), -] -# Controllers that sorted before their base and so worked even with the bug -- kept here -# so a future "fix" cannot regress them. -SORTS_BEFORE_ITS_BASE = [ - ("directional_trading", "bollinger_v1", "BollingerV1ControllerConfig"), - ("directional_trading", "dman_v3", "DManV3ControllerConfig"), -] - - -@pytest.mark.parametrize( - "controller_type,controller_name,expected", - SORTS_AFTER_ITS_BASE + SORTS_BEFORE_ITS_BASE, -) -def test_resolves_the_concrete_config_class(controller_type, controller_name, expected): +def _discover_controllers(): + """Every controller actually present, the same way /controllers/ enumerates them. + + This used to be a hardcoded list, which drifted: it named ema_trend_v1, a controller + that is not in this repo, so two tests failed on a checkout for a reason that had + nothing to do with the resolver they were testing. The bug being covered is a + property of the resolution mechanism, not of any one controller, so ask the + filesystem instead and cover whatever is there. + """ + discovered = [] + for controller_type in ControllerType: + type_path = f"controllers/{controller_type.value}" + try: + files = fs_util.list_files(type_path) + folders = fs_util.list_folders(type_path) + except FileNotFoundError: + continue + discovered.extend( + (controller_type.value, f[:-3]) + for f in files + if f.endswith(".py") and f != "__init__.py" + ) + # Package-style: a folder holding a same-named module. + discovered.extend( + (controller_type.value, folder) + for folder in folders + if folder != "__pycache__" + and f"{folder}.py" in (fs_util.list_files(f"{type_path}/{folder}") or []) + ) + return sorted(discovered) + + +CONTROLLERS = _discover_controllers() + + +def _sorts_after_its_base(config_class) -> bool: + """Whether getmembers()' name ordering would have put a base ahead of this class.""" + bases = [b.__name__ for b in config_class.__mro__[1:] if b.__name__ in BASE_CLASSES] + return any(config_class.__name__ > base for base in bases) + + +def test_there_are_controllers_to_check(): + """Guard the parametrisation: an empty discovery would pass everything vacuously.""" + assert CONTROLLERS, "no controllers discovered under bots/controllers/" + + +@pytest.mark.parametrize("controller_type,controller_name", CONTROLLERS) +def test_resolves_the_concrete_config_class(controller_type, controller_name): config_class = fs_util.load_controller_config_class(controller_type, controller_name) assert config_class is not None, f"{controller_name} did not resolve to any config class" @@ -60,26 +90,71 @@ def test_resolves_the_concrete_config_class(controller_type, controller_name, ex f"{controller_name} resolved to the base class {config_class.__name__}; " "controller-specific fields would be rejected as 'Extra inputs are not permitted'" ) - assert config_class.__name__ == expected - - -def test_controller_specific_fields_are_accepted_by_the_resolved_class(): - """The end-to-end symptom: /config/validate instantiates the resolved class.""" - config_class = fs_util.load_controller_config_class("directional_trading", "ema_trend_v1") - - config = config_class( - id="ema_eth_5_55", - controller_name="ema_trend_v1", - controller_type="directional_trading", - connector_name="binance_perpetual", - trading_pair="ETH-USDT", - interval="15m", - ema_fast=5, - ema_slow=55, + + +def test_the_ordering_trap_is_still_represented(): + """The bug only ever bit controllers whose config name sorts after its base. + + If a repo ever held none of those, every assertion above would still pass while + covering nothing, so say so out loud rather than going quietly vacuous. + """ + trapped = [ + name for controller_type, name in CONTROLLERS + if (cls := fs_util.load_controller_config_class(controller_type, name)) is not None + and _sorts_after_its_base(cls) + ] + assert trapped, ( + "no controller here has a config class that sorts after its base, so nothing " + "in this file exercises the name-ordering bug any more" ) - assert config.ema_fast == 5 - assert config.ema_slow == 55 + +def _own_fields(config_class): + """The controller's own fields -- the ones a base class rejects as extra.""" + base = next(b for b in config_class.__mro__[1:] if b.__name__ in BASE_CLASSES) + return set(config_class.model_fields) - set(base.model_fields) + + +@pytest.mark.parametrize("controller_type,controller_name", CONTROLLERS) +def test_the_resolved_class_declares_the_controllers_own_fields(controller_type, controller_name): + """A base declares none of them, which is what made resolving to one fatal.""" + config_class = fs_util.load_controller_config_class(controller_type, controller_name) + + if config_class.__name__ == "PMMSimpleConfig": + # Genuinely adds nothing to MarketMakingControllerConfigBase, so it has no own + # field that could have been rejected. Named rather than skipped by a rule, so a + # controller that loses its fields by accident still fails here. + assert not _own_fields(config_class) + return + + assert _own_fields(config_class), ( + f"{controller_name} resolved to {config_class.__name__}, which declares nothing " + "its base does not -- that is what a base class looks like" + ) + + +@pytest.mark.parametrize( + "base", + [DirectionalTradingControllerConfigBase, MarketMakingControllerConfigBase], +) +def test_a_base_class_rejects_controller_specific_fields(base): + """Why resolving to a base was fatal rather than merely wrong. + + The bases set extra="forbid", so /config/validate answered "Extra inputs are not + permitted" for every controller-specific field and /config/template advertised only + base fields. This pins the mechanism: if a base ever stopped forbidding extras, the + resolution tests above would still pass while the bug they cover became invisible. + """ + with pytest.raises(ValidationError) as excinfo: + base( + id="pinned", + connector_name="binance_perpetual", + trading_pair="ETH-USDT", + a_controller_specific_field=5, + ) + + assert "a_controller_specific_field" in str(excinfo.value) + assert "Extra inputs are not permitted" in str(excinfo.value) def test_unknown_controller_still_returns_none(): From d71e7cf38c4ce730554d891aecbd3450078e9669 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Fri, 21 Aug 2026 16:04:42 -0700 Subject: [PATCH 51/54] feat(gateway): pass connector and type through to pools/save Gateway needs GeckoTerminal for exactly one thing on this route: deciding which DEX an address belongs to, and whether it is amm or clmm. The pool's base, quote and fee always come from the connector afterwards. Gateway now accepts connector and type to answer that question directly, so this forwards them. It matters because the callers most likely to save a pool already know the answer. An lp_executor holds lp_provider 'meteora/clmm' and a pool address -- everything the fast path needs -- and the pools it opens against are the newest ones, which are exactly the ones GeckoTerminal is least likely to have indexed. Both or neither: one without the other is a 400 rather than a half-honoured request that silently falls back to the lookup, and type is checked against amm/clmm here so a typo fails with the legal values instead of surfacing from Gateway as something vaguer. --- routers/gateway.py | 30 ++++++++++++++++++++++++------ services/gateway_client.py | 24 +++++++++++++++++++----- 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/routers/gateway.py b/routers/gateway.py index 80242cf5..cd82b86f 100644 --- a/routers/gateway.py +++ b/routers/gateway.py @@ -880,27 +880,45 @@ async def add_network_pool( async def save_network_pool( network_id: str, pool_address: str, + connector: Optional[str] = Query(default=None), + type: Optional[str] = Query(default=None), accounts_service: AccountsService = Depends(get_accounts_service) ) -> Dict: """ - Save a pool by address using GeckoTerminal lookup. - This automatically fetches pool info and token info from GeckoTerminal. + Save a pool by address, auto-adding any missing tokens. + + Gateway only needs GeckoTerminal to answer one question: which DEX does this + address belong to, and is it amm or clmm. The pool's base, quote and fee always + come from the connector. Pass connector and type to answer that directly and skip + the lookup — which is what a caller holding an LP provider config like + 'meteora/clmm' can always do, and what makes this work for a token or pool + GeckoTerminal has not indexed. Args: network_id: Network ID in format 'chain-network' (e.g., 'solana-mainnet-beta') pool_address: Pool contract address + connector: DEX connector ('meteora', 'raydium', 'orca', 'uniswap'). With type. + type: Pool type, 'amm' or 'clmm'. With connector. - Example: POST /gateway/networks/solana-mainnet-beta/pools/save/58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2 - - Note: This will auto-add any missing tokens to the network's token list. + Example: POST /gateway/networks/solana-mainnet-beta/pools/save/2sf5NYcY...?connector=meteora&type=clmm """ try: if not await accounts_service.gateway_client.ping(): raise HTTPException(status_code=503, detail="Gateway service is not available") + if (connector is None) != (type is None): + raise HTTPException( + status_code=400, + detail="connector and type must be given together, or both omitted", + ) + if type is not None and type not in ("amm", "clmm"): + raise HTTPException(status_code=400, detail=f"Invalid type '{type}': use 'amm' or 'clmm'") + result = await accounts_service.gateway_client.save_pool( chain_network=network_id, - address=pool_address + address=pool_address, + connector=connector, + pool_type=type, ) if result is None: diff --git a/services/gateway_client.py b/services/gateway_client.py index e5c842d2..42eb4e48 100644 --- a/services/gateway_client.py +++ b/services/gateway_client.py @@ -555,11 +555,25 @@ async def add_pool( payload["feePct"] = fee_pct return await self._request("POST", "pools", json=payload) - async def save_pool(self, chain_network: str, address: str) -> Dict: - """Save a pool by address using GeckoTerminal lookup""" - return await self._request("POST", f"pools/save/{address}", params={ - "chainNetwork": chain_network - }, json={}) + async def save_pool( + self, + chain_network: str, + address: str, + connector: Optional[str] = None, + pool_type: Optional[str] = None, + ) -> Dict: + """Save a pool by address. + + Gateway asks GeckoTerminal which DEX an address belongs to, and whether it is + amm or clmm; the pool's own facts always come from the connector. Passing + connector and pool_type answers that question directly and skips the lookup — + a caller holding an LP provider config such as 'meteora/clmm' already knows it. + """ + params = {"chainNetwork": chain_network} + if connector and pool_type: + params["connector"] = connector + params["type"] = pool_type + return await self._request("POST", f"pools/save/{address}", params=params, json={}) async def delete_pool(self, chain: str, network: str, address: str) -> Dict: """Delete a pool from Gateway's pool list""" From 3802f32f7dcbb4fd9cca57d4201c59d96bdb57bd Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Fri, 21 Aug 2026 17:48:51 -0700 Subject: [PATCH 52/54] fix(lp_rebalancer): carry the slippage ramp across executor retries The LP executor widens its tolerance on a failure Gateway attributes to slippage: 0.05 -> 0.25 -> 1.25 -> 5. But a failed OPEN ends that executor, and the replacement this controller creates started at the configured tolerance again, so the ladder never got past its first rung and max_slippage_pct was unreachable by construction. A live bot showed it exactly: 64 slippage failures on one pair, every one logging "(1/10 retries used) ... Retrying at 0.25%". Never 2/10, never 1.25%. Each attempt paid gas to repeat a bound that had already been rejected. The controller now reads the dead executor's ending slippage_pct out of custom_info and seeds the replacement with it, clamped to LPExecutorConfig's ceiling. A clean close clears it: an open and the close that follows are separate phases, and an exit should ask for near-spot execution again rather than inherit 5% because an entry once needed it. Simulated against the real next_slippage_pct: before: 0.05 -> 0.05 -> 0.05 -> 0.05 -> 0.05 -> 0.05 after: 0.05 -> 0.25 -> 1.25 -> 5 -> 5 -> 5 --- .../generic/lp_rebalancer/lp_rebalancer.py | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py b/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py index dabec86e..7a774c03 100644 --- a/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py +++ b/bots/controllers/generic/lp_rebalancer/lp_rebalancer.py @@ -1,5 +1,5 @@ import logging -from decimal import Decimal +from decimal import Decimal, InvalidOperation from typing import List, Optional from hummingbot.core.data_type.common import MarketDict, TradeType @@ -187,6 +187,14 @@ def __init__(self, config: LPRebalancerConfig, *args, **kwargs): # controller halts new position creation until it is recovered manually self._orphaned_position_address: Optional[str] = None + # Slippage tolerance carried into the next attempt after a slippage failure. + # The executor ramps 0.05 -> 0.25 -> 1.25 -> 5 within its own lifetime, but a + # failed open *ends* that executor, and the replacement this controller creates + # would start at the configured tolerance again. The ladder therefore never got + # past its first rung and max_slippage_pct was unreachable: on a thin pool that + # is 64 identical retries at 0.25%, each paying gas. None means "start fresh". + self._retry_slippage_pct: Optional[Decimal] = None + # Track amounts from last closed position (for autoswap sizing) self._last_closed_base_amount: Optional[Decimal] = None self._last_closed_quote_amount: Optional[Decimal] = None @@ -563,6 +571,9 @@ def determine_executor_actions(self) -> List[ExecutorAction]: failed_executor_side = None if executor_failed or involuntary_hold: failed_executor_side = terminated_executor.custom_info.get("side") + # Resume the ramp where the dead executor left off, so the retry is + # actually doing something different from the attempt that just failed. + self._retry_slippage_pct = self._carry_slippage(terminated_executor) # A terminal executor that still reports a position address went down on # the CLOSE side: its deposit is still on-chain (involuntary hold, or a # legacy FAILED-with-position from a force-stop). Re-opening would stack @@ -584,6 +595,9 @@ def determine_executor_actions(self) -> List[ExecutorAction]: if terminated_executor and not executor_failed and not involuntary_hold: closed_lower_price = Decimal(str(terminated_executor.custom_info.get("lower_price", 0))) closed_upper_price = Decimal(str(terminated_executor.custom_info.get("upper_price", 0))) + # A clean close ends the ramp: the next open is a new phase and asks + # for near-spot execution again, exactly as a fresh executor would. + self._retry_slippage_pct = None # Clear tracking self._current_executor_id = None @@ -719,6 +733,8 @@ def _create_executor_config(self, side: TradeType) -> Optional[LPExecutorConfig] f"base={base_amt:.6f}, quote={quote_amt:.6f}" ) + slippage_pct = self._next_slippage_pct() + return LPExecutorConfig( timestamp=self.market_data_provider.time(), connector_name=self.config.connector_name, @@ -730,6 +746,7 @@ def _create_executor_config(self, side: TradeType) -> Optional[LPExecutorConfig] base_amount=base_amt, quote_amount=quote_amt, side=side, + slippage_pct=slippage_pct, extra_params=extra_params if extra_params else None, # Key difference: set limit prices for auto-close upper_limit_price=upper_limit_price, @@ -738,6 +755,35 @@ def _create_executor_config(self, side: TradeType) -> Optional[LPExecutorConfig] keep_position=True, ) + @staticmethod + def _carry_slippage(terminated_executor) -> Optional[Decimal]: + """The tolerance a dead executor had reached, if it is worth carrying. + + Only a value the executor actually widened to is carried; a failure that never + touched slippage leaves it at the configured start, and re-seeding the + replacement with that is the same as not carrying anything. + """ + raw = terminated_executor.custom_info.get("slippage_pct") + if raw is None: + return None + try: + return Decimal(str(raw)) + except (InvalidOperation, TypeError, ValueError): + return None + + def _next_slippage_pct(self) -> Decimal: + """Tolerance for the executor about to be created. + + Clamped to LPExecutorConfig's ceiling: the config rejects a slippage_pct above + max_slippage_pct, and a carried value at the ceiling is legal but must not + exceed it. + """ + default = LPExecutorConfig.model_fields["slippage_pct"].default + ceiling = LPExecutorConfig.model_fields["max_slippage_pct"].default + if self._retry_slippage_pct is None: + return default + return min(self._retry_slippage_pct, ceiling) + def _calculate_amounts(self, side: TradeType, current_price: Decimal) -> tuple: """ Calculate base and quote amounts based on side, offset, and total_amount_quote. From cf8a1369cde0339c28d8083d409da9fdce0029cf Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Fri, 21 Aug 2026 18:25:22 -0700 Subject: [PATCH 53/54] refactor(executors): drop volume_traded_quote, filled_amount_quote is the volume Follows hummingbot f25981aa5, which collapses the two fields into one. An LP executor's filled_amount_quote now derives the volume that crossed the position from the fees it earned, instead of reporting the capital it deposited, so one field means the same thing on every executor type and this API has nothing extra to carry. Removed: the executors table column and its migration, the repository parameter, the three aggregates that summed it, and the read in executor_service. The summary now sums filled_amount_quote, which is the same figure by a shorter route. utils/core_compatibility.py keeps its guard and loses its contents. volume_traded_quote was the only entry -- the field this API read that a released hummingbot did not carry, and the reason a stock image refused to boot. There is no such field now, so REQUIRED_CORE_SURFACE is empty and the mechanism stays for the next one. Worth knowing: a stock core no longer fails at startup, but it also no longer gets caught. The old guard fired on a MISSING field; what differs now is the MEANING of one that exists, and an old core's filled_amount_quote still returns the deposit. Booting against it succeeds and reports LP volume as capital. Until the hummingbot change ships, a local core is a correctness requirement rather than a startup one. PositionHold and PositionSummary keep their own volume_traded_quote -- a position-level figure accumulated from fills, unrelated to the executor property beyond sharing a name. test_executor_volume_is_generated_not_deposited.py is replaced by test_executor_volume_is_the_filled_amount.py rather than edited: it existed to pin the split, and what is worth pinning now is that nothing re-introduces it. --- database/connection.py | 15 -- database/models.py | 1 - database/repositories/executor_repository.py | 9 +- services/executor_service.py | 11 +- test/test_core_compatibility.py | 16 +- ...cutor_volume_is_generated_not_deposited.py | 236 ------------------ ...st_executor_volume_is_the_filled_amount.py | 82 ++++++ utils/core_compatibility.py | 14 +- 8 files changed, 103 insertions(+), 281 deletions(-) delete mode 100644 test/test_executor_volume_is_generated_not_deposited.py create mode 100644 test/test_executor_volume_is_the_filled_amount.py diff --git a/database/connection.py b/database/connection.py index 45fbb308..8c4003f5 100644 --- a/database/connection.py +++ b/database/connection.py @@ -94,21 +94,6 @@ async def _run_migrations(self, conn): "gateway_amm_positions", "position_rent_refunded", "ALTER TABLE gateway_amm_positions ADD COLUMN position_rent_refunded NUMERIC(30,18)" ), - # Volume generated, split from capital deployed. Existing rows are backfilled - # from filled_amount_quote for every executor type EXCEPT lp_executor, because - # for an executor that places orders the amount it filled IS the volume it - # traded, while an LP position's filled amount is the capital it put up. The - # real volume of a historical LP position is not recoverable — its fees were - # never stored — so those rows keep 0 rather than a number that was wrong. - ( - "executors", "volume_traded_quote", - ( - "ALTER TABLE executors ADD COLUMN volume_traded_quote " - "NUMERIC(30,18) NOT NULL DEFAULT 0", - "UPDATE executors SET volume_traded_quote = filled_amount_quote " - "WHERE executor_type <> 'lp_executor'", - ), - ), ] for table, column, sql in migrations: try: diff --git a/database/models.py b/database/models.py index 55f97c5d..18bf9322 100644 --- a/database/models.py +++ b/database/models.py @@ -485,7 +485,6 @@ class ExecutorRecord(Base): # Trading volume generated. The same number as filled_amount_quote for any executor # that places orders, and deliberately not for an LP executor, whose filled amount is # the capital it deposited — depositing capital trades nothing. - volume_traded_quote = Column(Numeric(precision=30, scale=18), nullable=False, default=0) # Error tracking error_log = Column(Text, nullable=True) # JSON: last errors captured during execution diff --git a/database/repositories/executor_repository.py b/database/repositories/executor_repository.py index 6430e343..b434d9a0 100644 --- a/database/repositories/executor_repository.py +++ b/database/repositories/executor_repository.py @@ -58,7 +58,6 @@ async def update_executor( net_pnl_pct: Optional[Decimal] = None, cum_fees_quote: Optional[Decimal] = None, filled_amount_quote: Optional[Decimal] = None, - volume_traded_quote: Optional[Decimal] = None, final_state: Optional[str] = None, error_log: Optional[str] = None ) -> Optional[ExecutorRecord]: @@ -81,8 +80,6 @@ async def update_executor( executor.cum_fees_quote = cum_fees_quote if filled_amount_quote is not None: executor.filled_amount_quote = filled_amount_quote - if volume_traded_quote is not None: - executor.volume_traded_quote = volume_traded_quote if final_state is not None: executor.final_state = final_state if error_log is not None: @@ -338,7 +335,7 @@ async def get_executor_stats(self) -> Dict[str, Any]: total_pnl = pnl_result.scalar() or Decimal("0") # Total volume — the volume generated, not the capital deployed. - volume_stmt = select(func.sum(ExecutorRecord.volume_traded_quote)) + volume_stmt = select(func.sum(ExecutorRecord.filled_amount_quote)) volume_result = await self.session.execute(volume_stmt) total_volume = volume_result.scalar() or Decimal("0") @@ -409,7 +406,7 @@ async def get_performance_report( agg_stmt = select( func.coalesce(func.sum(ExecutorRecord.net_pnl_quote), Decimal(0)).label("pnl"), func.coalesce(func.sum(ExecutorRecord.cum_fees_quote), Decimal(0)).label("fees"), - func.coalesce(func.sum(ExecutorRecord.volume_traded_quote), Decimal(0)).label("vol"), + func.coalesce(func.sum(ExecutorRecord.filled_amount_quote), Decimal(0)).label("vol"), func.coalesce(func.avg(ExecutorRecord.net_pnl_pct), Decimal(0)).label("pnl_pct_avg"), func.count(ExecutorRecord.id).label("completed_count"), func.sum(case( @@ -443,7 +440,7 @@ async def get_performance_report( else_=0, )).label("running"), func.coalesce(func.sum(ExecutorRecord.net_pnl_quote), Decimal(0)).label("pnl"), - func.coalesce(func.sum(ExecutorRecord.volume_traded_quote), Decimal(0)).label("vol"), + func.coalesce(func.sum(ExecutorRecord.filled_amount_quote), Decimal(0)).label("vol"), func.coalesce(func.sum(ExecutorRecord.cum_fees_quote), Decimal(0)).label("fees"), ).where( and_(*completed_filter) diff --git a/services/executor_service.py b/services/executor_service.py index 057b596a..955d4caf 100644 --- a/services/executor_service.py +++ b/services/executor_service.py @@ -1178,7 +1178,6 @@ def _format_db_record(self, record) -> Dict[str, Any]: "net_pnl_pct": float(record.net_pnl_pct) if record.net_pnl_pct else 0.0, "cum_fees_quote": float(record.cum_fees_quote) if record.cum_fees_quote else 0.0, "filled_amount_quote": float(record.filled_amount_quote) if record.filled_amount_quote else 0.0, - "volume_traded_quote": float(record.volume_traded_quote) if record.volume_traded_quote else 0.0, "config": json.loads(record.config) if record.config else None, "custom_info": self._strip_heavy_fields( json.loads(record.final_state), record.executor_type @@ -1202,9 +1201,10 @@ def get_summary(self) -> Dict[str, Any]: active_count = len(executors) total_pnl = sum(e.get("net_pnl_quote", 0) for e in executors) - # Volume generated, not capital deployed: an LP executor's filled amount is the - # money it put up, and putting up money trades nothing. - total_volume = sum(e.get("volume_traded_quote", 0) for e in executors) + # filled_amount_quote is the volume traded on every executor type — an LP + # executor derives it from the fees it earned rather than the capital it put up, + # so this sums like with like and no separate field is needed. + total_volume = sum(e.get("filled_amount_quote", 0) for e in executors) by_type: Dict[str, int] = {} by_connector: Dict[str, int] = {} @@ -1399,14 +1399,12 @@ async def _persist_executor_completed(self, executor_id: str, executor: Executor net_pnl_pct = executor_info.net_pnl_pct cum_fees_quote = executor_info.cum_fees_quote filled_amount_quote = executor_info.filled_amount_quote - volume_traded_quote = executor_info.volume_traded_quote except Exception as e: logger.debug(f"Error accessing executor_info for persistence: {e}") net_pnl_quote = Decimal("0") net_pnl_pct = Decimal("0") cum_fees_quote = Decimal("0") filled_amount_quote = Decimal("0") - volume_traded_quote = Decimal("0") # Get custom_info directly from executor to avoid Pydantic serialization issues # with TrackedOrder and other complex types @@ -1475,7 +1473,6 @@ async def _persist_executor_completed(self, executor_id: str, executor: Executor net_pnl_pct=net_pnl_pct, cum_fees_quote=cum_fees_quote, filled_amount_quote=filled_amount_quote, - volume_traded_quote=volume_traded_quote, final_state=final_state_json, error_log=error_log_json ) diff --git a/test/test_core_compatibility.py b/test/test_core_compatibility.py index aa6aab1b..6d1c8c62 100644 --- a/test/test_core_compatibility.py +++ b/test/test_core_compatibility.py @@ -21,12 +21,16 @@ def test_the_installed_core_carries_everything_this_api_reads(): require_core_surface() -def test_the_list_names_the_field_the_volume_work_added(): - checked = {(path, attribute) for path, attribute, _ in REQUIRED_CORE_SURFACE} - assert ( - "hummingbot.strategy_v2.models.executors_info:ExecutorInfo", - "volume_traded_quote", - ) in checked +def test_the_list_is_empty_because_nothing_extra_is_required_right_now(): + """The guard stays; its contents do not. + + It exists for a core field this API reads that a released hummingbot may not carry + yet. volume_traded_quote was the last such field and is gone — filled_amount_quote + means the volume traded on every executor type, LP included — so there is nothing + to require. An empty list is the honest state, and require_core_surface() passing + trivially is the point: no patched core needed to boot. + """ + assert list(REQUIRED_CORE_SURFACE) == [] def test_a_missing_field_is_a_startup_error_naming_it(monkeypatch): diff --git a/test/test_executor_volume_is_generated_not_deposited.py b/test/test_executor_volume_is_generated_not_deposited.py deleted file mode 100644 index a907756d..00000000 --- a/test/test_executor_volume_is_generated_not_deposited.py +++ /dev/null @@ -1,236 +0,0 @@ -"""Volume is what an executor traded, not what it deposited. - -`ExecutorRecord.filled_amount_quote` was summed as `volume_total_quote`. For every -executor that places orders that is the same number — the amount it filled IS the volume. -For an LP executor it is not: its filled amount is the capital it put up, and putting up -capital trades nothing. A position that deposited $100 and never saw a swap reported $100 -of volume, and the round trip in and back out read as more. - -The volume an LP position DOES generate is derived in the wheel, from the fees it earned -(fees are a fixed fraction of the flow that paid them). This side's job is to store that -figure and aggregate it, rather than reaching for the deposit. -""" -import inspect -import re -from decimal import Decimal -from unittest.mock import MagicMock - -import pytest - -pytest.importorskip("hummingbot") - -from database.connection import AsyncDatabaseManager # noqa: E402 -from database.models import ExecutorRecord # noqa: E402 -from database.repositories.executor_repository import ExecutorRepository # noqa: E402 -from services.executor_service import ExecutorService # noqa: E402 - - -def test_the_record_has_a_column_for_volume_separate_from_filled_amount(): - columns = {c.name for c in ExecutorRecord.__table__.columns} - - assert "volume_traded_quote" in columns - # Both, not one renamed into the other: capital deployed is still a fact worth having. - assert "filled_amount_quote" in columns - - -def test_every_aggregate_sums_volume_rather_than_the_filled_amount(): - """Three places summed the wrong column; a fourth added later would too.""" - source = inspect.getsource(ExecutorRepository.get_performance_report) - summed = set(re.findall(r"func\.sum\(ExecutorRecord\.(\w+)\)", source)) - - assert "volume_traded_quote" in summed - assert "filled_amount_quote" not in summed, ( - "an aggregate is still summing the capital deployed and calling it volume" - ) - - -def _service_with(executor_info_fields): - """An ExecutorService wired to one fake executor, with nothing else running.""" - service = ExecutorService.__new__(ExecutorService) - service._executor_metadata = {"e-1": {"executor_type": "lp_executor"}} - service._log_capture = MagicMock() - service._log_capture.get_error_count.return_value = 0 - service._log_capture.get_last_error.return_value = None - - executor = MagicMock() - info = MagicMock() - dumped = {"custom_info": {}, **executor_info_fields} - info.model_dump.return_value = dumped - info.side = None - executor.executor_info = info - executor.status.name = "TERMINATED" - executor.close_type = None - executor.is_closed = True - service._active_executors = {"e-1": executor} - return service - - -def test_the_active_summary_counts_volume_generated_not_capital_deposited(): - """An LP position holding $200 of capital that has traded $2,500 through its range.""" - service = _service_with({"filled_amount_quote": 200.0, "volume_traded_quote": 2500.0}) - - summary = service.get_summary() - - assert summary["total_volume_quote"] == 2500.0 - - -def test_a_funded_position_that_traded_nothing_summarises_as_no_volume(): - service = _service_with({"filled_amount_quote": 200.0, "volume_traded_quote": 0.0}) - - summary = service.get_summary() - - assert summary["total_volume_quote"] == 0.0 - - -@pytest.mark.asyncio -async def test_completion_persists_the_volume_the_executor_reported(): - """The figure the wheel derived has to reach the row it is later summed from.""" - from contextlib import asynccontextmanager - - recorded = {} - - class _Repo: - def __init__(self, _session): - pass - - async def update_executor(self, **kwargs): - recorded.update(kwargs) - - service = ExecutorService.__new__(ExecutorService) - service._executor_metadata = {"e-1": {"executor_type": "lp_executor"}} - service._log_capture = MagicMock() - service._log_capture.get_error_count.return_value = 0 - - db_manager = MagicMock() - - @asynccontextmanager - async def session_context(): - yield MagicMock() - - db_manager.get_session_context = session_context - service.db_manager = db_manager - - executor = MagicMock() - executor.status.name = "TERMINATED" - executor.close_type = None - executor.get_custom_info.return_value = {} - info = MagicMock() - info.net_pnl_quote = Decimal("3") - info.net_pnl_pct = Decimal("0.01") - info.cum_fees_quote = Decimal("1") - info.filled_amount_quote = Decimal("200") - info.volume_traded_quote = Decimal("2500") - executor.executor_info = info - - import services.executor_service as module - original = module.ExecutorRepository - module.ExecutorRepository = _Repo - try: - await service._persist_executor_completed("e-1", executor) - finally: - module.ExecutorRepository = original - - assert recorded["volume_traded_quote"] == Decimal("2500") - # Capital deployed still stored, under its own name. - assert recorded["filled_amount_quote"] == Decimal("200") - - -def test_update_executor_accepts_it(): - parameters = inspect.signature(ExecutorRepository.update_executor).parameters - - assert "volume_traded_quote" in parameters - - -class TestTheMigration: - """create_all only creates MISSING tables, so an existing database gains a column - only through the migration list. Without the entry the column exists in the model, - every write names it, and every one of them fails against a real deployment.""" - - def _entry(self): - source = inspect.getsource(AsyncDatabaseManager._run_migrations) - match = re.search( - r'\(\s*"executors",\s*"volume_traded_quote",\s*\((.*?)\),\s*\),', source, re.DOTALL - ) - assert match, "no migration adds volume_traded_quote to executors" - return match.group(1) - - def test_it_adds_the_column(self): - assert "ALTER TABLE executors ADD COLUMN volume_traded_quote" in self._entry() - - def test_it_backfills_the_executors_whose_filled_amount_was_their_volume(self): - """For an order-placing executor the two are the same number by definition, so - history stays intact rather than resetting to zero.""" - entry = self._entry() - - assert "UPDATE executors SET volume_traded_quote = filled_amount_quote" in entry - - def test_it_leaves_lp_rows_at_zero_rather_than_backfilling_the_deposit(self): - """The one thing the backfill must NOT do. A historical LP position's real volume - is unrecoverable — its fees were never stored — and copying the deposit across - would re-enter exactly the number this change exists to remove, now looking - migrated and deliberate.""" - entry = self._entry() - - assert "executor_type <> 'lp_executor'" in entry - - def test_a_multi_statement_migration_runs_every_statement(self): - source = inspect.getsource(AsyncDatabaseManager._run_migrations) - - assert "for statement in ((sql,) if isinstance(sql, str) else sql)" in source, ( - "the runner executes a single string, so the backfill beside the ALTER never runs" - ) - - -def test_a_completed_executors_api_row_carries_the_volume(): - """What the API returns for a completed executor, read back from its row.""" - service = ExecutorService.__new__(ExecutorService) - - record = MagicMock( - executor_id="e-1", executor_type="lp_executor", account_name="master_account", - connector_name="solana-mainnet-beta", trading_pair="SOL-USDC", status="TERMINATED", - close_type="EARLY_STOP", controller_id="main", error_log=None, config=None, - final_state=None, created_at=None, closed_at=None, - net_pnl_quote=Decimal("3"), net_pnl_pct=Decimal("0.01"), cum_fees_quote=Decimal("1"), - filled_amount_quote=Decimal("200"), volume_traded_quote=Decimal("2500"), - ) - - row = service._format_db_record(record) - - assert row["volume_traded_quote"] == 2500.0 - assert row["filled_amount_quote"] == 200.0 - - -def test_decimal_precision_matches_the_filled_amount_column(): - """Same scale, because they measure the same kind of quantity.""" - volume = ExecutorRecord.__table__.columns["volume_traded_quote"].type - filled = ExecutorRecord.__table__.columns["filled_amount_quote"].type - - assert (volume.precision, volume.scale) == (filled.precision, filled.scale) - assert Decimal(10) ** -volume.scale > 0 - - -def test_the_api_response_declares_volume_so_it_reaches_a_caller(): - """FastAPI filters a response to the fields its model declares, so a figure the - service computes and the model omits is silently dropped at the boundary — the - executor knows its volume, the row stores it, and the caller never sees it. - """ - from models.executors import ExecutorResponse - - assert "volume_traded_quote" in ExecutorResponse.model_fields - assert "filled_amount_quote" in ExecutorResponse.model_fields - - -def test_a_response_carries_a_volume_that_differs_from_the_capital(): - """The pair a defect would collapse back into one number.""" - from models.executors import ExecutorResponse - - response = ExecutorResponse( - executor_id="e-1", executor_type="lp_executor", account_name="master_account", - connector_name="solana-mainnet-beta", trading_pair="SOL-USDC", status="RUNNING", - is_active=True, is_trading=True, net_pnl_quote=3.0, net_pnl_pct=0.01, - cum_fees_quote=1.0, filled_amount_quote=200.0, volume_traded_quote=2500.0, - ) - - dumped = response.model_dump() - assert dumped["volume_traded_quote"] == 2500.0 - assert dumped["filled_amount_quote"] == 200.0 diff --git a/test/test_executor_volume_is_the_filled_amount.py b/test/test_executor_volume_is_the_filled_amount.py new file mode 100644 index 00000000..6daa41d4 --- /dev/null +++ b/test/test_executor_volume_is_the_filled_amount.py @@ -0,0 +1,82 @@ +"""Volume and filled amount are one number, on every executor type. + +They used to be two. `filled_amount_quote` meant "the amount this executor filled", +which for an order-placing executor IS its volume but for an LP executor was the +CAPITAL IT DEPOSITED — so a position that put up $200 and traded nothing reported $200 +of volume the moment it opened. The fix at the time was a second field, +`volume_traded_quote`, carried through the executor, the ExecutorInfo, this API's +schema, its database and a migration. + +That second field is gone. `lp_executor.filled_amount_quote` now derives the volume +that crossed the position from the fees it earned, so one field means the same thing +everywhere and an LP executor sums like with like against every other kind. It also +removes the reason this API had to require a core surface a released hummingbot did not +carry — see utils/core_compatibility.py. + +What is left to pin here is that nothing in this API re-introduces the split by summing +the wrong column. +""" + +import inspect +import re +from unittest.mock import MagicMock + +from database.models import ExecutorRecord +from database.repositories.executor_repository import ExecutorRepository +from services.executor_service import ExecutorService + + +def test_the_record_has_no_separate_volume_column(): + assert not hasattr(ExecutorRecord, "volume_traded_quote"), ( + "the executors table grew a second volume column again; filled_amount_quote is it" + ) + + +def test_every_aggregate_sums_the_filled_amount(): + """Three aggregates summed the old column; a fourth added later would too.""" + source = inspect.getsource(ExecutorRepository.get_performance_report) + summed = set(re.findall(r"func\.sum\(ExecutorRecord\.(\w+)\)", source)) + + assert "filled_amount_quote" in summed + assert "volume_traded_quote" not in summed + + +def _service_with(executor_info_fields): + """An ExecutorService wired to one fake LP executor, with nothing else running.""" + service = ExecutorService.__new__(ExecutorService) + service._executor_metadata = {"e-1": {"executor_type": "lp_executor"}} + service._log_capture = MagicMock() + service._log_capture.get_error_count.return_value = 0 + service._log_capture.get_last_error.return_value = None + + executor = MagicMock() + info = MagicMock() + info.model_dump.return_value = {"custom_info": {}, **executor_info_fields} + info.side = None + executor.executor_info = info + executor.status.name = "TERMINATED" + executor.close_type = None + executor.is_closed = True + service._active_executors = {"e-1": executor} + return service + + +def test_the_active_summary_counts_the_volume_the_executor_reported(): + """An LP position that traded $2,500 through its range reports $2,500. + + The executor derives that from its fees; this API just sums what it is given. + """ + service = _service_with({"filled_amount_quote": 2500.0}) + + assert service.get_summary()["total_volume_quote"] == 2500.0 + + +def test_a_funded_position_that_traded_nothing_summarises_as_no_volume(): + """The original defect, still pinned: capital deposited is not volume. + + It is enforced upstream now -- an LP executor with no fees derives no volume -- so + what this asserts is that the API does not put the deposit back by another route. + """ + service = _service_with({"filled_amount_quote": 0.0}) + + assert service.get_summary()["total_volume_quote"] == 0.0 diff --git a/utils/core_compatibility.py b/utils/core_compatibility.py index 7cd476ea..1f8e3502 100644 --- a/utils/core_compatibility.py +++ b/utils/core_compatibility.py @@ -18,16 +18,10 @@ # (import path, attribute, what it is for) — each one a field this API reads off the # core and cannot substitute. REQUIRED_CORE_SURFACE: List[Tuple[str, str, str]] = [ - ( - "hummingbot.strategy_v2.models.executors_info:ExecutorInfo", - "volume_traded_quote", - "volume an executor generated, as distinct from the capital it deposited", - ), - ( - "hummingbot.strategy_v2.executors.executor_base:ExecutorBase", - "volume_traded_quote", - "the executor-side source of that figure", - ), + # Empty on purpose. This guard exists for a core field this API reads that a released + # hummingbot may not carry yet; there is no such field right now. volume_traded_quote + # was the last one, and it is gone: filled_amount_quote means the volume traded on + # every executor, including LP, so there is nothing extra to require. ] From 437f8386ee373748201abbf4e9ac0257e05bf6a2 Mon Sep 17 00:00:00 2001 From: Michael Feng Date: Fri, 21 Aug 2026 19:15:37 -0700 Subject: [PATCH 54/54] fix(models): drop the volume field the API declared but no longer fills ExecutorResponse still carried volume_traded_quote with a default of 0.0. Nothing populated it after the collapse, so every response advertised a volume of zero beside a filled_amount_quote that was the real figure -- worse than absent, because a caller reading the schema would believe it. filled_amount_quote now carries the description the removed field had: it is the volume traded on every executor type, and an LP executor reports the swaps that crossed its range rather than its deposit. The summary's total_volume_quote says it sums that field. --- models/executors.py | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/models/executors.py b/models/executors.py index ed03b771..86287659 100644 --- a/models/executors.py +++ b/models/executors.py @@ -368,8 +368,7 @@ class ExecutorResponse(BaseModel): "net_pnl_quote": 125.50, "net_pnl_pct": 2.5, "cum_fees_quote": 1.25, - "filled_amount_quote": 5000.0, - "volume_traded_quote": 5000.0 + "filled_amount_quote": 5000.0 } } ) @@ -391,16 +390,13 @@ class ExecutorResponse(BaseModel): net_pnl_quote: float = Field(description="Net PnL in quote currency") net_pnl_pct: float = Field(description="Net PnL percentage") cum_fees_quote: float = Field(description="Cumulative fees in quote currency") - filled_amount_quote: float = Field(description="Total filled amount in quote currency") - volume_traded_quote: float = Field( - default=0.0, - description="Trading volume generated, in quote currency. The same number as " - "filled_amount_quote for any executor that places orders — the amount " - "it filled IS its volume. Deliberately different for an LP executor, " - "whose filled amount is the capital it deposited: depositing capital " - "trades nothing. An LP position's volume is derived from the fees it " - "earned, which are a fixed fraction of the swaps that crossed its " - "range, and is 0 while it has earned none.") + filled_amount_quote: float = Field( + description="Volume traded, in quote currency. For an executor that places " + "orders the amount it filled IS its volume. An LP executor reports " + "the same thing rather than the capital it deposited — depositing " + "trades nothing — deriving it from the fees it earned, which are a " + "fixed fraction of the swaps that crossed its range, and is 0 while " + "it has earned none.") error_count: int = Field(default=0, description="Number of ERROR-level log entries captured") last_error: Optional[str] = Field(default=None, description="Most recent error message, if any") @@ -511,8 +507,9 @@ class ExecutorsSummaryResponse(BaseModel): total_active: int = Field(description="Number of active executors") total_pnl_quote: float = Field(description="Total PnL across active executors") total_volume_quote: float = Field( - description="Total volume traded across active executors. Volume GENERATED, not " - "capital deployed — see volume_traded_quote on an executor.") + description="Total volume traded across active executors, summing each one's " + "filled_amount_quote. Volume GENERATED, not capital deployed: an LP " + "executor reports the swaps that crossed it, not its deposit.") by_type: Dict[str, int] = Field(description="Executor count by type") by_connector: Dict[str, int] = Field(description="Executor count by connector") by_status: Dict[str, int] = Field(description="Executor count by status")