diff --git a/docs/retry-architecture.md b/docs/retry-architecture.md new file mode 100644 index 0000000000..e2b814af05 --- /dev/null +++ b/docs/retry-architecture.md @@ -0,0 +1,240 @@ +# LP Close Retry Architecture + +**Origin:** [gateway#678](https://github.com/hummingbot/gateway/issues/678) — an Orca LP close failed with Whirlpool error `6018` (`TokenMinSubceeded`, misreported as `MATH_OVERFLOW`), the LP executor went terminally `FAILED` with the position still open on-chain, and subsequent stop requests returned 404. Fixing it properly required deciding **where retries live** across Gateway, the Hummingbot connector, the LP executor, the controller, and hummingbot-api — and auditing every layer against that model. + +Auditing those layers turned up defects on the paths the LP flow depends on but that #678 never named: the transaction-status contract the poller reads, and the CLMM pool-info contract the UI and agents read. Both are documented here because they share the same root cause as #678 — a caller could not tell a definitive answer from a transient one, or could not ask for what it needed at all. + +This document describes the issues found and the architecture that now stands, spanning five repositories: [gateway#679](https://github.com/hummingbot/gateway/pull/679), [hummingbot#8424](https://github.com/hummingbot/hummingbot/pull/8424), [hummingbot-api#217](https://github.com/hummingbot/hummingbot-api/pull/217), [hummingbot-api-client#25](https://github.com/hummingbot/hummingbot-api-client/pull/25), [condor#204](https://github.com/hummingbot/condor/pull/204). + +--- + +## 1. The principle + +> **Gateway is a stateless transaction oracle.** One HTTP request = one attempt at one on-chain action, built from fresh on-chain state, answered with either a confirmed result or a **typed error**. Hummingbot holds the position and order state, so Hummingbot owns retries — at distinct altitudes, each answering a different question. + +| Layer | Question it answers | What it owns | +|---|---|---| +| **Gateway** | "Did *this one attempt* land?" | Reads and transport only: RPC 429s, confirmation polling until blockhash expiry, typed error classification, pre-broadcast simulation rejection. Never re-submits a write. | +| **Connector** (`gateway_base.py`, `gateway.py`) | "Should I ask Gateway for *another attempt*?" | `_execute_with_retry`: transport timeouts only, the same for every operation. What an operation error means is the caller's decision. | +| **LP executor** (`lp_executor.py`) | "What does this failure mean for *the position lifecycle*?" | **The close retry loop** — state-machine re-entry with a bounded budget and exponential backoff, a fresh position read before every re-submit, and the terminal close type. It passes `max_retries=0` so the connector makes exactly one request per re-entry. | +| **Controller** (`lp_rebalancer.py`) | "What does this failure mean for *the strategy*?" | Re-creates a fresh executor with freshly computed bounds; halts instead of stacking exposure over an unresolved position. | +| **hummingbot-api / condor** | "What survives the process, and who is told?" | Durable orphan records, listing and resolution endpoints, agent-facing warnings. | + +```mermaid +flowchart TB + subgraph HB["Hummingbot — stateful, owns retries"] + C["Controller: lp_rebalancer
strategy: re-create after clean FAILED,
halt over unresolved position
"] + E["LP executor: lp_executor
lifecycle: CLOSING re-entry — the ONE close retry loop,
max_retries + 1 attempts, exponential backoff (2ⁿ s, cap 30 s),
fresh position read before every re-submit
"] + K["Connector: gateway_base / gateway
operation: _execute_with_retry — transport timeouts only;
close passes max_retries=0: one request per re-entry
"] + end + subgraph GW["Gateway — stateless, one attempt per request"] + R["Route: build tx from FRESH on-chain state
quote → simulate → sign → broadcast"] + S["Chain layer: typed errors, 429 interceptor,
confirm-poll to blockhash expiry,
pre-broadcast simulation guard"] + end + A["hummingbot-api / condor
durable orphan records, stop semantics, agent visibility"] + C --> E + E --> K + K -->|"each retry = fresh rebuild"| R + R --> S + S -->|"CONFIRMED · typed error [code: X]"| K + K --> E + E -->|terminal close_type| C + E -.->|persisted final state| A +``` + +The property this preserves: **a connector-level retry gets fresh state for free**, because every re-POST re-runs the route's fetch → quote → build → simulate pipeline. A retry loop inside a Gateway route would duplicate that one layer down, per connector, invisible to the strategy, and stacking under the connector's own loop. + +--- + +## 2. Issues found + +The #678 investigation and the adversarial review of the fix surfaced the following defects. Each is fixed in this PR family unless marked as an accepted residual (§4). + +### Gateway + +1. **Misattributed on-chain errors.** Orca `6018` was missing from the error table (reported as `MATH_OVERFLOW`), and the parser attributed custom errors to the *first* program in the log — simulation logs open with a ComputeBudget prelude, so the failing DEX program's table was never consulted. Errors are now attributed to the program on the `failed: custom program error` line, and `6018` maps to `SLIPPAGE_EXCEEDED`. +2. **Doomed transactions were broadcast anyway.** The compute-estimation simulation's `err` was ignored (only `unitsConsumed` was read), so a transaction guaranteed to fail was signed, broadcast, and paid fees. Both send paths now reject a failed simulation with a typed 400 **before broadcast** — a failed attempt costs nothing. +3. **Transient read errors served as definitive closure.** `Orca.getPositionInfo` swallowed every error into `null`, which the route surfaced as the position-specific 404 — so an RPC blip inside Gateway read as "position closed". It now returns `null` only for a definitive account-does-not-exist result and rethrows everything else. +4. **A dropped transaction was indistinguishable from a pending one.** `getTransaction` (commitment `confirmed`) returns null both for a transaction awaiting confirmation and for one the cluster has never seen, and `/poll` reported both as `txStatus 0` (pending) — forever. The poll now consults the signature-status cache (with history search) and reports an unknown signature as **`NOT_FOUND` (-2)**, which is terminal once the transaction's blockhash has expired (~90 s). +5. **The two poll routes spoke different dialects.** Ethereum's poll used raw numbers including `2` ("likely to be processed") and `3` ("likely stuck") that no consumer understood, reported not-found as `-1` (failed) after blocking the request for three in-route 1-second retries, and — via `typeof receipt.status === 'number' ? 1 : -1` — reported **reverted** transactions (receipt status `0`, which is a number) as CONFIRMED, so a reverted swap polled as filled. Both routes now share one `TransactionStatusCode` contract: `NOT_FOUND (-2) / FAILED (-1) / PENDING (0) / CONFIRMED (1)`. +6. **Post-SDK-migration slippage exposure.** The migrated Orca close route quotes withdrawal minimums at the configured `slippagePct` (~1%) rather than the legacy 50% buffer — exactly the condition under which the #678 race is reachable. The guards above are load-bearing, not defense-in-depth. + +### Hummingbot connector + +7. **The connector silently decided what operation errors meant.** `SLIPPAGE_EXCEEDED`/`SIMULATION_FAILED` were classified inside `_execute_with_retry`, one size for all operations — and any attempt to make that operation-aware (an opt-in error set, a per-operation inner budget) produced two nested retry loops with multiplying budgets. The connector now retries **transport timeouts only**, identically for every operation; what an operation error means is the caller's decision, and for close the caller is the executor's single loop (issue 11). +8. **The landed-but-failed shape had no error code.** A broadcast transaction that failed on-chain raised with no `[code:]` marker at all, so callers could not classify the flagship #678 failure shape. It now raises typed `TX_NOT_CONFIRMED`. +9. **`get_position_info` swallowed every exception into `None`**, and the executor read `None` as "already closed" → `COMPLETE` — a transient RPC error during close could **abandon a live position while reporting success**. The contract is now: `None` only for a position-specific 404; everything else re-raises. Three coupled requirements: (a) the match is position-specific, because the HTTP client stamps "(Not Found)" on *every* 404 — a missing route after a redeploy read as "position gone"; (b) existence decisions use an **uncached** read (`get_position_info_fresh`) — the 5 s TTL cache stores `None` like any value, so one cached 404 masqueraded as several independent confirmations; (c) external-close detection requires the position to have been **seen on-chain at least once** plus 3 consecutive fresh misses. +10. **Pending-transaction polling was unbounded.** `update_order_status` polled any in-flight order at 1 s forever; combined with issue 4, a dropped transaction never resolved — the order never failed and burned an RPC call per second until restart. The connector now treats `NOT_FOUND` as transient while the order is younger than `TX_NOT_FOUND_DEADLINE` (120 s, past blockhash validity) and afterwards feeds each miss to the order tracker's existing lost-order machinery, which fails the order after repeated consecutive misses. `PENDING` remains unbounded by design: the chain has seen the transaction, so it can still confirm. + +### LP executor + +11. **Close failure was terminal on the first error.** `_handle_close_failure` jumped straight to `FAILED` with the position still open. It now counts the attempt, arms exponential backoff (2ⁿ s, capped 30 s — so a Gateway restart spans a few retries instead of burning the whole budget in seconds), and stays `CLOSING`; the family hook `evaluate_max_retries` decides termination after `max_retries + 1` attempts. This re-entry is the **only** close retry loop: the executor passes `max_retries=0` so the connector makes one request per attempt, and budgets cannot multiply. +12. **The wrong terminal close type.** `FAILED` in the executor family means "abnormal end with *no residual exposure*" — everything downstream (hold store, PnL, dashboards) assumes it. Terminating an exhausted close as `FAILED` forced a parallel side-channel at every layer. An exhausted close with the position still on-chain now terminates as an **involuntary `POSITION_HOLD`** with `hold_reason: "close_retries_exhausted"` and a zero-amount marker order carrying the position address — riding the existing hold machinery (DB-recovered on the API path) instead of a bespoke flag. `FAILED` is reserved for exhaustion with nothing left on-chain (e.g. an open rejected at simulation). + +### Controller, hummingbot-api, condor + +13. **The controller stacked exposure over a live position.** After a terminal executor still holding a position, `lp_rebalancer` created a fresh executor — a fresh `lp_executor` cannot adopt an existing position (it always mints a new one), so this doubled the funded exposure. It now halts and logs until the orphan is resolved. +14. **Stopping a terminal executor returned 404.** The API's completion handler pops the executor from memory within one tick, so every stop against a terminal executor hit the "unknown id" branch — the #678 dead-end. Stop is now DB-aware: any DB-known, not-in-memory executor returns `already_terminated` with its final `close_type`, `position_address`, and `hold_reason`; 404 is reserved for ids the database has never seen. +15. **Failures were invisible to agents.** The condor tick prompt listed only `RUNNING` executors, so a terminal executor with a live position *vanished from view*. The provider now surfaces orphaned executors with a warning, and `manage_executors` gained `orphaned` and `resolve_orphan` actions. +16. **Orphans had no durable record or resolution path.** The persisted final state now carries the orphan shape; `GET /executors/positions/orphaned` lists candidates (involuntary holds, legacy `FAILED`-with-position, and `SYSTEM_CLEANUP` LP executors from an API restart — the latter flagged `needs_onchain_reconciliation` since no final state was persisted); `POST /executors/{id}/resolve-orphan` marks a recovered position so it stops surfacing. + +### CLMM pool-info + +The same audit covered the read path that agents and the dashboard use to see a pool before opening a position. + +17. **The bin distribution was unreachable through the unified route.** `GET /trading/clmm/pool-info` — the route hummingbot-api and condor read through — had no `binCount` in its querystring schema and called every connector as `(fastify, network, poolAddress)`, dropping the parameter orca, raydium and uniswap already implemented. Meteora returns its bins unconditionally, so the gap looked like "only Meteora has bins" rather than "nobody can ask". The route now accepts `binCount` and forwards it, and the parameter is threaded through hummingbot-api, the API client (1.5.8) and condor. +18. **PancakeSwap had no bin support at all.** Added, reusing the V3 tick walk. The two SDKs disagree on numeric type — `@uniswap/v3-sdk` is JSBI-based, `@pancakeswap/v3-sdk` uses native `bigint` — so the walk moved to a shared `clmm-v3-utils` helper that works in `bigint`, with each connector adapting its own SDK rather than one importing the other's math. +19. **PancakeSwap reported virtual liquidity as token amounts.** Both `baseTokenAmount` and `quoteTokenAmount` came from `pool.liquidity` — V3's active liquidity in sqrt-price space, not a token quantity — scaled by each token's decimals, so the same meaningless figure was reported for both sides. Now ERC20 `balanceOf` on the pool contract, the fix Uniswap already carried. +20. **Raydium bypassed Gateway entirely.** hummingbot-api's pool-info special-cased Raydium: it skipped Gateway, called `api-v3.raydium.io` directly, and reshaped that response to imitate Gateway's. The transform hardcoded `active_bin_id` to `None`, `bin_step` to `1` and `bins` to `[]`, so Raydium silently returned degraded data and could not answer `binCount` at all. It now takes the same path as every other CLMM connector. +21. **Two default RPC endpoints were dead.** `eth.llamarpc.com` and `binance.llamarpc.com` answer nothing; Gateway logged "Unable to fetch block number" at startup and every read on those chains failed — which is how a PancakeSwap pool that plainly exists reported "Pool not found". Defaults are now `eth-mainnet.g.alchemy.com/public` and `bsc-dataseed.bnbchain.org`. + +--- + +## 3. The architecture + +### 3.1 Why close retries and open does not + +| Operation | If the outcome is uncertain and you blindly re-submit… | Blind-retry safe? | +|---|---|---| +| Swap | Second swap also executes → double spend | ❌ | +| Open position | Second position minted → duplicate exposure | ❌ | +| **Close position** | Success consumes the position account; a second close fails cleanly with "position not found" | ✅ idempotent | + +The same price move that makes a close fail (stale withdrawal minimums) also makes an open fail (stale deposit maximums), but the recovery is asymmetric: + +- **Close**: the intent — "remove whatever is in this position" — stays valid at any price. Retry with a fresh quote, at the connector (fast path) and the executor (paced re-entry). +- **Open**: the intent — a range and a base/quote split computed at the old price — is stale. The executor fails cleanly with nothing on-chain (`FAILED`), and the **controller** re-decides: the next cycle recomputes bounds around the current price and creates a fresh executor. (One guard: if the add actually landed and only the bookkeeping after it threw, the executor flips to `CLOSING` to recover the funds instead of stranding them.) + +### 3.2 The close lifecycle + +```mermaid +sequenceDiagram + participant EX as LP executor + participant CN as Connector + participant GW as Gateway + participant SOL as Chain + + loop each CLOSING re-entry (≤ max_retries + 1, backoff 2ⁿ s cap 30 s) + EX->>CN: get_position_info_fresh (uncached) + alt position definitively absent (seen on-chain before + 3 fresh misses) + EX->>EX: already closed → COMPLETE + else position live or read transiently failed + EX->>CN: close position + CN->>GW: POST close-position (one request — max_retries=0) + GW->>SOL: fetch fresh state → quote → build → simulate + alt simulation fails + GW-->>CN: typed 400, pre-broadcast — no fee spent + else + GW->>SOL: broadcast + confirm + GW-->>CN: CONFIRMED or typed error + end + CN-->>EX: result or typed exception + end + end + Note over EX: budget exhausted, position still live →
involuntary POSITION_HOLD (hold_reason: close_retries_exhausted,
zero-amount marker order carrying position_address) +``` + +### 3.3 Terminal semantics + +```mermaid +stateDiagram-v2 + [*] --> OPENING + OPENING --> IN_RANGE: position minted + OPENING --> FAILED: open failed, nothing on-chain
(controller re-decides with fresh bounds) + OPENING --> CLOSING: add landed but bookkeeping threw
(recover the funds) + IN_RANGE --> CLOSING: limit price hit / stop requested + OUT_OF_RANGE --> CLOSING: limit price hit / stop requested + IN_RANGE --> OUT_OF_RANGE + OUT_OF_RANGE --> IN_RANGE + CLOSING --> CLOSING: close attempt failed,
budget remains — backoff, re-enter + CLOSING --> COMPLETE: closed (or confirmed already closed) + CLOSING --> POSITION_HOLD: budget exhausted, position live —
hold_reason set, marker order carries address + POSITION_HOLD --> [*] + COMPLETE --> [*] + FAILED --> [*] +``` + +- **`FAILED`** = abnormal end with **nothing left on-chain**. The controller may create a replacement. +- **`POSITION_HOLD` + `hold_reason`** = residual exposure that could not be closed. Voluntary holds (`keep_position=True`) and involuntary holds are the same close type, distinguished only by the reason. The zero-amount marker order makes the hold visible to the durable hold store without corrupting spot accounting (a still-open position's pool balances are not returned tokens). +- Consumers key off `hold_reason`, not close type: the rebalancer halts and skips accounting; the API's orphan listing is a view over involuntary holds; condor warns the agent. +- Exception: a **force-stop** with a live position still terminates `FAILED` — the close may be in flight at the shutdown deadline, so the legacy `FAILED`-with-position orphan class covers it. + +### 3.4 Two topologies, one orphan lifecycle + +The executor is the only layer present in both deployment topologies, which is why the bounded close re-entry lives there. + +```mermaid +flowchart TB + subgraph TA["Topology A — deployed bot"] + A0["lp_rebalancer controller"] --> A1["ExecutorOrchestrator"] --> A2["LPExecutor"] + end + subgraph TB2["Topology B — manage_executors (condor / API)"] + B0["condor agent / API user"] --> B1["manage_executors MCP tool"] --> B2["hummingbot-api ExecutorService
(no controller — controller_id is a label)"] --> B3["LPExecutor"] + end + A2 --> GW["Gateway"] + B3 --> GW +``` + +When a close exhausts its budget: + +```mermaid +flowchart TD + T["Executor terminates POSITION_HOLD
hold_reason: close_retries_exhausted"] --> P["hummingbot-api persists final state
with orphan shape"] + T --> H["Controller (topology A) halts:
no new positions over the orphan"] + P --> L["GET /executors/positions/orphaned
lists the candidate"] + P --> W["condor tick prompt warns the agent"] + L --> R["Operator/agent closes the position
via gateway tools (remove liquidity by address —
a fresh lp_executor CANNOT adopt it)"] + R --> S["POST /executors/{id}/resolve-orphan
clears the listing and warnings"] + S --> X["Restart the controller —
its halt is in-memory; restart is the
acknowledgment that recovery is done"] +``` + +Also listed as orphan candidates: legacy `FAILED` executors whose final state carries a position address (force-stop stragglers), and `SYSTEM_CLEANUP` LP executors from an API restart (position address unknown — reconcile against on-chain positions). + +### 3.5 Transaction status polling + +Both chains' `/poll` routes share one `TransactionStatusCode` contract; the connector's 1 s poller acts on it: + +| `txStatus` | Meaning | Connector behavior | +|---|---|---| +| `1` CONFIRMED | Landed without error | Fill the order | +| `0` PENDING | The chain has seen the transaction (Solana: signature status; EVM: in the mempool) | Keep polling — it can still confirm (unbounded by design) | +| `-1` FAILED | Landed with an error / reverted | Fail the order, trigger `TransactionFailure` | +| `-2` NOT_FOUND | Unknown to the chain — never received or dropped (Solana: terminal once the blockhash expires; EVM: the mempool is visible via `getTransaction`, so not-found already means dropped) | Transient while the order is younger than 120 s (blockhash validity margin); afterwards each miss counts toward the tracker's lost-order limit → order fails after repeated consecutive misses | + +Transient poll errors (RPC failures) report `PENDING`, never `NOT_FOUND` — an unknown outcome is a reason to poll again, not to give up. Note the LP flow's writes do not depend on this poller to progress: the connector's operation calls only return a signature after in-request confirmation. The bound matters for flows that record a hash at submission time, and for the RPC cost of stuck orders. + +### 3.6 CLMM pool-info and bins + +One route answers for every CLMM connector, and every caller reaches it through the same chain: + +```mermaid +flowchart LR + A["condor
manage_gateway_clmm(bin_count=N)"] --> B["hummingbot-api-client 1.5.8
get_pool_info(bin_count=N)"] + B --> C["hummingbot-api
GET /gateway/clmm/pool-info?bin_count=N"] + C --> D["gateway
GET /trading/clmm/pool-info?binCount=N"] + D --> E["connector pool-info
bins computed only when asked"] +``` + +`binCount` is a **request for work, not a formatting flag** — the bins are read from chain state, so the default of `0` exists to keep the common call cheap: + +| Connector | Bins | Cost of `binCount = N` | +|---|---|---| +| Meteora | Always returned; ignores `binCount` | Already paid — the DLMM pool exposes its bins directly | +| Orca | On request | Roughly flat: one position fetch, then local math | +| Raydium | On request | Roughly flat: tick-array fetch, then local math | +| Uniswap | On request | Linear: `N + 1` parallel `pool.ticks()` eth_calls | +| PancakeSwap | On request | Linear: `N + 1` parallel `pool.ticks()` eth_calls | + +Gateway caps `binCount` at 401. Because the EVM cost scales with `N` while the Solana cost does not, callers that render a depth column should treat the window size as a parameter rather than a constant. + +The output shape mirrors Meteora's `bins[]` for every connector — `{ binId, price, baseTokenAmount, quoteTokenAmount }` — and is centred on the active tick: bins below the active price hold only quote, bins above hold only base, and the one bin straddling it holds both. + +--- + +## 4. Accepted residual risks + +- **The controller halt is process-local.** A bot restart clears `_orphaned_position_address` and the rebalancer can reopen while the orphan is unresolved; conversely, resolving the orphan does not un-halt a running controller (restart is the acknowledgment step). Controllers have no persisted state to rebuild the latch from; the durable guard is the API-layer orphan record, listing, and agent warnings. +- **Force-stop stragglers.** A force-stop does not cancel an in-flight close; a lingering attempt can land after the executor is persisted with the orphan shape, making the record stale-wrong. `resolve-orphan` (after an on-chain check) is the correction path. +- **Deployment coupling.** hummingbot-api runs the *installed* hummingbot wheel — the executor- and connector-level fixes reach topology B only after the wheel is rebuilt and reinstalled. +- **`SYSTEM_CLEANUP` orphans have no position address** (no final state was persisted); they are listed for on-chain reconciliation rather than resolved automatically. +- **A lost-order verdict can race a very late confirmation.** An order failed via the NOT_FOUND deadline could in principle confirm afterwards if an RPC node was more than two minutes behind the cluster; the deadline plus the consecutive-miss requirement makes this require a broken RPC, and the alternative — waiting forever — is strictly worse. diff --git a/openapi.json b/openapi.json index dec2716d23..8d8430f965 100644 --- a/openapi.json +++ b/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "Hummingbot Gateway", "description": "API endpoints for interacting with DEXs and blockchains", - "version": "dev-2.11.0" + "version": "dev-2.17.0" }, "components": { "parameters": { @@ -358,7 +358,7 @@ "privateKey": { "description": "Private key for the wallet", "type": "string", - "example": "\u003Cyour-private-key\u003E" + "example": "" }, "setDefault": { "description": "Set this wallet as the default for the chain", @@ -370,7 +370,7 @@ }, "example": { "chain": "solana", - "privateKey": "\u003Cyour-private-key\u003E", + "privateKey": "", "setDefault": true } } @@ -671,68 +671,6 @@ "maximum": 255, "type": "number", "example": 6 - }, - "geckoData": { - "type": "object", - "properties": { - "coingeckoCoinId": { - "description": "CoinGecko coin ID if available", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "imageUrl": { - "description": "Token image URL", - "type": "string" - }, - "priceUsd": { - "description": "Current price in USD", - "type": "string" - }, - "volumeUsd24h": { - "description": "24h trading volume in USD", - "type": "string" - }, - "marketCapUsd": { - "description": "Market capitalization in USD", - "type": "string" - }, - "fdvUsd": { - "description": "Fully diluted valuation in USD", - "type": "string" - }, - "totalSupply": { - "description": "Normalized total supply (human-readable)", - "type": "string" - }, - "topPools": { - "description": "Array of top pool addresses", - "type": "array", - "items": { - "type": "string" - } - }, - "timestamp": { - "description": "Unix timestamp (ms) when data was fetched", - "type": "number" - } - }, - "required": [ - "coingeckoCoinId", - "imageUrl", - "priceUsd", - "volumeUsd24h", - "marketCapUsd", - "fdvUsd", - "totalSupply", - "topPools", - "timestamp" - ] } }, "required": ["name", "symbol", "address", "decimals"] @@ -832,135 +770,6 @@ "maximum": 255, "type": "number", "example": 6 - }, - "geckoData": { - "type": "object", - "allOf": [ - { - "type": "object", - "properties": { - "coingeckoCoinId": { - "description": "CoinGecko coin ID if available", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "imageUrl": { - "description": "Token image URL", - "type": "string" - }, - "priceUsd": { - "description": "Current price in USD", - "type": "string" - }, - "volumeUsd24h": { - "description": "24h trading volume in USD", - "type": "string" - }, - "marketCapUsd": { - "description": "Market capitalization in USD", - "type": "string" - }, - "fdvUsd": { - "description": "Fully diluted valuation in USD", - "type": "string" - }, - "totalSupply": { - "description": "Normalized total supply (human-readable)", - "type": "string" - }, - "topPools": { - "description": "Array of top pool addresses", - "type": "array", - "items": { - "type": "string" - } - }, - "timestamp": { - "description": "Unix timestamp (ms) when data was fetched", - "type": "number" - } - }, - "required": [ - "coingeckoCoinId", - "imageUrl", - "priceUsd", - "volumeUsd24h", - "marketCapUsd", - "fdvUsd", - "totalSupply", - "topPools", - "timestamp" - ] - }, - { - "type": "object", - "properties": { - "coingeckoCoinId": { - "description": "CoinGecko coin ID if available", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "imageUrl": { - "description": "Token image URL", - "type": "string" - }, - "priceUsd": { - "description": "Current price in USD", - "type": "string" - }, - "volumeUsd24h": { - "description": "24h trading volume in USD", - "type": "string" - }, - "marketCapUsd": { - "description": "Market capitalization in USD", - "type": "string" - }, - "fdvUsd": { - "description": "Fully diluted valuation in USD", - "type": "string" - }, - "totalSupply": { - "description": "Normalized total supply (human-readable)", - "type": "string" - }, - "topPools": { - "description": "Array of top pool addresses", - "type": "array", - "items": { - "type": "string" - } - }, - "timestamp": { - "description": "Unix timestamp (ms) when data was fetched", - "type": "number" - } - }, - "required": [ - "coingeckoCoinId", - "imageUrl", - "priceUsd", - "volumeUsd24h", - "marketCapUsd", - "fdvUsd", - "totalSupply", - "topPools", - "timestamp" - ] - } - ] } }, "required": ["name", "symbol", "address", "decimals"] @@ -1070,68 +879,6 @@ "maximum": 255, "type": "number", "example": 6 - }, - "geckoData": { - "type": "object", - "properties": { - "coingeckoCoinId": { - "description": "CoinGecko coin ID if available", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "imageUrl": { - "description": "Token image URL", - "type": "string" - }, - "priceUsd": { - "description": "Current price in USD", - "type": "string" - }, - "volumeUsd24h": { - "description": "24h trading volume in USD", - "type": "string" - }, - "marketCapUsd": { - "description": "Market capitalization in USD", - "type": "string" - }, - "fdvUsd": { - "description": "Fully diluted valuation in USD", - "type": "string" - }, - "totalSupply": { - "description": "Normalized total supply (human-readable)", - "type": "string" - }, - "topPools": { - "description": "Array of top pool addresses", - "type": "array", - "items": { - "type": "string" - } - }, - "timestamp": { - "description": "Unix timestamp (ms) when data was fetched", - "type": "number" - } - }, - "required": [ - "coingeckoCoinId", - "imageUrl", - "priceUsd", - "volumeUsd24h", - "marketCapUsd", - "fdvUsd", - "totalSupply", - "topPools", - "timestamp" - ] } }, "required": ["name", "symbol", "address", "decimals"] @@ -1193,68 +940,6 @@ "maximum": 255, "type": "number", "example": 6 - }, - "geckoData": { - "type": "object", - "properties": { - "coingeckoCoinId": { - "description": "CoinGecko coin ID if available", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "imageUrl": { - "description": "Token image URL", - "type": "string" - }, - "priceUsd": { - "description": "Current price in USD", - "type": "string" - }, - "volumeUsd24h": { - "description": "24h trading volume in USD", - "type": "string" - }, - "marketCapUsd": { - "description": "Market capitalization in USD", - "type": "string" - }, - "fdvUsd": { - "description": "Fully diluted valuation in USD", - "type": "string" - }, - "totalSupply": { - "description": "Normalized total supply (human-readable)", - "type": "string" - }, - "topPools": { - "description": "Array of top pool addresses", - "type": "array", - "items": { - "type": "string" - } - }, - "timestamp": { - "description": "Unix timestamp (ms) when data was fetched", - "type": "number" - } - }, - "required": [ - "coingeckoCoinId", - "imageUrl", - "priceUsd", - "volumeUsd24h", - "marketCapUsd", - "fdvUsd", - "totalSupply", - "topPools", - "timestamp" - ] } }, "required": ["name", "symbol", "address", "decimals"] @@ -1277,14 +962,9 @@ "message": { "description": "Success message", "type": "string" - }, - "requiresRestart": { - "description": "Whether gateway restart is required", - "default": true, - "type": "boolean" } }, - "required": ["message", "requiresRestart"] + "required": ["message"] } } } @@ -1378,68 +1058,6 @@ "maximum": 255, "type": "number", "example": 6 - }, - "geckoData": { - "type": "object", - "properties": { - "coingeckoCoinId": { - "description": "CoinGecko coin ID if available", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "imageUrl": { - "description": "Token image URL", - "type": "string" - }, - "priceUsd": { - "description": "Current price in USD", - "type": "string" - }, - "volumeUsd24h": { - "description": "24h trading volume in USD", - "type": "string" - }, - "marketCapUsd": { - "description": "Market capitalization in USD", - "type": "string" - }, - "fdvUsd": { - "description": "Fully diluted valuation in USD", - "type": "string" - }, - "totalSupply": { - "description": "Normalized total supply (human-readable)", - "type": "string" - }, - "topPools": { - "description": "Array of top pool addresses", - "type": "array", - "items": { - "type": "string" - } - }, - "timestamp": { - "description": "Unix timestamp (ms) when data was fetched", - "type": "number" - } - }, - "required": [ - "coingeckoCoinId", - "imageUrl", - "priceUsd", - "volumeUsd24h", - "marketCapUsd", - "fdvUsd", - "totalSupply", - "topPools", - "timestamp" - ] } }, "required": ["name", "symbol", "address", "decimals"] @@ -1516,14 +1134,9 @@ "message": { "description": "Success message", "type": "string" - }, - "requiresRestart": { - "description": "Whether gateway restart is required", - "default": true, - "type": "boolean" } }, - "required": ["message", "requiresRestart"] + "required": ["message"] } } } @@ -1541,23 +1154,17 @@ "type": "string" }, "examples": { - "raydium": { - "value": "raydium" - }, - "meteora": { - "value": "meteora" - }, - "uniswap": { - "value": "uniswap" + "solana": { + "value": "solana" }, - "orca": { - "value": "orca" + "ethereum": { + "value": "ethereum" } }, "in": "query", - "name": "connector", + "name": "chain", "required": true, - "description": "Connector (raydium, meteora, uniswap, orca)" + "description": "Blockchain chain (solana, ethereum)" }, { "schema": { @@ -1595,6 +1202,29 @@ "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" @@ -1621,6 +1251,11 @@ "schema": { "type": "object", "properties": { + "connector": { + "description": "Connector name (raydium, uniswap, orca, etc)", + "type": "string", + "example": "raydium" + }, "type": { "description": "Pool type", "enum": ["clmm", "amm"], @@ -1650,6 +1285,7 @@ } }, "required": [ + "connector", "type", "network", "baseSymbol", @@ -1735,6 +1371,11 @@ "schema": { "type": "object", "properties": { + "connector": { + "description": "Connector name (raydium, uniswap, orca, etc)", + "type": "string", + "example": "raydium" + }, "type": { "description": "Pool type", "enum": ["clmm", "amm"], @@ -1761,55 +1402,10 @@ }, "address": { "type": "string" - }, - "geckoData": { - "type": "object", - "properties": { - "volumeUsd24h": { - "description": "24-hour trading volume in USD", - "type": "string" - }, - "liquidityUsd": { - "description": "Total liquidity in USD", - "type": "string" - }, - "priceNative": { - "description": "Base token price in quote token", - "type": "string" - }, - "priceUsd": { - "description": "Base token price in USD", - "type": "string" - }, - "buys24h": { - "description": "Number of buy transactions in 24h", - "type": "number" - }, - "sells24h": { - "description": "Number of sell transactions in 24h", - "type": "number" - }, - "apr": { - "description": "Annual percentage rate", - "type": "number" - }, - "timestamp": { - "description": "Unix timestamp (ms) when data was fetched", - "type": "number" - } - }, - "required": [ - "volumeUsd24h", - "liquidityUsd", - "priceNative", - "priceUsd", - "buys24h", - "sells24h", - "timestamp" - ] } }, "required": [ + "connector", "type", "network", "baseSymbol", @@ -1965,6 +1561,11 @@ "items": { "type": "object", "properties": { + "connector": { + "description": "Connector name (raydium, uniswap, orca, etc)", + "type": "string", + "example": "raydium" + }, "type": { "description": "Pool type", "enum": ["clmm", "amm"], @@ -1991,55 +1592,10 @@ }, "address": { "type": "string" - }, - "geckoData": { - "type": "object", - "properties": { - "volumeUsd24h": { - "description": "24-hour trading volume in USD", - "type": "string" - }, - "liquidityUsd": { - "description": "Total liquidity in USD", - "type": "string" - }, - "priceNative": { - "description": "Base token price in quote token", - "type": "string" - }, - "priceUsd": { - "description": "Base token price in USD", - "type": "string" - }, - "buys24h": { - "description": "Number of buy transactions in 24h", - "type": "number" - }, - "sells24h": { - "description": "Number of sell transactions in 24h", - "type": "number" - }, - "apr": { - "description": "Annual percentage rate", - "type": "number" - }, - "timestamp": { - "description": "Unix timestamp (ms) when data was fetched", - "type": "number" - } - }, - "required": [ - "volumeUsd24h", - "liquidityUsd", - "priceNative", - "priceUsd", - "buys24h", - "sells24h", - "timestamp" - ] } }, "required": [ + "connector", "type", "network", "baseSymbol", @@ -2060,50 +1616,70 @@ "/pools/": { "get": { "tags": ["/pools"], - "description": "List all pools for a connector, optionally filtered by network, type, or search term", + "description": "List all pools for a chain/network, optionally filtered by connector, type, or search term", "parameters": [ { "schema": { "type": "string" }, "examples": { - "raydium": { - "value": "raydium" - }, - "meteora": { - "value": "meteora" - }, - "uniswap": { - "value": "uniswap" + "solana": { + "value": "solana" }, - "orca": { - "value": "orca" + "ethereum": { + "value": "ethereum" } }, "in": "query", - "name": "connector", + "name": "chain", "required": true, - "description": "Connector (raydium, meteora, uniswap, orca)" + "description": "Blockchain chain (solana, ethereum)" }, { "schema": { "type": "string" }, "examples": { - "mainnet": { - "value": "mainnet" - }, "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 network (mainnet, mainnet-beta, etc)" + "description": "Optional: filter by connector (raydium, meteora, uniswap, orca)" }, { "schema": { @@ -2143,6 +1719,11 @@ "items": { "type": "object", "properties": { + "connector": { + "description": "Connector name (raydium, uniswap, orca, etc)", + "type": "string", + "example": "raydium" + }, "type": { "description": "Pool type", "enum": ["clmm", "amm"], @@ -2172,6 +1753,7 @@ } }, "required": [ + "connector", "type", "network", "baseSymbol", @@ -2197,6 +1779,11 @@ "schema": { "type": "object", "properties": { + "chain": { + "description": "Blockchain chain (solana, ethereum)", + "type": "string", + "example": "solana" + }, "connector": { "description": "Connector (raydium, meteora, uniswap, orca)", "type": "string", @@ -2219,12 +1806,12 @@ "type": "string" }, "baseSymbol": { - "description": "Base token symbol", + "description": "Base token symbol (optional - fetched automatically if not provided)", "type": "string", "example": "SOL" }, "quoteSymbol": { - "description": "Quote token symbol", + "description": "Quote token symbol (optional - fetched automatically if not provided)", "type": "string", "example": "USDC" }, @@ -2247,12 +1834,11 @@ } }, "required": [ + "chain", "connector", "type", "network", "address", - "baseSymbol", - "quoteSymbol", "baseTokenAddress", "quoteTokenAddress" ] @@ -2299,7 +1885,7 @@ "/pools/save/{address}": { "post": { "tags": ["/pools"], - "description": "Find pool from GeckoTerminal and save it to the pool list", + "description": "Find pool from GeckoTerminal and save it to the pool list. Auto-adds missing tokens.", "parameters": [ { "schema": { @@ -2356,6 +1942,11 @@ "pool": { "type": "object", "properties": { + "connector": { + "description": "Connector name (raydium, uniswap, orca, etc)", + "type": "string", + "example": "raydium" + }, "type": { "description": "Pool type", "enum": ["clmm", "amm"], @@ -2382,55 +1973,10 @@ }, "address": { "type": "string" - }, - "geckoData": { - "type": "object", - "properties": { - "volumeUsd24h": { - "description": "24-hour trading volume in USD", - "type": "string" - }, - "liquidityUsd": { - "description": "Total liquidity in USD", - "type": "string" - }, - "priceNative": { - "description": "Base token price in quote token", - "type": "string" - }, - "priceUsd": { - "description": "Base token price in USD", - "type": "string" - }, - "buys24h": { - "description": "Number of buy transactions in 24h", - "type": "number" - }, - "sells24h": { - "description": "Number of sell transactions in 24h", - "type": "number" - }, - "apr": { - "description": "Annual percentage rate", - "type": "number" - }, - "timestamp": { - "description": "Unix timestamp (ms) when data was fetched", - "type": "number" - } - }, - "required": [ - "volumeUsd24h", - "liquidityUsd", - "priceNative", - "priceUsd", - "buys24h", - "sells24h", - "timestamp" - ] } }, "required": [ + "connector", "type", "network", "baseSymbol", @@ -2440,6 +1986,12 @@ "feePct", "address" ] + }, + "tokensAdded": { + "type": "array", + "items": { + "type": "string" + } } }, "required": ["message", "pool"] @@ -2460,23 +2012,17 @@ "type": "string" }, "examples": { - "raydium": { - "value": "raydium" - }, - "meteora": { - "value": "meteora" - }, - "uniswap": { - "value": "uniswap" + "solana": { + "value": "solana" }, - "orca": { - "value": "orca" + "ethereum": { + "value": "ethereum" } }, "in": "query", - "name": "connector", + "name": "chain", "required": true, - "description": "Connector (raydium, meteora, uniswap, orca)" + "description": "Blockchain chain (solana, ethereum)" }, { "schema": { @@ -2495,23 +2041,6 @@ "required": true, "description": "Network name (mainnet, mainnet-beta, etc)" }, - { - "schema": { - "type": "string" - }, - "examples": { - "amm": { - "value": "amm" - }, - "clmm": { - "value": "clmm" - } - }, - "in": "query", - "name": "type", - "required": true, - "description": "Pool type" - }, { "schema": { "type": "string" @@ -2567,16 +2096,17 @@ "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, ethereum-polygon)" + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)" }, { "schema": { - "default": "jupiter/router", "type": "string" }, + "example": "jupiter/router", "in": "query", "name": "connector", "required": false, @@ -2625,13 +2155,34 @@ }, { "schema": { - "default": 1, + "type": "string" + }, + "in": "query", + "name": "poolAddress", + "required": false, + "description": "Pin the swap to a specific pool. Only meaningful for amm/clmm providers, which trade against one pool; router providers choose their own route and reject it. Omit to resolve the pool from Gateway's configured pool list by token pair — which a pool that is not in that list (a freshly created one, an unlisted token) cannot be, so pass its address here." + }, + { + "schema": { + "minimum": 0, + "maximum": 100, "type": "number" }, + "example": 1, "in": "query", "name": "slippagePct", "required": false, - "description": "Slippage tolerance percentage (optional)" + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct." + }, + { + "schema": { + "default": true, + "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. Solana routers only." } ], "responses": { @@ -2716,18 +2267,19 @@ "properties": { "walletAddress": { "description": "Wallet address to execute swap from", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string" }, "chainNetwork": { - "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet, ethereum-polygon)", + "description": "Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)", "default": "solana-mainnet-beta", - "type": "string" + "type": "string", + "example": "solana-mainnet-beta" }, "connector": { "description": "Connector to use in format: connector/type (e.g., jupiter/router, raydium/amm, uniswap/clmm). If not provided, uses network's configured swapProvider", - "default": "jupiter/router", - "type": "string" + "type": "string", + "example": "jupiter/router" }, "baseToken": { "description": "Symbol or address of the base token", @@ -2750,10 +2302,21 @@ "default": "SELL", "type": "string" }, + "poolAddress": { + "description": "Pin the swap to a specific pool. Only meaningful for amm/clmm providers, which trade against one pool; router providers choose their own route and reject it. Omit to resolve the pool from Gateway's configured pool list by token pair — which a pool that is not in that list (a freshly created one, an unlisted token) cannot be, so pass its address here.", + "type": "string" + }, "slippagePct": { - "description": "Slippage tolerance percentage (optional)", - "default": 1, - "type": "number" + "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 quote instead of failing. Solana routers only.", + "default": true, + "type": "boolean" } }, "required": ["walletAddress", "chainNetwork", "baseToken", "quoteToken", "amount", "side"] @@ -2808,6 +2371,10 @@ "quoteTokenBalanceChange": { "description": "Change in quote token balance (negative for decrease)", "type": "number" + }, + "slippagePct": { + "description": "Slippage tolerance percentage actually applied to the swap", + "type": "number" } }, "required": [ @@ -2836,7 +2403,7 @@ "parameters": [ { "schema": { - "enum": ["raydium", "meteora", "pancakeswap-sol", "uniswap", "pancakeswap", "orca"], + "enum": ["meteora", "raydium", "pancakeswap-sol", "orca", "uniswap", "pancakeswap"], "default": "meteora", "type": "string" }, @@ -2844,7 +2411,7 @@ "in": "query", "name": "connector", "required": true, - "description": "CLMM connector (raydium, meteora, pancakeswap-sol, uniswap, pancakeswap, orca)" + "description": "CLMM connector" }, { "schema": { @@ -2866,6 +2433,18 @@ "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": { @@ -2902,6 +2481,28 @@ }, "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": [ @@ -2928,7 +2529,7 @@ "parameters": [ { "schema": { - "enum": ["raydium", "meteora", "pancakeswap-sol", "uniswap", "pancakeswap", "orca"], + "enum": ["meteora", "raydium", "pancakeswap-sol", "orca", "uniswap", "pancakeswap"], "default": "meteora", "type": "string" }, @@ -2936,7 +2537,7 @@ "in": "query", "name": "connector", "required": true, - "description": "CLMM connector (raydium, meteora, pancakeswap-sol, uniswap, pancakeswap, orca)" + "description": "CLMM connector" }, { "schema": { @@ -2953,7 +2554,7 @@ "schema": { "type": "string" }, - "example": "\u003Csample-position-address\u003E", + "example": "", "in": "query", "name": "positionAddress", "required": true, @@ -3006,12 +2607,6 @@ }, "price": { "type": "number" - }, - "rewardTokenAddress": { - "type": "string" - }, - "rewardAmount": { - "type": "number" } }, "required": [ @@ -3043,7 +2638,7 @@ "parameters": [ { "schema": { - "enum": ["raydium", "meteora", "pancakeswap-sol", "uniswap", "pancakeswap", "orca"], + "enum": ["meteora", "raydium", "pancakeswap-sol", "orca", "uniswap", "pancakeswap"], "default": "meteora", "type": "string" }, @@ -3051,7 +2646,7 @@ "in": "query", "name": "connector", "required": true, - "description": "CLMM connector (raydium, meteora, pancakeswap-sol, uniswap, pancakeswap, orca)" + "description": "CLMM connector" }, { "schema": { @@ -3066,13 +2661,13 @@ }, { "schema": { - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string" }, "in": "query", "name": "walletAddress", - "required": false, - "description": "Wallet address (optional, uses default wallet if not provided)" + "required": true, + "description": "Wallet address" } ], "responses": { @@ -3123,12 +2718,6 @@ }, "price": { "type": "number" - }, - "rewardTokenAddress": { - "type": "string" - }, - "rewardAmount": { - "type": "number" } }, "required": [ @@ -3162,7 +2751,7 @@ "parameters": [ { "schema": { - "enum": ["raydium", "meteora", "pancakeswap-sol", "uniswap", "pancakeswap", "orca"], + "enum": ["meteora", "raydium", "pancakeswap-sol", "orca", "uniswap", "pancakeswap"], "default": "meteora", "type": "string" }, @@ -3170,7 +2759,7 @@ "in": "query", "name": "connector", "required": true, - "description": "CLMM connector (raydium, meteora, pancakeswap-sol, uniswap, pancakeswap, orca)" + "description": "CLMM connector" }, { "schema": { @@ -3237,14 +2826,13 @@ "schema": { "minimum": 0, "maximum": 100, - "default": 1, "type": "number" }, "example": 1, "in": "query", "name": "slippagePct", "required": false, - "description": "Maximum acceptable slippage percentage" + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct." } ], "responses": { @@ -3297,7 +2885,8 @@ "type": "object", "properties": { "connector": { - "description": "Connector name (uniswap, pancakeswap, raydium, meteora, pancakeswap-sol, orca)", + "description": "CLMM connector", + "enum": ["meteora", "raydium", "pancakeswap-sol", "orca", "uniswap", "pancakeswap"], "default": "meteora", "type": "string", "example": "meteora" @@ -3310,7 +2899,7 @@ }, "walletAddress": { "description": "Wallet address", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string" }, "lowerPrice": { @@ -3341,10 +2930,14 @@ "slippagePct": { "minimum": 0, "maximum": 100, - "description": "Maximum acceptable slippage percentage", - "default": 1, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", "type": "number", "example": 1 + }, + "strategyType": { + "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"] @@ -3415,7 +3008,8 @@ "type": "object", "properties": { "connector": { - "description": "Connector name (uniswap, pancakeswap, raydium, meteora, pancakeswap-sol, orca)", + "description": "CLMM connector", + "enum": ["meteora", "raydium", "pancakeswap-sol", "orca", "uniswap", "pancakeswap"], "default": "meteora", "type": "string", "example": "meteora" @@ -3428,41 +3022,38 @@ }, "walletAddress": { "description": "Wallet address", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string" }, "positionAddress": { "description": "Position address", "type": "string", - "example": "\u003Csample-position-address\u003E" + "example": "" }, "baseTokenAmount": { - "description": "Amount of base token to deposit", + "description": "Amount of base token to deposit (omit for single-sided quote deposit)", "type": "number", "example": 0.01 }, "quoteTokenAmount": { - "description": "Amount of quote token to deposit", + "description": "Amount of quote token to deposit (omit for single-sided base deposit)", "type": "number", "example": 2 }, "slippagePct": { "minimum": 0, "maximum": 100, - "description": "Maximum acceptable slippage percentage", - "default": 1, + "description": "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", "type": "number", "example": 1 + }, + "strategyType": { + "description": "Strategy type for Meteora positions (0=Spot, 1=Curve). Only applies to Meteora connector.", + "type": "number", + "example": 0 } }, - "required": [ - "connector", - "chainNetwork", - "walletAddress", - "positionAddress", - "baseTokenAmount", - "quoteTokenAmount" - ] + "required": ["connector", "chainNetwork", "walletAddress", "positionAddress"] } } }, @@ -3518,7 +3109,8 @@ "type": "object", "properties": { "connector": { - "description": "Connector name (uniswap, pancakeswap, raydium, meteora, pancakeswap-sol, orca)", + "description": "CLMM connector", + "enum": ["meteora", "raydium", "pancakeswap-sol", "orca", "uniswap", "pancakeswap"], "default": "meteora", "type": "string", "example": "meteora" @@ -3531,13 +3123,13 @@ }, "walletAddress": { "description": "Wallet address", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string" }, "positionAddress": { "description": "Position address", "type": "string", - "example": "\u003Csample-position-address\u003E" + "example": "" }, "percentageToRemove": { "minimum": 0, @@ -3546,6 +3138,13 @@ "default": 100, "type": "number", "example": 100 + }, + "slippagePct": { + "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"] @@ -3604,7 +3203,8 @@ "type": "object", "properties": { "connector": { - "description": "Connector name (uniswap, pancakeswap, raydium, meteora, pancakeswap-sol, orca)", + "description": "CLMM connector", + "enum": ["meteora", "raydium", "pancakeswap-sol", "orca", "uniswap", "pancakeswap"], "default": "meteora", "type": "string", "example": "meteora" @@ -3617,13 +3217,13 @@ }, "walletAddress": { "description": "Wallet address", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string" }, "positionAddress": { "description": "Position address", "type": "string", - "example": "\u003Csample-position-address\u003E" + "example": "" } }, "required": ["connector", "chainNetwork", "walletAddress", "positionAddress"] @@ -3682,7 +3282,8 @@ "type": "object", "properties": { "connector": { - "description": "Connector name (uniswap, pancakeswap, raydium, meteora, pancakeswap-sol, orca)", + "description": "CLMM connector", + "enum": ["meteora", "raydium", "pancakeswap-sol", "orca", "uniswap", "pancakeswap"], "default": "meteora", "type": "string", "example": "meteora" @@ -3695,13 +3296,13 @@ }, "walletAddress": { "description": "Wallet address", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string" }, "positionAddress": { "description": "Position address", "type": "string", - "example": "\u003Csample-position-address\u003E" + "example": "" } }, "required": ["connector", "chainNetwork", "walletAddress", "positionAddress"] @@ -3765,23 +3366,63 @@ } } }, - "/chains/solana/status": { - "get": { - "tags": ["/chain/solana"], - "description": "Get Solana network status", - "parameters": [ - { - "schema": { - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "in": "query", - "name": "network", - "required": false, - "description": "The Solana network to use" - } - ], + "/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": { + "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": { + "description": "Bin/tick granularity: Meteora DLMM bin step (bps); Orca Whirlpool tick spacing.", + "type": "number" + }, + "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).", + "type": "number" + }, + "ammConfigIndex": { + "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", @@ -3790,37 +3431,32 @@ "schema": { "type": "object", "properties": { - "chain": { - "type": "string" - }, - "network": { + "signature": { "type": "string" }, - "rpcUrl": { - "type": "string" + "status": { + "description": "TransactionStatus enum value", + "type": "number" }, - "rpcProvider": { + "poolAddress": { + "description": "Address of the newly created pool", "type": "string" }, - "currentBlockNumber": { + "price": { + "description": "Initial price the pool was initialized at (quote per base)", "type": "number" }, - "nativeCurrency": { - "type": "string" - }, - "swapProvider": { - "type": "string" - } - }, - "required": [ - "chain", - "network", - "rpcUrl", - "rpcProvider", - "currentBlockNumber", - "nativeCurrency", - "swapProvider" - ] + "data": { + "type": "object", + "properties": { + "fee": { + "type": "number" + } + }, + "required": ["fee"] + } + }, + "required": ["signature", "status", "poolAddress"] } } } @@ -3828,21 +3464,42 @@ } } }, - "/chains/solana/estimate-gas": { + "/trading/amm/pool-info": { "get": { - "tags": ["/chain/solana"], - "description": "Estimate gas prices for Solana transactions", + "tags": ["/trading/amm"], + "description": "Get AMM pool information from any supported connector", "parameters": [ { "schema": { - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], + "enum": ["meteora", "raydium", "uniswap", "pancakeswap"], + "default": "meteora", "type": "string" }, + "example": "meteora", "in": "query", - "name": "network", - "required": false, - "description": "The Solana network to use" + "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": { @@ -3853,35 +3510,37 @@ "schema": { "type": "object", "properties": { - "feePerComputeUnit": { - "type": "number" - }, - "denomination": { + "address": { "type": "string" }, - "computeUnits": { - "type": "number" + "baseTokenAddress": { + "type": "string" }, - "feeAsset": { + "quoteTokenAddress": { "type": "string" }, - "fee": { + "feePct": { "type": "number" }, - "timestamp": { + "price": { "type": "number" }, - "gasType": { - "type": "string" - }, - "maxFeePerGas": { + "baseTokenAmount": { "type": "number" }, - "maxPriorityFeePerGas": { + "quoteTokenAmount": { "type": "number" } }, - "required": ["feePerComputeUnit", "denomination", "computeUnits", "feeAsset", "fee", "timestamp"] + "required": [ + "address", + "baseTokenAddress", + "quoteTokenAddress", + "feePct", + "price", + "baseTokenAmount", + "quoteTokenAmount" + ] } } } @@ -3889,64 +3548,121 @@ } } }, - "/chains/solana/balances": { - "post": { - "tags": ["/chain/solana"], - "description": "Get token balances for a Solana address. Only returns tokens in the network's token list. If no tokens specified or empty array provided, returns non-zero balances for tokens from the token list that are found in the wallet (includes SOL even if zero). If specific tokens are requested, returns those exact tokens with their balances, including zeros.", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "network": { - "description": "The Solana network to use", - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "address": { - "description": "Solana wallet address", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string" - }, - "tokens": { - "description": "A list of token symbols (SOL, USDC, BONK) from the network's token list. Only tokens in the token list will be returned. An empty array is treated the same as if the parameter was not provided, returning only non-zero balances (with the exception of SOL).", - "type": "array", - "items": { - "type": "string" - }, - "example": ["SOL", "USDC", "BONK"] - } - } - } - } + "/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": "Token balances for the specified address (only tokens in token list)", + "description": "Default Response", "content": { "application/json": { "schema": { "type": "object", "properties": { - "balances": { - "type": "object", - "additionalProperties": { - "type": "number" + "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": { + "description": "Liquidity held by this position (LP units)", + "type": "number" + }, + "baseTokenAmount": { + "type": "number" + }, + "quoteTokenAmount": { + "type": "number" + } + }, + "required": ["positionAddress", "lpTokenAmount", "baseTokenAmount", "quoteTokenAmount"], + "title": "PositionDetail" } } }, - "required": ["balances"], - "description": "Token balances for the specified address (only tokens in token list)" - }, - "example": { - "balances": { - "SOL": 1.5, - "USDC": 100, - "BONK": 50000 - } + "required": [ + "poolAddress", + "walletAddress", + "baseTokenAddress", + "quoteTokenAddress", + "lpTokenAmount", + "baseTokenAmount", + "quoteTokenAmount", + "price" + ] } } } @@ -3954,107 +3670,3073 @@ } } }, - "/chains/solana/poll": { - "post": { - "tags": ["/chain/solana"], - "description": "Poll for the status of a Solana transaction", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "network": { - "description": "The Solana network to use", - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "signature": { - "description": "Transaction signature to poll", - "type": "string", - "example": "55ukR6VCt1sQFMC8Nyeo51R1SMaTzUC7jikmkEJ2jjkQNdqBxXHraH7vaoaNmf8rX4Y55EXAj8XXoyzvvsrQqWZa" - }, - "tokens": { - "description": "Tokens to track balance changes for", - "type": "array", - "items": { - "type": "string" - }, - "example": ["SOL", "USDC", "BONK"] - }, - "walletAddress": { - "description": "Wallet address to track balance changes for", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string" - } - }, - "required": ["signature"] - } - } - }, - "required": true - }, - "responses": { - "200": { + "/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": { + "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": { + "type": "number" + }, + "in": "query", + "name": "baseTokenAmount", + "required": true, + "description": "Amount of base token to deposit" + }, + { + "schema": { + "type": "number" + }, + "in": "query", + "name": "quoteTokenAmount", + "required": true, + "description": "Amount of quote token to deposit" + }, + { + "schema": { + "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/add-liquidity": { + "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": { + "description": "Amount of base token to add", + "type": "number" + }, + "quoteTokenAmount": { + "description": "Amount of quote token to add", + "type": "number" + }, + "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.", + "type": "string" + }, + "slippagePct": { + "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": { + "type": "number" + }, + "baseTokenAmountAdded": { + "type": "number" + }, + "quoteTokenAmountAdded": { + "type": "number" + } + }, + "required": ["fee", "baseTokenAmountAdded", "quoteTokenAmountAdded"] + } + }, + "required": ["signature", "status"] + } + } + } + } + } + } + }, + "/trading/amm/remove-liquidity": { + "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": { + "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": { + "minimum": 0, + "maximum": 100, + "description": "Percentage of liquidity to remove", + "default": 100, + "type": "number", + "example": 100 + }, + "slippagePct": { + "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/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": { + "description": "Amount of base token to seed the pool with", + "type": "number" + }, + "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.", + "type": "number" + }, + "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.", + "type": "number" + }, + "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).", + "type": "string" + }, + "ammConfigIndex": { + "description": "Raydium CPMM fee-config index (optional; defaults to the first available config).", + "type": "number" + }, + "slippagePct": { + "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": { + "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/solana/status": { + "get": { + "tags": ["/chain/solana"], + "description": "Get Solana network status", + "parameters": [ + { + "schema": { + "default": "mainnet-beta", + "enum": ["devnet", "mainnet-beta"], + "type": "string" + }, + "in": "query", + "name": "network", + "required": false, + "description": "The Solana network to use" + } + ], + "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/solana/estimate-gas": { + "get": { + "tags": ["/chain/solana"], + "description": "Estimate priority fees for Solana transactions. Optionally pass addresses (program IDs, pools) for Helius-specific fee estimation.", + "parameters": [ + { + "schema": { + "default": "mainnet-beta", + "enum": ["devnet", "mainnet-beta"], + "type": "string" + }, + "in": "query", + "name": "network", + "required": false, + "description": "The Solana network to use" + } + ], + "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/solana/balances": { + "post": { + "tags": ["/chain/solana"], + "description": "Get token balances for a Solana address. Only returns tokens in the network's token list. If no tokens specified or empty array provided, returns non-zero balances for tokens from the token list that are found in the wallet (includes SOL even if zero). If specific tokens are requested, returns those exact tokens with their balances, including zeros.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "network": { + "description": "The Solana network to use", + "default": "mainnet-beta", + "enum": ["devnet", "mainnet-beta"], + "type": "string" + }, + "address": { + "description": "Solana wallet address", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "tokens": { + "description": "A list of token symbols (SOL, USDC, BONK) from the network's token list. Only tokens in the token list will be returned. An empty array is treated the same as if the parameter was not provided, returning only non-zero balances (with the exception of SOL).", + "type": "array", + "items": { + "type": "string" + }, + "example": ["SOL", "USDC", "BONK"] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Token balances for the specified address (only tokens in token list)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "balances": { + "type": "object", + "additionalProperties": { + "type": "number" + } + } + }, + "required": ["balances"], + "description": "Token balances for the specified address (only tokens in token list)" + }, + "example": { + "balances": { + "SOL": 1.5, + "USDC": 100, + "BONK": 50000 + } + } + } + } + } + } + } + }, + "/chains/solana/poll": { + "post": { + "tags": ["/chain/solana"], + "description": "Poll for the status of a Solana transaction", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "network": { + "description": "The Solana network to use", + "default": "mainnet-beta", + "enum": ["devnet", "mainnet-beta"], + "type": "string" + }, + "signature": { + "description": "Transaction signature to poll", + "type": "string", + "example": "55ukR6VCt1sQFMC8Nyeo51R1SMaTzUC7jikmkEJ2jjkQNdqBxXHraH7vaoaNmf8rX4Y55EXAj8XXoyzvvsrQqWZa" + } + }, + "required": ["signature"] + } + } + }, + "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/solana/wrap": { + "post": { + "tags": ["/chain/solana"], + "description": "Wrap SOL to WSOL (Wrapped SOL)", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "network": { + "description": "The Solana network to use", + "default": "mainnet-beta", + "enum": ["devnet", "mainnet-beta"], + "type": "string" + }, + "address": { + "description": "Solana wallet address", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "amount": { + "description": "The amount of SOL to wrap (in SOL, not lamports)", + "type": "string", + "example": "1.0" + } + }, + "required": ["amount"] + } + } + }, + "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": "string" + }, + "amount": { + "type": "string" + }, + "wrappedAddress": { + "type": "string" + }, + "nativeToken": { + "type": "string" + }, + "wrappedToken": { + "type": "string" + } + }, + "required": ["fee", "amount", "wrappedAddress", "nativeToken", "wrappedToken"] + } + }, + "required": ["signature", "status"] + } + } + } + } + } + } + }, + "/chains/solana/unwrap": { + "post": { + "tags": ["/chain/solana"], + "description": "Unwrap WSOL to SOL. Note: This closes the entire WSOL account, returning all WSOL as SOL.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "network": { + "description": "The Solana network to use", + "default": "mainnet-beta", + "enum": ["devnet", "mainnet-beta"], + "type": "string" + }, + "address": { + "description": "Solana wallet address", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "amount": { + "description": "The amount of WSOL to unwrap (in SOL, not lamports). If not provided, unwraps all WSOL.", + "type": "string", + "example": "1.0" + } + } + } + } + } + }, + "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": "string" + }, + "amount": { + "type": "string" + }, + "wrappedAddress": { + "type": "string" + }, + "nativeToken": { + "type": "string" + }, + "wrappedToken": { + "type": "string" + } + }, + "required": ["fee", "amount", "wrappedAddress", "nativeToken", "wrappedToken"] + } + }, + "required": ["signature", "status"] + } + } + } + } + } + } + }, + "/chains/ethereum/status": { + "get": { + "tags": ["/chain/ethereum"], + "description": "Get Ethereum chain status", + "parameters": [ + { + "schema": { + "default": "mainnet", + "enum": [ + "arbitrum", + "avalanche", + "base", + "bsc", + "celo", + "mainnet", + "optimism", + "polygon", + "robinhoodchain-testnet", + "robinhoodchain", + "sepolia", + "unichain" + ], + "type": "string" + }, + "in": "query", + "name": "network", + "required": false, + "description": "The Ethereum network to use" + } + ], + "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/ethereum/estimate-gas": { + "get": { + "tags": ["/chain/ethereum"], + "description": "Estimate gas prices for Ethereum transactions", + "parameters": [ + { + "schema": { + "default": "mainnet", + "enum": [ + "arbitrum", + "avalanche", + "base", + "bsc", + "celo", + "mainnet", + "optimism", + "polygon", + "robinhoodchain-testnet", + "robinhoodchain", + "sepolia", + "unichain" + ], + "type": "string" + }, + "in": "query", + "name": "network", + "required": false, + "description": "The Ethereum network to use" + } + ], + "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/ethereum/balances": { + "post": { + "tags": ["/chain/ethereum"], + "description": "Get Ethereum balances. If no tokens specified or empty array provided, returns native token (ETH) and only non-zero balances for tokens from the token list. If specific tokens are requested, returns those exact tokens with their balances, including zeros.", + "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" + }, + "tokens": { + "description": "A list of token symbols (ETH, USDC, WETH) or token addresses. Both formats are accepted and will be automatically detected. An empty array is treated the same as if the parameter was not provided, returning only non-zero balances (with the exception of ETH).", + "type": "array", + "items": { + "type": "string" + }, + "example": ["ETH", "USDC", "WETH"] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "balances": { + "type": "object", + "additionalProperties": { + "type": "number" + } + } + }, + "required": ["balances"] + } + } + } + } + } + } + }, + "/chains/ethereum/poll": { + "post": { + "tags": ["/chain/ethereum"], + "description": "Poll Ethereum transaction status", + "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" + }, + "signature": { + "description": "Transaction hash to poll", + "type": "string", + "example": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + } + }, + "required": ["signature"] + } + } + }, + "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/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"] + } + } + } + } + } + } + }, + "/chains/ethereum/wrap": { + "post": { + "tags": ["/chain/ethereum"], + "description": "Wrap native token to wrapped token (e.g., ETH to WETH, BNB to WBNB)", + "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" + }, + "amount": { + "description": "The amount of native token to wrap (e.g., ETH, BNB, AVAX)", + "type": "string", + "example": "0.01" + } + }, + "required": ["amount"] + } + } + }, + "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": { + "type": "number" + }, + "fee": { + "type": "string" + }, + "amount": { + "type": "string" + }, + "wrappedAddress": { + "type": "string" + }, + "nativeToken": { + "type": "string" + }, + "wrappedToken": { + "type": "string" + } + }, + "required": ["nonce", "fee", "amount", "wrappedAddress", "nativeToken", "wrappedToken"] + } + }, + "required": ["signature", "status"] + } + } + } + } + } + } + }, + "/chains/ethereum/unwrap": { + "post": { + "tags": ["/chain/ethereum"], + "description": "Unwrap wrapped token to native token (e.g., WETH to ETH, WBNB to BNB)", + "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" + }, + "amount": { + "description": "The amount of wrapped token to unwrap (e.g., WETH, WBNB, WAVAX)", + "type": "string", + "example": "0.01" + } + }, + "required": ["amount"] + } + } + }, + "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": { + "type": "number" + }, + "fee": { + "type": "string" + }, + "amount": { + "type": "string" + }, + "wrappedAddress": { + "type": "string" + }, + "nativeToken": { + "type": "string" + }, + "wrappedToken": { + "type": "string" + } + }, + "required": ["nonce", "fee", "amount", "wrappedAddress", "nativeToken", "wrappedToken"] + } + }, + "required": ["signature", "status"] + } + } + } + } + } + } + }, + "/connectors/jupiter/router/quote-swap": { + "get": { + "tags": ["/connector/jupiter"], + "description": "Get an executable swap quote from Jupiter", + "parameters": [ + { + "schema": { + "default": "mainnet-beta", + "enum": ["devnet", "mainnet-beta"], + "type": "string" + }, + "in": "query", + "name": "network", + "required": false, + "description": "Solana network to use" + }, + { + "schema": { + "type": "string" + }, + "example": "SOL", + "in": "query", + "name": "baseToken", + "required": true, + "description": "Solana token symbol or address to determine swap direction" + }, + { + "schema": { + "type": "string" + }, + "example": "USDC", + "in": "query", + "name": "quoteToken", + "required": true, + "description": "The other Solana token symbol or address in the pair" + }, + { + "schema": { + "type": "number" + }, + "example": 0.1, + "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": "Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token" + }, + { + "schema": { + "minimum": 0, + "maximum": 100, + "default": 1, + "type": "number" + }, + "in": "query", + "name": "slippagePct", + "required": false, + "description": "Maximum acceptable slippage percentage" + }, + { + "schema": { + "default": true, + "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" + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "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": { + "description": "Amount of tokenIn to be swapped", + "type": "number" + }, + "amountOut": { + "description": "Expected amount of tokenOut to receive", + "type": "number" + }, + "price": { + "description": "Exchange rate between tokenIn and tokenOut", + "type": "number" + }, + "priceImpactPct": { + "description": "Estimated price impact percentage (0-100)", + "type": "number" + }, + "minAmountOut": { + "description": "Minimum amount of tokenOut that will be accepted", + "type": "number" + }, + "maxAmountIn": { + "description": "Maximum amount of tokenIn that will be spent", + "type": "number" + }, + "quoteResponse": { + "type": "object", + "properties": { + "inputMint": { + "description": "Solana mint address of input token", + "type": "string" + }, + "inAmount": { + "description": "Input amount in token decimals", + "type": "string" + }, + "outputMint": { + "description": "Solana mint address of output token", + "type": "string" + }, + "outAmount": { + "description": "Expected output amount in token decimals", + "type": "string" + }, + "otherAmountThreshold": { + "description": "Minimum output amount based on slippage", + "type": "string" + }, + "swapMode": { + "description": "Swap mode used (ExactIn or ExactOut)", + "type": "string" + }, + "slippageBps": { + "description": "Slippage in basis points", + "type": "number" + }, + "platformFee": { + "description": "Platform fee information if applicable" + }, + "priceImpactPct": { + "description": "Estimated price impact percentage", + "type": "string" + }, + "routePlan": { + "description": "Detailed routing plan through various markets", + "type": "array", + "items": {} + }, + "contextSlot": { + "description": "Solana slot used for quote calculation", + "type": "number" + }, + "timeTaken": { + "description": "Time taken to generate quote in milliseconds", + "type": "number" + } + }, + "required": [ + "inputMint", + "inAmount", + "outputMint", + "outAmount", + "otherAmountThreshold", + "swapMode", + "slippageBps", + "priceImpactPct", + "routePlan" + ] + }, + "approximation": { + "description": "Indicates if ExactIn approximation was used when ExactOut route was not available", + "type": "boolean" + } + }, + "required": [ + "quoteId", + "tokenIn", + "tokenOut", + "amountIn", + "amountOut", + "price", + "priceImpactPct", + "minAmountOut", + "maxAmountIn", + "quoteResponse" + ] + } + } + } + } + } + } + }, + "/connectors/jupiter/router/execute-quote": { + "post": { + "tags": ["/connector/jupiter"], + "description": "Execute a previously fetched quote from Jupiter", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "walletAddress": { + "description": "Solana wallet address that will execute the swap", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "network": { + "description": "Solana network to use", + "default": "mainnet-beta", + "enum": ["devnet", "mainnet-beta"], + "type": "string" + }, + "quoteId": { + "description": "ID of the Jupiter quote to execute", + "type": "string", + "example": "123e4567-e89b-12d3-a456-426614174000" + } + }, + "required": ["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": { + "description": "Actual amount of tokenIn swapped", + "type": "number" + }, + "amountOut": { + "description": "Actual amount of tokenOut received", + "type": "number" + }, + "fee": { + "description": "Transaction fee paid", + "type": "number" + }, + "baseTokenBalanceChange": { + "description": "Change in base token balance (negative for decrease)", + "type": "number" + }, + "quoteTokenBalanceChange": { + "description": "Change in quote token balance (negative for decrease)", + "type": "number" + }, + "slippagePct": { + "description": "Slippage tolerance percentage actually applied to the swap", + "type": "number" + } + }, + "required": [ + "tokenIn", + "tokenOut", + "amountIn", + "amountOut", + "fee", + "baseTokenBalanceChange", + "quoteTokenBalanceChange" + ] + } + }, + "required": ["signature", "status"] + } + } + } + } + } + } + }, + "/connectors/jupiter/router/execute-swap": { + "post": { + "tags": ["/connector/jupiter"], + "description": "Quote and execute a token swap on Jupiter in one step", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "walletAddress": { + "description": "Solana wallet address that will execute the swap", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "network": { + "description": "Solana network to use", + "default": "mainnet-beta", + "enum": ["devnet", "mainnet-beta"], + "type": "string" + }, + "baseToken": { + "description": "Solana token symbol or address to determine swap direction", + "type": "string", + "example": "SOL" + }, + "quoteToken": { + "description": "The other Solana token symbol or address in the pair", + "type": "string", + "example": "USDC" + }, + "amount": { + "description": "Amount of base token to trade", + "type": "number", + "example": 0.1 + }, + "side": { + "description": "Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token", + "enum": ["BUY", "SELL"], + "default": "SELL", + "type": "string" + }, + "slippagePct": { + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage", + "default": 1, + "type": "number" + }, + "approximateIfNoExactOut": { + "description": "For BUY orders when the router has no ExactOut route: approximate via a sell-leg ExactIn quote instead of failing", + "default": true, + "type": "boolean" + } + }, + "required": ["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": { + "description": "Actual amount of tokenIn swapped", + "type": "number" + }, + "amountOut": { + "description": "Actual amount of tokenOut received", + "type": "number" + }, + "fee": { + "description": "Transaction fee paid", + "type": "number" + }, + "baseTokenBalanceChange": { + "description": "Change in base token balance (negative for decrease)", + "type": "number" + }, + "quoteTokenBalanceChange": { + "description": "Change in quote token balance (negative for decrease)", + "type": "number" + }, + "slippagePct": { + "description": "Slippage tolerance percentage actually applied to the swap", + "type": "number" + } + }, + "required": [ + "tokenIn", + "tokenOut", + "amountIn", + "amountOut", + "fee", + "baseTokenBalanceChange", + "quoteTokenBalanceChange" + ] + } + }, + "required": ["signature", "status"] + } + } + } + } + } + } + }, + "/connectors/dflow/router/quote-swap": { + "get": { + "tags": ["/connector/dflow"], + "description": "Get an executable swap quote from DFlow", + "parameters": [ + { + "schema": { + "default": "mainnet-beta", + "enum": ["mainnet-beta"], + "type": "string" + }, + "in": "query", + "name": "network", + "required": false, + "description": "Solana network to use" + }, + { + "schema": { + "type": "string" + }, + "example": "SOL", + "in": "query", + "name": "baseToken", + "required": true, + "description": "Solana token symbol or address to determine swap direction" + }, + { + "schema": { + "type": "string" + }, + "example": "USDC", + "in": "query", + "name": "quoteToken", + "required": true, + "description": "The other Solana token symbol or address in the pair" + }, + { + "schema": { + "type": "number" + }, + "example": 0.1, + "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": "Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token" + }, + { + "schema": { + "minimum": 0, + "maximum": 100, + "default": 1, + "type": "number" + }, + "in": "query", + "name": "slippagePct", + "required": false, + "description": "Maximum acceptable slippage percentage" + }, + { + "schema": { + "default": true, + "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" + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "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": { + "description": "Amount of tokenIn to be swapped", + "type": "number" + }, + "amountOut": { + "description": "Expected amount of tokenOut to receive", + "type": "number" + }, + "price": { + "description": "Exchange rate between tokenIn and tokenOut", + "type": "number" + }, + "priceImpactPct": { + "description": "Estimated price impact percentage (0-100)", + "type": "number" + }, + "minAmountOut": { + "description": "Minimum amount of tokenOut that will be accepted", + "type": "number" + }, + "maxAmountIn": { + "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 (DFlow is ExactIn-only); amountOut is an estimate", + "type": "boolean" + }, + "quoteResponse": { + "description": "DFlow's native quote response, used to build the swap transaction at execution time" + } + }, + "required": [ + "quoteId", + "tokenIn", + "tokenOut", + "amountIn", + "amountOut", + "price", + "priceImpactPct", + "minAmountOut", + "maxAmountIn", + "quoteResponse" + ] + } + } + } + } + } + } + }, + "/connectors/dflow/router/execute-quote": { + "post": { + "tags": ["/connector/dflow"], + "description": "Execute a previously fetched quote from DFlow", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "walletAddress": { + "description": "Solana wallet address that will execute the swap", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "network": { + "description": "Solana network to use", + "default": "mainnet-beta", + "enum": ["mainnet-beta"], + "type": "string" + }, + "quoteId": { + "description": "ID of the DFlow quote to execute", + "type": "string", + "example": "123e4567-e89b-12d3-a456-426614174000" + } + }, + "required": ["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": { + "description": "Actual amount of tokenIn swapped", + "type": "number" + }, + "amountOut": { + "description": "Actual amount of tokenOut received", + "type": "number" + }, + "fee": { + "description": "Transaction fee paid", + "type": "number" + }, + "baseTokenBalanceChange": { + "description": "Change in base token balance (negative for decrease)", + "type": "number" + }, + "quoteTokenBalanceChange": { + "description": "Change in quote token balance (negative for decrease)", + "type": "number" + }, + "slippagePct": { + "description": "Slippage tolerance percentage actually applied to the swap", + "type": "number" + } + }, + "required": [ + "tokenIn", + "tokenOut", + "amountIn", + "amountOut", + "fee", + "baseTokenBalanceChange", + "quoteTokenBalanceChange" + ] + } + }, + "required": ["signature", "status"] + } + } + } + } + } + } + }, + "/connectors/dflow/router/execute-swap": { + "post": { + "tags": ["/connector/dflow"], + "description": "Quote and execute a token swap on DFlow in one step", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "walletAddress": { + "description": "Solana wallet address that will execute the swap", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "network": { + "description": "Solana network to use", + "default": "mainnet-beta", + "enum": ["mainnet-beta"], + "type": "string" + }, + "baseToken": { + "description": "Solana token symbol or address to determine swap direction", + "type": "string", + "example": "SOL" + }, + "quoteToken": { + "description": "The other Solana token symbol or address in the pair", + "type": "string", + "example": "USDC" + }, + "amount": { + "description": "Amount of base token to trade", + "type": "number", + "example": 0.1 + }, + "side": { + "description": "Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token", + "enum": ["BUY", "SELL"], + "default": "SELL", + "type": "string" + }, + "slippagePct": { + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage", + "default": 1, + "type": "number" + }, + "approximateIfNoExactOut": { + "description": "For BUY orders when the router has no ExactOut route: approximate via a sell-leg ExactIn quote instead of failing", + "default": true, + "type": "boolean" + } + }, + "required": ["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": { + "description": "Actual amount of tokenIn swapped", + "type": "number" + }, + "amountOut": { + "description": "Actual amount of tokenOut received", + "type": "number" + }, + "fee": { + "description": "Transaction fee paid", + "type": "number" + }, + "baseTokenBalanceChange": { + "description": "Change in base token balance (negative for decrease)", + "type": "number" + }, + "quoteTokenBalanceChange": { + "description": "Change in quote token balance (negative for decrease)", + "type": "number" + }, + "slippagePct": { + "description": "Slippage tolerance percentage actually applied to the swap", + "type": "number" + } + }, + "required": [ + "tokenIn", + "tokenOut", + "amountIn", + "amountOut", + "fee", + "baseTokenBalanceChange", + "quoteTokenBalanceChange" + ] + } + }, + "required": ["signature", "status"] + } + } + } + } + } + } + }, + "/connectors/okx/router/quote-swap": { + "get": { + "tags": ["/connector/okx"], + "description": "Get an executable swap quote from the OKX DEX aggregator", + "parameters": [ + { + "schema": { + "default": "mainnet-beta", + "enum": ["mainnet-beta"], + "type": "string" + }, + "in": "query", + "name": "network", + "required": false, + "description": "Solana network to use" + }, + { + "schema": { + "type": "string" + }, + "example": "SOL", + "in": "query", + "name": "baseToken", + "required": true, + "description": "Solana token symbol or address to determine swap direction" + }, + { + "schema": { + "type": "string" + }, + "example": "USDC", + "in": "query", + "name": "quoteToken", + "required": true, + "description": "The other Solana token symbol or address in the pair" + }, + { + "schema": { + "type": "number" + }, + "example": 0.1, + "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": "Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token" + }, + { + "schema": { + "minimum": 0, + "maximum": 100, + "default": 1, + "type": "number" + }, + "in": "query", + "name": "slippagePct", + "required": false, + "description": "Maximum acceptable slippage percentage" + }, + { + "schema": { + "default": true, + "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" + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "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": { + "description": "Amount of tokenIn to be swapped", + "type": "number" + }, + "amountOut": { + "description": "Expected amount of tokenOut to receive", + "type": "number" + }, + "price": { + "description": "Exchange rate between tokenIn and tokenOut", + "type": "number" + }, + "priceImpactPct": { + "description": "Estimated price impact percentage (0-100)", + "type": "number" + }, + "minAmountOut": { + "description": "Minimum amount of tokenOut that will be accepted", + "type": "number" + }, + "maxAmountIn": { + "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 exactOut was unavailable; amountOut is an estimate", + "type": "boolean" + }, + "routerResult": { + "description": "OKX's native quote result (amounts, price impact, routing breakdown)" + } + }, + "required": [ + "quoteId", + "tokenIn", + "tokenOut", + "amountIn", + "amountOut", + "price", + "priceImpactPct", + "minAmountOut", + "maxAmountIn", + "routerResult" + ] + } + } + } + } + } + } + }, + "/connectors/okx/router/execute-quote": { + "post": { + "tags": ["/connector/okx"], + "description": "Execute a previously fetched quote from the OKX DEX aggregator", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "walletAddress": { + "description": "Solana wallet address that will execute the swap", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "network": { + "description": "Solana network to use", + "default": "mainnet-beta", + "enum": ["mainnet-beta"], + "type": "string" + }, + "quoteId": { + "description": "ID of the OKX quote to execute", + "type": "string", + "example": "123e4567-e89b-12d3-a456-426614174000" + } + }, + "required": ["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": { + "description": "Actual amount of tokenIn swapped", + "type": "number" + }, + "amountOut": { + "description": "Actual amount of tokenOut received", + "type": "number" + }, + "fee": { + "description": "Transaction fee paid", + "type": "number" + }, + "baseTokenBalanceChange": { + "description": "Change in base token balance (negative for decrease)", + "type": "number" + }, + "quoteTokenBalanceChange": { + "description": "Change in quote token balance (negative for decrease)", + "type": "number" + }, + "slippagePct": { + "description": "Slippage tolerance percentage actually applied to the swap", + "type": "number" + } + }, + "required": [ + "tokenIn", + "tokenOut", + "amountIn", + "amountOut", + "fee", + "baseTokenBalanceChange", + "quoteTokenBalanceChange" + ] + } + }, + "required": ["signature", "status"] + } + } + } + } + } + } + }, + "/connectors/okx/router/execute-swap": { + "post": { + "tags": ["/connector/okx"], + "description": "Quote and execute a token swap on the OKX DEX aggregator in one step", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "walletAddress": { + "description": "Solana wallet address that will execute the swap", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "network": { + "description": "Solana network to use", + "default": "mainnet-beta", + "enum": ["mainnet-beta"], + "type": "string" + }, + "baseToken": { + "description": "Solana token symbol or address to determine swap direction", + "type": "string", + "example": "SOL" + }, + "quoteToken": { + "description": "The other Solana token symbol or address in the pair", + "type": "string", + "example": "USDC" + }, + "amount": { + "description": "Amount of base token to trade", + "type": "number", + "example": 0.1 + }, + "side": { + "description": "Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token", + "enum": ["BUY", "SELL"], + "default": "SELL", + "type": "string" + }, + "slippagePct": { + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage", + "default": 1, + "type": "number" + }, + "approximateIfNoExactOut": { + "description": "For BUY orders when the router has no ExactOut route: approximate via a sell-leg ExactIn quote instead of failing", + "default": true, + "type": "boolean" + } + }, + "required": ["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": { + "description": "Actual amount of tokenIn swapped", + "type": "number" + }, + "amountOut": { + "description": "Actual amount of tokenOut received", + "type": "number" + }, + "fee": { + "description": "Transaction fee paid", + "type": "number" + }, + "baseTokenBalanceChange": { + "description": "Change in base token balance (negative for decrease)", + "type": "number" + }, + "quoteTokenBalanceChange": { + "description": "Change in quote token balance (negative for decrease)", + "type": "number" + }, + "slippagePct": { + "description": "Slippage tolerance percentage actually applied to the swap", + "type": "number" + } + }, + "required": [ + "tokenIn", + "tokenOut", + "amountIn", + "amountOut", + "fee", + "baseTokenBalanceChange", + "quoteTokenBalanceChange" + ] + } + }, + "required": ["signature", "status"] + } + } + } + } + } + } + }, + "/connectors/titan/router/quote-swap": { + "get": { + "tags": ["/connector/titan"], + "description": "Get an executable swap quote from Titan (DART)", + "parameters": [ + { + "schema": { + "default": "mainnet-beta", + "enum": ["mainnet-beta"], + "type": "string" + }, + "in": "query", + "name": "network", + "required": false, + "description": "Solana network to use" + }, + { + "schema": { + "type": "string" + }, + "example": "SOL", + "in": "query", + "name": "baseToken", + "required": true, + "description": "Solana token symbol or address to determine swap direction" + }, + { + "schema": { + "type": "string" + }, + "example": "USDC", + "in": "query", + "name": "quoteToken", + "required": true, + "description": "The other Solana token symbol or address in the pair" + }, + { + "schema": { + "type": "number" + }, + "example": 0.1, + "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": "Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token" + }, + { + "schema": { + "minimum": 0, + "maximum": 100, + "default": 1, + "type": "number" + }, + "in": "query", + "name": "slippagePct", + "required": false, + "description": "Maximum acceptable slippage percentage" + }, + { + "schema": { + "default": true, + "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": { + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "in": "query", + "name": "walletAddress", + "required": false, + "description": "Solana wallet the quote instructions are built for (Titan quotes are wallet-bound)" + } + ], + "responses": { + "200": { "description": "Default Response", "content": { "application/json": { "schema": { "type": "object", "properties": { - "currentBlock": { - "type": "number" + "quoteId": { + "description": "Unique identifier for this quote", + "type": "string" }, - "signature": { + "tokenIn": { + "description": "Address of the token being swapped from", "type": "string" }, - "txBlock": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] + "tokenOut": { + "description": "Address of the token being swapped to", + "type": "string" }, - "txStatus": { + "amountIn": { + "description": "Amount of tokenIn to be swapped", "type": "number" }, - "fee": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] + "amountOut": { + "description": "Expected amount of tokenOut to receive", + "type": "number" }, - "tokenBalanceChanges": { - "description": "Dictionary of token balance changes keyed by token input value (symbol or address)", - "type": "object", - "additionalProperties": { - "type": "number" - } + "price": { + "description": "Exchange rate between tokenIn and tokenOut", + "type": "number" }, - "txData": { - "anyOf": [ - { - "type": "object", - "additionalProperties": {} - }, - { - "type": "null" - } - ] + "priceImpactPct": { + "description": "Estimated price impact percentage (0-100); Titan DART does not report price impact, so this is 0", + "type": "number" }, - "error": { + "minAmountOut": { + "description": "Minimum amount of tokenOut that will be accepted", + "type": "number" + }, + "maxAmountIn": { + "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 (Titan DART is ExactIn-only); amountOut is an estimate", + "type": "boolean" + }, + "wallet": { + "description": "Wallet address this quote is bound to; execute-quote must be called with the same wallet or it will fail", "type": "string" } }, - "required": ["currentBlock", "signature", "txBlock", "txStatus", "fee", "txData"] + "required": [ + "quoteId", + "tokenIn", + "tokenOut", + "amountIn", + "amountOut", + "price", + "priceImpactPct", + "minAmountOut", + "maxAmountIn", + "wallet" + ] } } } @@ -4062,34 +6744,34 @@ } } }, - "/chains/solana/wrap": { + "/connectors/titan/router/execute-quote": { "post": { - "tags": ["/chain/solana"], - "description": "Wrap SOL to WSOL (Wrapped SOL)", + "tags": ["/connector/titan"], + "description": "Execute a previously fetched quote from Titan (DART)", "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { - "network": { - "description": "The Solana network to use", - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], + "walletAddress": { + "description": "Solana wallet address that will execute the swap (must match the wallet the quote was created for)", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string" }, - "address": { - "description": "Solana wallet address", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", + "network": { + "description": "Solana network to use", + "default": "mainnet-beta", + "enum": ["mainnet-beta"], "type": "string" }, - "amount": { - "description": "The amount of SOL to wrap (in SOL, not lamports)", + "quoteId": { + "description": "ID of the Titan quote to execute", "type": "string", - "example": "1.0" + "example": "123e4567-e89b-12d3-a456-426614174000" } }, - "required": ["amount"] + "required": ["quoteId"] } } }, @@ -4104,32 +6786,58 @@ "type": "object", "properties": { "signature": { + "description": "Transaction signature/hash", "type": "string" }, "status": { - "description": "TransactionStatus enum value", + "description": "Transaction status: 0 = PENDING, 1 = CONFIRMED, -1 = FAILED", "type": "number" }, "data": { "type": "object", "properties": { - "fee": { + "tokenIn": { + "description": "Address of the token swapped from", "type": "string" }, - "amount": { + "tokenOut": { + "description": "Address of the token swapped to", "type": "string" }, - "wrappedAddress": { - "type": "string" + "amountIn": { + "description": "Actual amount of tokenIn swapped", + "type": "number" }, - "nativeToken": { - "type": "string" + "amountOut": { + "description": "Actual amount of tokenOut received", + "type": "number" }, - "wrappedToken": { - "type": "string" + "fee": { + "description": "Transaction fee paid", + "type": "number" + }, + "baseTokenBalanceChange": { + "description": "Change in base token balance (negative for decrease)", + "type": "number" + }, + "quoteTokenBalanceChange": { + "description": "Change in quote token balance (negative for decrease)", + "type": "number" + }, + "slippagePct": { + "description": "Slippage tolerance percentage actually applied to the swap", + "type": "number" } }, - "required": ["fee", "amount", "wrappedAddress", "nativeToken", "wrappedToken"] + "required": [ + "tokenIn", + "tokenOut", + "amountIn", + "amountOut", + "fee", + "baseTokenBalanceChange", + "quoteTokenBalanceChange" + ] } }, "required": ["signature", "status"] @@ -4140,36 +6848,66 @@ } } }, - "/chains/solana/unwrap": { + "/connectors/titan/router/execute-swap": { "post": { - "tags": ["/chain/solana"], - "description": "Unwrap WSOL to SOL. Note: This closes the entire WSOL account, returning all WSOL as SOL.", + "tags": ["/connector/titan"], + "description": "Quote and execute a token swap on Titan (DART) in one step", "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { + "walletAddress": { + "description": "Solana wallet address that will execute the swap", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, "network": { - "description": "The Solana network to use", + "description": "Solana network to use", "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], + "enum": ["mainnet-beta"], "type": "string" }, - "address": { - "description": "Solana wallet address", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string" + "baseToken": { + "description": "Solana token symbol or address to determine swap direction", + "type": "string", + "example": "SOL" }, - "amount": { - "description": "The amount of WSOL to unwrap (in SOL, not lamports). If not provided, unwraps all WSOL.", + "quoteToken": { + "description": "The other Solana token symbol or address in the pair", "type": "string", - "example": "1.0" + "example": "USDC" + }, + "amount": { + "description": "Amount of base token to trade", + "type": "number", + "example": 0.1 + }, + "side": { + "description": "Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token", + "enum": ["BUY", "SELL"], + "default": "SELL", + "type": "string" + }, + "slippagePct": { + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage", + "default": 1, + "type": "number" + }, + "approximateIfNoExactOut": { + "description": "For BUY orders when the router has no ExactOut route: approximate via a sell-leg ExactIn quote instead of failing", + "default": true, + "type": "boolean" } - } + }, + "required": ["baseToken", "quoteToken", "amount", "side"] } } - } + }, + "required": true }, "responses": { "200": { @@ -4180,32 +6918,58 @@ "type": "object", "properties": { "signature": { + "description": "Transaction signature/hash", "type": "string" }, "status": { - "description": "TransactionStatus enum value", + "description": "Transaction status: 0 = PENDING, 1 = CONFIRMED, -1 = FAILED", "type": "number" }, "data": { "type": "object", "properties": { - "fee": { + "tokenIn": { + "description": "Address of the token swapped from", "type": "string" }, - "amount": { + "tokenOut": { + "description": "Address of the token swapped to", "type": "string" }, - "wrappedAddress": { - "type": "string" + "amountIn": { + "description": "Actual amount of tokenIn swapped", + "type": "number" }, - "nativeToken": { - "type": "string" + "amountOut": { + "description": "Actual amount of tokenOut received", + "type": "number" }, - "wrappedToken": { - "type": "string" + "fee": { + "description": "Transaction fee paid", + "type": "number" + }, + "baseTokenBalanceChange": { + "description": "Change in base token balance (negative for decrease)", + "type": "number" + }, + "quoteTokenBalanceChange": { + "description": "Change in quote token balance (negative for decrease)", + "type": "number" + }, + "slippagePct": { + "description": "Slippage tolerance percentage actually applied to the swap", + "type": "number" } }, - "required": ["fee", "amount", "wrappedAddress", "nativeToken", "wrappedToken"] + "required": [ + "tokenIn", + "tokenOut", + "amountIn", + "amountOut", + "fee", + "baseTokenBalanceChange", + "quoteTokenBalanceChange" + ] } }, "required": ["signature", "status"] @@ -4216,21 +6980,97 @@ } } }, - "/chains/ethereum/status": { + "/connectors/meteora/clmm/fetch-pools": { "get": { - "tags": ["/chain/ethereum"], - "description": "Get Ethereum chain status", + "tags": ["/connector/meteora"], + "description": "Fetch Meteora pools from API with search and sorting", "parameters": [ { "schema": { - "default": "mainnet", - "enum": ["arbitrum", "avalanche", "base", "bsc", "celo", "mainnet", "optimism", "polygon", "sepolia"], + "default": "mainnet-beta", + "enum": ["devnet", "mainnet-beta"], "type": "string" }, "in": "query", "name": "network", "required": false, - "description": "The Ethereum network to use" + "description": "Solana network to use" + }, + { + "schema": { + "minimum": 0, + "default": 0, + "type": "number" + }, + "example": 0, + "in": "query", + "name": "page", + "required": false, + "description": "Page number (0-based)" + }, + { + "schema": { + "minimum": 1, + "maximum": 1000, + "default": 50, + "type": "number" + }, + "example": 50, + "in": "query", + "name": "limit", + "required": false, + "description": "Maximum number of pools to return (max 1000)" + }, + { + "schema": { + "type": "string" + }, + "examples": { + "SOL": { + "value": "SOL" + }, + "USDC": { + "value": "USDC" + }, + "SOL-USDC": { + "value": "SOL-USDC" + } + }, + "in": "query", + "name": "query", + "required": false, + "description": "Search query to match pools by name, tokens, or address" + }, + { + "schema": { + "default": "volume_24h:desc", + "type": "string" + }, + "examples": { + "volume_24h:desc": { + "value": "volume_24h:desc" + }, + "tvl:desc": { + "value": "tvl:desc" + }, + "apr:desc": { + "value": "apr:desc" + } + }, + "in": "query", + "name": "sortBy", + "required": false, + "description": "Sort by field (volume, fees, tvl, apr) with optional time window" + }, + { + "schema": { + "default": true, + "type": "boolean" + }, + "in": "query", + "name": "includeUnverified", + "required": false, + "description": "Include pools with unverified tokens" } ], "responses": { @@ -4241,37 +7081,192 @@ "schema": { "type": "object", "properties": { - "chain": { - "type": "string" + "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": { + "description": "Base fee percentage", + "type": "number" + }, + "price": { + "description": "Current price", + "type": "number" + }, + "tvl": { + "description": "Total value locked in USD", + "type": "number" + }, + "apr": { + "description": "Annual percentage rate", + "type": "number" + }, + "apy": { + "description": "Annual percentage yield", + "type": "number" + }, + "volume24h": { + "description": "24-hour trading volume", + "type": "number" + }, + "fees24h": { + "description": "24-hour fees collected", + "type": "number" + } + }, + "required": [ + "address", + "name", + "baseTokenAddress", + "baseTokenSymbol", + "quoteTokenAddress", + "quoteTokenSymbol", + "binStep", + "baseFee", + "price", + "tvl" + ], + "title": "PoolListItem" + } }, - "network": { - "type": "string" + "total": { + "description": "Total number of matching pools", + "type": "number" }, - "rpcUrl": { - "type": "string" + "page": { + "description": "Current page number", + "type": "number" }, - "rpcProvider": { + "pageSize": { + "description": "Number of pools per page", + "type": "number" + } + }, + "required": ["pools", "total", "page", "pageSize"] + } + } + } + } + } + } + }, + "/connectors/meteora/clmm/create-pool": { + "post": { + "tags": ["/connector/meteora"], + "description": "Create and initialize a new Meteora DLMM pool (LB pair) at an initial price (no liquidity seeded)", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "network": { + "description": "Solana network to use", + "default": "mainnet-beta", + "enum": ["devnet", "mainnet-beta"], + "type": "string" + }, + "walletAddress": { + "description": "Solana wallet address that will create the pool", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string", + "example": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5" + }, + "baseToken": { + "description": "Base token symbol or address", + "type": "string", + "example": "SOL" + }, + "quoteToken": { + "description": "Quote token symbol or address", + "type": "string", + "example": "USDC" + }, + "initialPrice": { + "description": "Initial price as quote per base (e.g. USDC per SOL). Encodes the pool active bin.", + "type": "number", + "example": 250 + }, + "binStep": { + "description": "Bin step in basis points (e.g. 1, 2, 4, 5, 10, 20, 25, 50, 100). Sets pool granularity; cannot be changed after creation.", + "type": "number", + "example": 20 + }, + "feeBps": { + "description": "Base swap fee in basis points (e.g. 20 = 0.20%). Must be compatible with binStep.", + "type": "number", + "example": 20 + } + }, + "required": ["baseToken", "quoteToken", "initialPrice", "binStep", "feeBps"] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "signature": { "type": "string" }, - "currentBlockNumber": { + "status": { + "description": "TransactionStatus enum value", "type": "number" }, - "nativeCurrency": { + "poolAddress": { + "description": "Address of the newly created pool", "type": "string" }, - "swapProvider": { - "type": "string" + "price": { + "description": "Initial price the pool was initialized at (quote per base)", + "type": "number" + }, + "data": { + "type": "object", + "properties": { + "fee": { + "type": "number" + } + }, + "required": ["fee"] } }, - "required": [ - "chain", - "network", - "rpcUrl", - "rpcProvider", - "currentBlockNumber", - "nativeCurrency", - "swapProvider" - ] + "required": ["signature", "status", "poolAddress"] } } } @@ -4279,21 +7274,31 @@ } } }, - "/chains/ethereum/estimate-gas": { - "get": { - "tags": ["/chain/ethereum"], - "description": "Estimate gas prices for Ethereum transactions", + "/connectors/meteora/clmm/pool-info": { + "get": { + "tags": ["/connector/meteora"], + "description": "Get pool information for a Meteora pool", "parameters": [ { "schema": { - "default": "mainnet", - "enum": ["arbitrum", "avalanche", "base", "bsc", "celo", "mainnet", "optimism", "polygon", "sepolia"], + "default": "mainnet-beta", + "enum": ["devnet", "mainnet-beta"], "type": "string" }, "in": "query", "name": "network", "required": false, - "description": "The Ethereum network to use" + "description": "Solana network to use" + }, + { + "schema": { + "type": "string" + }, + "example": "2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3", + "in": "query", + "name": "poolAddress", + "required": true, + "description": "Meteora DLMM pool address" } ], "responses": { @@ -4304,35 +7309,77 @@ "schema": { "type": "object", "properties": { - "feePerComputeUnit": { - "type": "number" + "address": { + "type": "string" }, - "denomination": { + "baseTokenAddress": { "type": "string" }, - "computeUnits": { + "quoteTokenAddress": { + "type": "string" + }, + "binStep": { "type": "number" }, - "feeAsset": { - "type": "string" + "feePct": { + "type": "number" }, - "fee": { + "price": { "type": "number" }, - "timestamp": { + "baseTokenAmount": { "type": "number" }, - "gasType": { - "type": "string" + "quoteTokenAmount": { + "type": "number" }, - "maxFeePerGas": { + "activeBinId": { "type": "number" }, - "maxPriorityFeePerGas": { + "bins": { + "type": "array", + "items": { + "type": "object", + "properties": { + "binId": { + "type": "number" + }, + "price": { + "type": "number" + }, + "baseTokenAmount": { + "type": "number" + }, + "quoteTokenAmount": { + "type": "number" + } + }, + "required": ["binId", "price", "baseTokenAmount", "quoteTokenAmount"] + } + }, + "dynamicFeePct": { + "type": "number" + }, + "minBinId": { + "type": "number" + }, + "maxBinId": { "type": "number" } }, - "required": ["feePerComputeUnit", "denomination", "computeUnits", "feeAsset", "fee", "timestamp"] + "required": [ + "address", + "baseTokenAddress", + "quoteTokenAddress", + "feePct", + "price", + "baseTokenAmount", + "quoteTokenAmount", + "activeBinId", + "dynamicFeePct", + "minBinId", + "maxBinId" + ] } } } @@ -4340,66 +7387,99 @@ } } }, - "/chains/ethereum/balances": { - "post": { - "tags": ["/chain/ethereum"], - "description": "Get Ethereum balances. If no tokens specified or empty array provided, returns native token (ETH) and only non-zero balances for tokens from the token list. If specific tokens are requested, returns those exact tokens with their balances, including zeros.", - "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", - "sepolia" - ], - "type": "string" - }, - "address": { - "description": "Ethereum wallet address", - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", - "type": "string" - }, - "tokens": { - "description": "A list of token symbols (ETH, USDC, WETH) or token addresses. Both formats are accepted and will be automatically detected. An empty array is treated the same as if the parameter was not provided, returning only non-zero balances (with the exception of ETH).", - "type": "array", - "items": { - "type": "string" - }, - "example": ["ETH", "USDC", "WETH"] - } - } - } - } + "/connectors/meteora/clmm/positions-owned": { + "get": { + "tags": ["/connector/meteora"], + "description": "Retrieve all positions owned by a user's wallet across all Meteora pools", + "parameters": [ + { + "schema": { + "default": "mainnet-beta", + "enum": ["devnet", "mainnet-beta"], + "type": "string" + }, + "in": "query", + "name": "network", + "required": false, + "description": "Solana network to use" + }, + { + "schema": { + "type": "string" + }, + "example": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "in": "query", + "name": "walletAddress", + "required": true, + "description": "Solana wallet address to check for positions" } - }, + ], "responses": { "200": { "description": "Default Response", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "balances": { - "type": "object", - "additionalProperties": { + "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": ["balances"] + }, + "required": [ + "address", + "poolAddress", + "baseTokenAddress", + "quoteTokenAddress", + "baseTokenAmount", + "quoteTokenAmount", + "baseFeeAmount", + "quoteFeeAmount", + "lowerBinId", + "upperBinId", + "lowerPrice", + "upperPrice", + "price" + ] + } } } } @@ -4407,44 +7487,33 @@ } } }, - "/chains/ethereum/poll": { - "post": { - "tags": ["/chain/ethereum"], - "description": "Poll Ethereum transaction status", - "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", - "sepolia" - ], - "type": "string" - }, - "signature": { - "description": "Transaction hash to poll", - "type": "string", - "example": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" - } - }, - "required": ["signature"] - } - } + "/connectors/meteora/clmm/position-info": { + "get": { + "tags": ["/connector/meteora"], + "description": "Get details for a specific Meteora position", + "parameters": [ + { + "schema": { + "default": "mainnet-beta", + "enum": ["devnet", "mainnet-beta"], + "type": "string" + }, + "in": "query", + "name": "network", + "required": false, + "description": "Solana network to use" }, - "required": true - }, + { + "schema": { + "type": "string" + }, + "example": "", + "in": "query", + "name": "positionAddress", + "required": true, + "description": "Position NFT address" + } + ], "responses": { "200": { "description": "Default Response", @@ -4453,58 +7522,61 @@ "schema": { "type": "object", "properties": { - "currentBlock": { - "type": "number" + "address": { + "type": "string" }, - "signature": { + "poolAddress": { "type": "string" }, - "txBlock": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] + "baseTokenAddress": { + "type": "string" }, - "txStatus": { + "quoteTokenAddress": { + "type": "string" + }, + "baseTokenAmount": { "type": "number" }, - "fee": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] + "quoteTokenAmount": { + "type": "number" }, - "tokenBalanceChanges": { - "description": "Dictionary of token balance changes keyed by token input value (symbol or address)", - "type": "object", - "additionalProperties": { - "type": "number" - } + "baseFeeAmount": { + "type": "number" }, - "txData": { - "anyOf": [ - { - "type": "object", - "additionalProperties": {} - }, - { - "type": "null" - } - ] + "quoteFeeAmount": { + "type": "number" }, - "error": { - "type": "string" + "lowerBinId": { + "type": "number" + }, + "upperBinId": { + "type": "number" + }, + "lowerPrice": { + "type": "number" + }, + "upperPrice": { + "type": "number" + }, + "price": { + "type": "number" } }, - "required": ["currentBlock", "signature", "txBlock", "txStatus", "fee", "txData"] + "required": [ + "address", + "poolAddress", + "baseTokenAddress", + "quoteTokenAddress", + "baseTokenAmount", + "quoteTokenAmount", + "baseFeeAmount", + "quoteFeeAmount", + "lowerBinId", + "upperBinId", + "lowerPrice", + "upperPrice", + "price" + ] } } } @@ -4512,57 +7584,97 @@ } } }, - "/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", - "sepolia" - ], - "type": "string" - }, - "address": { - "description": "Ethereum wallet address", - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", - "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"] - } - } + "/connectors/meteora/clmm/quote-position": { + "get": { + "tags": ["/connector/meteora"], + "description": "Quote amounts for a new Meteora CLMM position", + "parameters": [ + { + "schema": { + "default": "mainnet-beta", + "enum": ["devnet", "mainnet-beta"], + "type": "string" + }, + "in": "query", + "name": "network", + "required": false, + "description": "Solana network to use" }, - "required": true - }, + { + "schema": { + "type": "number" + }, + "example": 150, + "in": "query", + "name": "lowerPrice", + "required": true, + "description": "Lower price bound for the position" + }, + { + "schema": { + "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": "Meteora DLMM pool address" + }, + { + "schema": { + "type": "number" + }, + "example": 0.01, + "in": "query", + "name": "baseTokenAmount", + "required": false, + "description": "Amount of base token to deposit" + }, + { + "schema": { + "type": "number" + }, + "example": 2, + "in": "query", + "name": "quoteTokenAmount", + "required": false, + "description": "Amount of quote token to deposit" + }, + { + "schema": { + "minimum": 0, + "maximum": 100, + "default": 2, + "type": "number" + }, + "example": 2, + "in": "query", + "name": "slippagePct", + "required": false, + "description": "Maximum acceptable slippage percentage" + }, + { + "schema": { + "enum": [0, 1, 2], + "type": "number" + }, + "example": 0, + "in": "query", + "name": "strategyType", + "required": false, + "description": "Strategy type for the position" + } + ], "responses": { "200": { "description": "Default Response", @@ -4571,17 +7683,30 @@ "schema": { "type": "object", "properties": { - "spender": { - "type": "string" + "baseLimited": { + "type": "boolean" + }, + "baseTokenAmount": { + "type": "number" + }, + "quoteTokenAmount": { + "type": "number" + }, + "baseTokenAmountMax": { + "type": "number" + }, + "quoteTokenAmountMax": { + "type": "number" }, - "approvals": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } + "liquidity": {} }, - "required": ["spender", "approvals"] + "required": [ + "baseLimited", + "baseTokenAmount", + "quoteTokenAmount", + "baseTokenAmountMax", + "quoteTokenAmountMax" + ] } } } @@ -4589,59 +7714,88 @@ } } }, - "/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", - "sepolia" - ], - "type": "string" - }, - "address": { - "description": "Ethereum wallet address", - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", - "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"] - } - } + "/connectors/meteora/clmm/quote-swap": { + "get": { + "tags": ["/connector/meteora"], + "description": "Get swap quote for Meteora CLMM", + "parameters": [ + { + "schema": { + "default": "mainnet-beta", + "enum": ["devnet", "mainnet-beta"], + "type": "string" + }, + "in": "query", + "name": "network", + "required": false, + "description": "Solana network to use" }, - "required": true - }, + { + "schema": { + "type": "string" + }, + "example": "2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3", + "in": "query", + "name": "poolAddress", + "required": false, + "description": "Meteora DLMM pool address (optional - can be looked up from baseToken and quoteToken)" + }, + { + "schema": { + "type": "string" + }, + "example": "SOL", + "in": "query", + "name": "baseToken", + "required": true, + "description": "Token to determine swap direction" + }, + { + "schema": { + "type": "string" + }, + "example": "USDC", + "in": "query", + "name": "quoteToken", + "required": false, + "description": "The other token in the pair (optional - required if poolAddress not provided)" + }, + { + "schema": { + "type": "number" + }, + "example": 0.01, + "in": "query", + "name": "amount", + "required": true, + "description": "Amount to swap" + }, + { + "schema": { + "enum": ["BUY", "SELL"], + "default": "SELL", + "type": "string" + }, + "example": "SELL", + "in": "query", + "name": "side", + "required": true, + "description": "Trade direction" + }, + { + "schema": { + "minimum": 0, + "maximum": 100, + "default": 2, + "type": "number" + }, + "example": 2, + "in": "query", + "name": "slippagePct", + "required": false, + "description": "Maximum acceptable slippage percentage" + } + ], "responses": { "200": { "description": "Default Response", @@ -4650,36 +7804,48 @@ "schema": { "type": "object", "properties": { - "signature": { + "poolAddress": { "type": "string" }, - "status": { - "description": "TransactionStatus enum value", + "tokenIn": { + "type": "string" + }, + "tokenOut": { + "type": "string" + }, + "amountIn": { "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"] + "amountOut": { + "type": "number" + }, + "price": { + "type": "number" + }, + "slippagePct": { + "type": "number" + }, + "minAmountOut": { + "type": "number" + }, + "maxAmountIn": { + "type": "number" + }, + "priceImpactPct": { + "type": "number" } }, - "required": ["signature", "status"] + "required": [ + "poolAddress", + "tokenIn", + "tokenOut", + "amountIn", + "amountOut", + "price", + "minAmountOut", + "maxAmountIn", + "priceImpactPct" + ] } } } @@ -4687,10 +7853,10 @@ } } }, - "/chains/ethereum/wrap": { + "/connectors/meteora/clmm/execute-swap": { "post": { - "tags": ["/chain/ethereum"], - "description": "Wrap native token to wrapped token (e.g., ETH to WETH, BNB to WBNB)", + "tags": ["/connector/meteora"], + "description": "Execute a token swap on Meteora DLMM", "requestBody": { "content": { "application/json": { @@ -4698,33 +7864,54 @@ "type": "object", "properties": { "network": { - "description": "The Ethereum network to use", - "default": "mainnet", - "enum": [ - "arbitrum", - "avalanche", - "base", - "bsc", - "celo", - "mainnet", - "optimism", - "polygon", - "sepolia" - ], + "description": "Solana network to use", + "default": "mainnet-beta", + "enum": ["devnet", "mainnet-beta"], "type": "string" }, - "address": { - "description": "Ethereum wallet address", - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", - "type": "string" + "walletAddress": { + "description": "Solana wallet address that will execute the swap", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string", + "example": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5" + }, + "poolAddress": { + "description": "Meteora DLMM pool address (optional - can be looked up from baseToken and quoteToken)", + "type": "string", + "example": "2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3" + }, + "baseToken": { + "description": "Base token symbol or address", + "type": "string", + "example": "SOL" + }, + "quoteToken": { + "description": "Quote token symbol or address (optional - required if poolAddress not provided)", + "type": "string", + "example": "USDC" }, "amount": { - "description": "The amount of native token to wrap (e.g., ETH, BNB, AVAX)", + "description": "Amount to swap", + "type": "number", + "example": 0.01 + }, + "side": { + "description": "Trade direction", + "enum": ["BUY", "SELL"], + "default": "SELL", "type": "string", - "example": "0.01" + "example": "SELL" + }, + "slippagePct": { + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage", + "default": 2, + "type": "number", + "example": 2 } }, - "required": ["amount"] + "required": ["baseToken", "amount", "side"] } } }, @@ -4748,26 +7935,41 @@ "data": { "type": "object", "properties": { - "nonce": { - "type": "number" - }, - "fee": { + "tokenIn": { "type": "string" }, - "amount": { + "tokenOut": { "type": "string" }, - "wrappedAddress": { - "type": "string" + "amountIn": { + "type": "number" }, - "nativeToken": { - "type": "string" + "amountOut": { + "type": "number" }, - "wrappedToken": { - "type": "string" + "fee": { + "type": "number" + }, + "baseTokenBalanceChange": { + "type": "number" + }, + "quoteTokenBalanceChange": { + "type": "number" + }, + "slippagePct": { + "description": "Slippage tolerance percentage actually applied to the swap", + "type": "number" } }, - "required": ["nonce", "fee", "amount", "wrappedAddress", "nativeToken", "wrappedToken"] + "required": [ + "tokenIn", + "tokenOut", + "amountIn", + "amountOut", + "fee", + "baseTokenBalanceChange", + "quoteTokenBalanceChange" + ] } }, "required": ["signature", "status"] @@ -4778,10 +7980,10 @@ } } }, - "/chains/ethereum/unwrap": { + "/connectors/meteora/clmm/open-position": { "post": { - "tags": ["/chain/ethereum"], - "description": "Unwrap wrapped token to native token (e.g., WETH to ETH, WBNB to BNB)", + "tags": ["/connector/meteora"], + "description": "Open a new Meteora position", "requestBody": { "content": { "application/json": { @@ -4789,33 +7991,58 @@ "type": "object", "properties": { "network": { - "description": "The Ethereum network to use", - "default": "mainnet", - "enum": [ - "arbitrum", - "avalanche", - "base", - "bsc", - "celo", - "mainnet", - "optimism", - "polygon", - "sepolia" - ], + "description": "Solana network to use", + "default": "mainnet-beta", + "enum": ["devnet", "mainnet-beta"], "type": "string" }, - "address": { - "description": "Ethereum wallet address", - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", - "type": "string" + "walletAddress": { + "description": "Solana wallet address that will open the position", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string", + "example": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5" }, - "amount": { - "description": "The amount of wrapped token to unwrap (e.g., WETH, WBNB, WAVAX)", + "lowerPrice": { + "description": "Lower price bound for the position", + "type": "number", + "example": 150 + }, + "upperPrice": { + "description": "Upper price bound for the position", + "type": "number", + "example": 250 + }, + "poolAddress": { + "description": "Meteora DLMM pool address", "type": "string", - "example": "0.01" + "example": "2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3" + }, + "baseTokenAmount": { + "description": "Amount of base token to deposit", + "type": "number", + "example": 0.01 + }, + "quoteTokenAmount": { + "description": "Amount of quote token to deposit", + "type": "number", + "example": 2 + }, + "slippagePct": { + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage", + "default": 2, + "type": "number", + "example": 2 + }, + "strategyType": { + "description": "Strategy type for the position", + "enum": [0, 1, 2], + "type": "number", + "example": 0 } }, - "required": ["amount"] + "required": ["lowerPrice", "upperPrice", "poolAddress"] } } }, @@ -4839,26 +8066,29 @@ "data": { "type": "object", "properties": { - "nonce": { - "type": "number" - }, "fee": { - "type": "string" + "type": "number" }, - "amount": { + "positionAddress": { "type": "string" }, - "wrappedAddress": { - "type": "string" + "positionRent": { + "type": "number" }, - "nativeToken": { - "type": "string" + "baseTokenAmountAdded": { + "type": "number" }, - "wrappedToken": { - "type": "string" + "quoteTokenAmountAdded": { + "type": "number" } }, - "required": ["nonce", "fee", "amount", "wrappedAddress", "nativeToken", "wrappedToken"] + "required": [ + "fee", + "positionAddress", + "positionRent", + "baseTokenAmountAdded", + "quoteTokenAmountAdded" + ] } }, "required": ["signature", "status"] @@ -4869,96 +8099,64 @@ } } }, - "/connectors/jupiter/router/quote-swap": { - "get": { - "tags": ["/connector/jupiter"], - "description": "Get an executable swap quote from Jupiter", - "parameters": [ - { - "schema": { - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "in": "query", - "name": "network", - "required": false, - "description": "Solana network to use" - }, - { - "schema": { - "type": "string" - }, - "example": "SOL", - "in": "query", - "name": "baseToken", - "required": true, - "description": "Solana token symbol or address to determine swap direction" - }, - { - "schema": { - "type": "string" - }, - "example": "USDC", - "in": "query", - "name": "quoteToken", - "required": true, - "description": "The other Solana token symbol or address in the pair" - }, - { - "schema": { - "type": "number" - }, - "example": 0.1, - "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": "Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token" - }, - { - "schema": { - "minimum": 0, - "maximum": 100, - "default": 1, - "type": "number" - }, - "in": "query", - "name": "slippagePct", - "required": false, - "description": "Maximum acceptable slippage percentage" - }, - { - "schema": { - "default": true, - "type": "boolean" - }, - "in": "query", - "name": "restrictIntermediateTokens", - "required": false, - "description": "Restrict routing through highly liquid intermediate tokens only for better price and stability" + "/connectors/meteora/clmm/add-liquidity": { + "post": { + "tags": ["/connector/meteora"], + "description": "Add liquidity to a Meteora position", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "network": { + "description": "Solana network to use", + "default": "mainnet-beta", + "enum": ["devnet", "mainnet-beta"], + "type": "string" + }, + "walletAddress": { + "description": "Solana wallet address that will add liquidity", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string", + "example": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5" + }, + "positionAddress": { + "description": "Position NFT address", + "type": "string", + "example": "" + }, + "baseTokenAmount": { + "description": "Amount of base token to deposit", + "type": "number", + "example": 0.01 + }, + "quoteTokenAmount": { + "description": "Amount of quote token to deposit", + "type": "number", + "example": 2 + }, + "slippagePct": { + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage", + "default": 2, + "type": "number", + "example": 2 + }, + "strategyType": { + "description": "Strategy type for the position", + "enum": [0, 1, 2], + "type": "number", + "example": 0 + } + }, + "required": ["positionAddress"] + } + } }, - { - "schema": { - "default": false, - "type": "boolean" - }, - "in": "query", - "name": "onlyDirectRoutes", - "required": false, - "description": "Restrict routing to only go through 1 market" - } - ], + "required": true + }, "responses": { "200": { "description": "Default Response", @@ -4967,123 +8165,30 @@ "schema": { "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", + "signature": { "type": "string" }, - "amountIn": { - "description": "Amount of tokenIn to be swapped", - "type": "number" - }, - "amountOut": { - "description": "Expected amount of tokenOut to receive", - "type": "number" - }, - "price": { - "description": "Exchange rate between tokenIn and tokenOut", - "type": "number" - }, - "priceImpactPct": { - "description": "Estimated price impact percentage (0-100)", - "type": "number" - }, - "minAmountOut": { - "description": "Minimum amount of tokenOut that will be accepted", - "type": "number" - }, - "maxAmountIn": { - "description": "Maximum amount of tokenIn that will be spent", + "status": { + "description": "TransactionStatus enum value", "type": "number" }, - "quoteResponse": { + "data": { "type": "object", "properties": { - "inputMint": { - "description": "Solana mint address of input token", - "type": "string" - }, - "inAmount": { - "description": "Input amount in token decimals", - "type": "string" - }, - "outputMint": { - "description": "Solana mint address of output token", - "type": "string" - }, - "outAmount": { - "description": "Expected output amount in token decimals", - "type": "string" - }, - "otherAmountThreshold": { - "description": "Minimum output amount based on slippage", - "type": "string" - }, - "swapMode": { - "description": "Swap mode used (ExactIn or ExactOut)", - "type": "string" - }, - "slippageBps": { - "description": "Slippage in basis points", + "fee": { "type": "number" }, - "platformFee": { - "description": "Platform fee information if applicable" - }, - "priceImpactPct": { - "description": "Estimated price impact percentage", - "type": "string" - }, - "routePlan": { - "description": "Detailed routing plan through various markets", - "type": "array", - "items": {} - }, - "contextSlot": { - "description": "Solana slot used for quote calculation", + "baseTokenAmountAdded": { "type": "number" }, - "timeTaken": { - "description": "Time taken to generate quote in milliseconds", + "quoteTokenAmountAdded": { "type": "number" } }, - "required": [ - "inputMint", - "inAmount", - "outputMint", - "outAmount", - "otherAmountThreshold", - "swapMode", - "slippageBps", - "priceImpactPct", - "routePlan" - ] - }, - "approximation": { - "description": "Indicates if ExactIn approximation was used when ExactOut route was not available", - "type": "boolean" + "required": ["fee", "baseTokenAmountAdded", "quoteTokenAmountAdded"] } }, - "required": [ - "quoteId", - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "price", - "priceImpactPct", - "minAmountOut", - "maxAmountIn", - "quoteResponse" - ] + "required": ["signature", "status"] } } } @@ -5091,45 +8196,43 @@ } } }, - "/connectors/jupiter/router/execute-quote": { + "/connectors/meteora/clmm/remove-liquidity": { "post": { - "tags": ["/connector/jupiter"], - "description": "Execute a previously fetched quote from Jupiter", + "tags": ["/connector/meteora"], + "description": "Remove liquidity from a Meteora position", "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { - "walletAddress": { - "description": "Solana wallet address that will execute the swap", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string" - }, "network": { "description": "Solana network to use", "default": "mainnet-beta", "enum": ["devnet", "mainnet-beta"], "type": "string" }, - "quoteId": { - "description": "ID of the Jupiter quote to execute", + "walletAddress": { + "description": "Solana wallet address that will remove liquidity", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string", - "example": "123e4567-e89b-12d3-a456-426614174000" + "example": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5" }, - "priorityLevel": { - "description": "Priority level for Solana transaction processing", - "enum": ["medium", "high", "veryHigh"], - "default": "veryHigh", - "type": "string" + "positionAddress": { + "description": "Position NFT address", + "type": "string", + "example": "" }, - "maxLamports": { - "description": "Maximum priority fee in lamports for Solana transaction", - "default": [1000000], - "type": "number" + "percentageToRemove": { + "minimum": 0, + "maximum": 100, + "description": "Percentage of liquidity to remove", + "default": 100, + "type": "number", + "example": 100 } }, - "required": ["quoteId"] + "required": ["positionAddress"] } } }, @@ -5144,54 +8247,26 @@ "type": "object", "properties": { "signature": { - "description": "Transaction signature/hash", "type": "string" }, "status": { - "description": "Transaction status: 0 = PENDING, 1 = CONFIRMED, -1 = FAILED", + "description": "TransactionStatus enum value", "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": { - "description": "Actual amount of tokenIn swapped", - "type": "number" - }, - "amountOut": { - "description": "Actual amount of tokenOut received", - "type": "number" - }, "fee": { - "description": "Transaction fee paid", "type": "number" }, - "baseTokenBalanceChange": { - "description": "Change in base token balance (negative for decrease)", + "baseTokenAmountRemoved": { "type": "number" }, - "quoteTokenBalanceChange": { - "description": "Change in quote token balance (negative for decrease)", + "quoteTokenAmountRemoved": { "type": "number" } }, - "required": [ - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "fee", - "baseTokenBalanceChange", - "quoteTokenBalanceChange" - ] + "required": ["fee", "baseTokenAmountRemoved", "quoteTokenAmountRemoved"] } }, "required": ["signature", "status"] @@ -5202,78 +8277,35 @@ } } }, - "/connectors/jupiter/router/execute-swap": { + "/connectors/meteora/clmm/collect-fees": { "post": { - "tags": ["/connector/jupiter"], - "description": "Quote and execute a token swap on Jupiter in one step", + "tags": ["/connector/meteora"], + "description": "Collect fees from a Meteora position", "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { - "walletAddress": { - "description": "Solana wallet address that will execute the swap", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string" - }, "network": { "description": "Solana network to use", "default": "mainnet-beta", "enum": ["devnet", "mainnet-beta"], "type": "string" }, - "baseToken": { - "description": "Solana token symbol or address to determine swap direction", + "walletAddress": { + "description": "Solana wallet address that will collect fees", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string", - "example": "SOL" + "example": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5" }, - "quoteToken": { - "description": "The other Solana token symbol or address in the pair", + "positionAddress": { + "description": "Position NFT address", "type": "string", - "example": "USDC" - }, - "amount": { - "description": "Amount of base token to trade", - "type": "number", - "example": 0.1 - }, - "side": { - "description": "Trade direction - BUY means buying base token with quote token, SELL means selling base token for quote token", - "enum": ["BUY", "SELL"], - "default": "SELL", - "type": "string" - }, - "slippagePct": { - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage", - "default": 1, - "type": "number" - }, - "restrictIntermediateTokens": { - "description": "Restrict routing through highly liquid intermediate tokens only for better price and stability", - "default": true, - "type": "boolean" - }, - "onlyDirectRoutes": { - "description": "Restrict routing to only go through 1 market", - "default": false, - "type": "boolean" - }, - "priorityLevel": { - "description": "Priority level for Solana transaction processing", - "enum": ["medium", "high", "veryHigh"], - "default": "veryHigh", - "type": "string" - }, - "maxLamports": { - "description": "Maximum priority fee in lamports for Solana transaction", - "default": 1000000, - "type": "number" + "example": "" } }, - "required": ["baseToken", "quoteToken", "amount", "side"] + "required": ["positionAddress"] } } }, @@ -5288,54 +8320,26 @@ "type": "object", "properties": { "signature": { - "description": "Transaction signature/hash", "type": "string" }, "status": { - "description": "Transaction status: 0 = PENDING, 1 = CONFIRMED, -1 = FAILED", + "description": "TransactionStatus enum value", "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": { - "description": "Actual amount of tokenIn swapped", - "type": "number" - }, - "amountOut": { - "description": "Actual amount of tokenOut received", - "type": "number" - }, "fee": { - "description": "Transaction fee paid", "type": "number" }, - "baseTokenBalanceChange": { - "description": "Change in base token balance (negative for decrease)", + "baseFeeAmountCollected": { "type": "number" }, - "quoteTokenBalanceChange": { - "description": "Change in quote token balance (negative for decrease)", + "quoteFeeAmountCollected": { "type": "number" } }, - "required": [ - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "fee", - "baseTokenBalanceChange", - "quoteTokenBalanceChange" - ] + "required": ["fee", "baseFeeAmountCollected", "quoteFeeAmountCollected"] } }, "required": ["signature", "status"] @@ -5346,139 +8350,40 @@ } } }, - "/connectors/meteora/clmm/fetch-pools": { - "get": { + "/connectors/meteora/clmm/close-position": { + "post": { "tags": ["/connector/meteora"], - "description": "Fetch info about Meteora pools", - "parameters": [ - { - "schema": { - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "in": "query", - "name": "network", - "required": false, - "description": "Solana network to use" - }, - { - "schema": { - "minimum": 1, - "default": 10, - "type": "number" - }, - "example": 10, - "in": "query", - "name": "limit", - "required": false, - "description": "Maximum number of pools to return" - }, - { - "schema": { - "type": "string" - }, - "example": "SOL", - "in": "query", - "name": "tokenA", - "required": false, - "description": "First token symbol or address" - }, - { - "schema": { - "type": "string" - }, - "example": "USDC", - "in": "query", - "name": "tokenB", - "required": false, - "description": "Second token symbol or address" - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "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" - } - }, - "required": [ - "address", - "baseTokenAddress", - "quoteTokenAddress", - "feePct", - "price", - "baseTokenAmount", - "quoteTokenAmount", - "activeBinId" - ], - "title": "PoolInfo" + "description": "Close a Meteora position", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "network": { + "description": "Solana network to use", + "default": "mainnet-beta", + "enum": ["devnet", "mainnet-beta"], + "type": "string" + }, + "walletAddress": { + "description": "Solana wallet address that will close the position", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string", + "example": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5" + }, + "positionAddress": { + "description": "Position NFT address", + "type": "string", + "example": "" } - } + }, + "required": ["positionAddress"] } } - } - } - } - }, - "/connectors/meteora/clmm/pool-info": { - "get": { - "tags": ["/connector/meteora"], - "description": "Get pool information for a Meteora pool", - "parameters": [ - { - "schema": { - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "in": "query", - "name": "network", - "required": false, - "description": "Solana network to use" }, - { - "schema": { - "type": "string" - }, - "example": "2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3", - "in": "query", - "name": "poolAddress", - "required": true, - "description": "Meteora DLMM pool address" - } - ], + "required": true + }, "responses": { "200": { "description": "Default Response", @@ -5487,79 +8392,46 @@ "schema": { "type": "object", "properties": { - "address": { - "type": "string" - }, - "baseTokenAddress": { - "type": "string" - }, - "quoteTokenAddress": { + "signature": { "type": "string" }, - "binStep": { - "type": "number" - }, - "feePct": { - "type": "number" - }, - "price": { - "type": "number" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "activeBinId": { - "type": "number" - }, - "dynamicFeePct": { - "type": "number" - }, - "minBinId": { - "type": "number" - }, - "maxBinId": { + "status": { + "description": "TransactionStatus enum value", "type": "number" }, - "bins": { - "type": "array", - "items": { - "type": "object", - "properties": { - "binId": { - "type": "number" - }, - "price": { - "type": "number" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - } + "data": { + "type": "object", + "properties": { + "fee": { + "type": "number" }, - "required": ["binId", "price", "baseTokenAmount", "quoteTokenAmount"], - "title": "BinLiquidity" - } + "positionRentRefunded": { + "type": "number" + }, + "baseTokenAmountRemoved": { + "type": "number" + }, + "quoteTokenAmountRemoved": { + "type": "number" + }, + "baseFeeAmountCollected": { + "type": "number" + }, + "quoteFeeAmountCollected": { + "type": "number" + } + }, + "required": [ + "fee", + "positionRentRefunded", + "baseTokenAmountRemoved", + "quoteTokenAmountRemoved", + "baseFeeAmountCollected", + "quoteFeeAmountCollected" + ] } }, - "required": [ - "address", - "baseTokenAddress", - "quoteTokenAddress", - "feePct", - "price", - "baseTokenAmount", - "quoteTokenAmount", - "activeBinId", - "dynamicFeePct", - "minBinId", - "maxBinId", - "bins" - ] + "required": ["signature", "status"] } } } @@ -5567,10 +8439,10 @@ } } }, - "/connectors/meteora/clmm/positions-owned": { + "/connectors/meteora/amm/pool-info": { "get": { "tags": ["/connector/meteora"], - "description": "Retrieve all positions owned by a user's wallet across all Meteora pools", + "description": "Get AMM pool information from Meteora DAMM v2", "parameters": [ { "schema": { @@ -5587,11 +8459,11 @@ "schema": { "type": "string" }, - "example": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", + "example": "FH6mP2MUobhDnLERp9z5yv5t2zMUA9WDNXPixpbvYKMv", "in": "query", - "name": "walletAddress", + "name": "poolAddress", "required": true, - "description": "Solana wallet address to check for positions" + "description": "Meteora DAMM v2 pool address" } ], "responses": { @@ -5600,72 +8472,39 @@ "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" - }, - "rewardTokenAddress": { - "type": "string" - }, - "rewardAmount": { - "type": "number" - } + "type": "object", + "properties": { + "address": { + "type": "string" }, - "required": [ - "address", - "poolAddress", - "baseTokenAddress", - "quoteTokenAddress", - "baseTokenAmount", - "quoteTokenAmount", - "baseFeeAmount", - "quoteFeeAmount", - "lowerBinId", - "upperBinId", - "lowerPrice", - "upperPrice", - "price" - ] - } + "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" + ] } } } @@ -5673,10 +8512,10 @@ } } }, - "/connectors/meteora/clmm/position-info": { + "/connectors/meteora/amm/position-info": { "get": { "tags": ["/connector/meteora"], - "description": "Get details for a specific Meteora position", + "description": "Get the wallet's aggregated liquidity in a Meteora DAMM v2 pool. DAMM v2 positions are NFTs; amounts sum across all of the wallet positions in the pool.", "parameters": [ { "schema": { @@ -5693,11 +8532,21 @@ "schema": { "type": "string" }, - "example": "\u003Csample-position-address\u003E", + "example": "FH6mP2MUobhDnLERp9z5yv5t2zMUA9WDNXPixpbvYKMv", "in": "query", - "name": "positionAddress", + "name": "poolAddress", "required": true, - "description": "Position NFT address" + "description": "Meteora DAMM v2 pool address" + }, + { + "schema": { + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "in": "query", + "name": "walletAddress", + "required": false, + "description": "Solana wallet address" } ], "responses": { @@ -5708,10 +8557,10 @@ "schema": { "type": "object", "properties": { - "address": { + "poolAddress": { "type": "string" }, - "poolAddress": { + "walletAddress": { "type": "string" }, "baseTokenAddress": { @@ -5720,53 +8569,50 @@ "quoteTokenAddress": { "type": "string" }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "baseFeeAmount": { - "type": "number" - }, - "quoteFeeAmount": { - "type": "number" - }, - "lowerBinId": { - "type": "number" - }, - "upperBinId": { + "lpTokenAmount": { "type": "number" }, - "lowerPrice": { + "baseTokenAmount": { "type": "number" }, - "upperPrice": { + "quoteTokenAmount": { "type": "number" }, "price": { "type": "number" }, - "rewardTokenAddress": { - "type": "string" - }, - "rewardAmount": { - "type": "number" + "positions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "positionAddress": { + "description": "Address of the individual position (NFT position account)", + "type": "string" + }, + "lpTokenAmount": { + "description": "Liquidity held by this position (LP units)", + "type": "number" + }, + "baseTokenAmount": { + "type": "number" + }, + "quoteTokenAmount": { + "type": "number" + } + }, + "required": ["positionAddress", "lpTokenAmount", "baseTokenAmount", "quoteTokenAmount"] + } } }, "required": [ - "address", "poolAddress", + "walletAddress", "baseTokenAddress", "quoteTokenAddress", + "lpTokenAmount", "baseTokenAmount", "quoteTokenAmount", - "baseFeeAmount", - "quoteFeeAmount", - "lowerBinId", - "upperBinId", - "lowerPrice", - "upperPrice", "price" ] } @@ -5776,95 +8622,31 @@ } } }, - "/connectors/meteora/clmm/quote-position": { + "/connectors/meteora/amm/positions-owned": { "get": { "tags": ["/connector/meteora"], - "description": "Quote amounts for a new Meteora CLMM position", + "description": "List all of a wallet's DAMM v2 positions across all Meteora AMM pools", "parameters": [ { "schema": { "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "in": "query", - "name": "network", - "required": false, - "description": "Solana network to use" - }, - { - "schema": { - "type": "number" - }, - "example": 150, - "in": "query", - "name": "lowerPrice", - "required": true, - "description": "Lower price bound for the position" - }, - { - "schema": { - "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": "Meteora DLMM pool address" - }, - { - "schema": { - "type": "number" - }, - "example": 0.01, - "in": "query", - "name": "baseTokenAmount", - "required": false, - "description": "Amount of base token to deposit" - }, - { - "schema": { - "type": "number" - }, - "example": 2, - "in": "query", - "name": "quoteTokenAmount", - "required": false, - "description": "Amount of quote token to deposit" - }, - { - "schema": { - "minimum": 0, - "maximum": 100, - "default": 2, - "type": "number" + "enum": ["devnet", "mainnet-beta"], + "type": "string" }, - "example": 2, "in": "query", - "name": "slippagePct", + "name": "network", "required": false, - "description": "Maximum acceptable slippage percentage" + "description": "Solana network to use" }, { "schema": { - "enum": [0, 1, 2], - "type": "number" + "type": "string" }, - "example": 0, + "example": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "in": "query", - "name": "strategyType", - "required": false, - "description": "Strategy type for the position" + "name": "walletAddress", + "required": true, + "description": "Solana wallet address to list DAMM v2 positions for" } ], "responses": { @@ -5873,32 +8655,69 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "baseLimited": { - "type": "boolean" - }, - "baseTokenAmount": { - "type": "number" - }, - "quoteTokenAmount": { - "type": "number" - }, - "baseTokenAmountMax": { - "type": "number" - }, - "quoteTokenAmountMax": { - "type": "number" + "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": { + "description": "Liquidity held by this position (LP units)", + "type": "number" + }, + "baseTokenAmount": { + "type": "number" + }, + "quoteTokenAmount": { + "type": "number" + } + }, + "required": ["positionAddress", "lpTokenAmount", "baseTokenAmount", "quoteTokenAmount"] + } + } }, - "liquidity": {} - }, - "required": [ - "baseLimited", - "baseTokenAmount", - "quoteTokenAmount", - "baseTokenAmountMax", - "quoteTokenAmountMax" - ] + "required": [ + "poolAddress", + "walletAddress", + "baseTokenAddress", + "quoteTokenAddress", + "lpTokenAmount", + "baseTokenAmount", + "quoteTokenAmount", + "price" + ] + } } } } @@ -5906,10 +8725,10 @@ } } }, - "/connectors/meteora/clmm/quote-swap": { + "/connectors/meteora/amm/quote-swap": { "get": { "tags": ["/connector/meteora"], - "description": "Get swap quote for Meteora CLMM", + "description": "Get a swap quote for a Meteora DAMM v2 pool", "parameters": [ { "schema": { @@ -5926,11 +8745,11 @@ "schema": { "type": "string" }, - "example": "2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3", + "example": "FH6mP2MUobhDnLERp9z5yv5t2zMUA9WDNXPixpbvYKMv", "in": "query", "name": "poolAddress", - "required": false, - "description": "Meteora DLMM pool address (optional - can be looked up from baseToken and quoteToken)" + "required": true, + "description": "Meteora DAMM v2 pool address" }, { "schema": { @@ -5950,7 +8769,7 @@ "in": "query", "name": "quoteToken", "required": false, - "description": "The other token in the pair (optional - required if poolAddress not provided)" + "description": "The other token in the pair (optional - resolved from the pool if omitted)" }, { "schema": { @@ -5960,7 +8779,7 @@ "in": "query", "name": "amount", "required": true, - "description": "Amount to swap" + "description": "Amount to swap (denominated in the base token)" }, { "schema": { @@ -5968,7 +8787,6 @@ "default": "SELL", "type": "string" }, - "example": "SELL", "in": "query", "name": "side", "required": true, @@ -6045,122 +8863,97 @@ } } }, - "/connectors/meteora/clmm/execute-swap": { - "post": { + "/connectors/meteora/amm/quote-liquidity": { + "get": { "tags": ["/connector/meteora"], - "description": "Execute a token swap on Meteora DLMM", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "network": { - "description": "Solana network to use", - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "walletAddress": { - "description": "Solana wallet address that will execute the swap", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string", - "example": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn" - }, - "poolAddress": { - "description": "Meteora DLMM pool address (optional - can be looked up from baseToken and quoteToken)", - "type": "string", - "example": "2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3" - }, - "baseToken": { - "description": "Base token symbol or address", - "type": "string", - "example": "SOL" - }, - "quoteToken": { - "description": "Quote token symbol or address (optional - required if poolAddress not provided)", - "type": "string", - "example": "USDC" - }, - "amount": { - "description": "Amount to swap", - "type": "number", - "example": 0.01 - }, - "side": { - "description": "Trade direction", - "enum": ["BUY", "SELL"], - "default": "SELL", - "type": "string", - "example": "SELL" - }, - "slippagePct": { - "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage", - "default": 2, - "type": "number", - "example": 2 - } - }, - "required": ["baseToken", "amount", "side"] - } - } + "description": "Quote amounts for adding liquidity to a Meteora DAMM v2 pool", + "parameters": [ + { + "schema": { + "default": "mainnet-beta", + "enum": ["devnet", "mainnet-beta"], + "type": "string" + }, + "in": "query", + "name": "network", + "required": false, + "description": "Solana network to use" }, - "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": { - "tokenIn": { - "type": "string" - }, - "tokenOut": { - "type": "string" - }, - "amountIn": { - "type": "number" - }, - "amountOut": { - "type": "number" - }, - "fee": { - "type": "number" - }, - "baseTokenBalanceChange": { - "type": "number" - }, - "quoteTokenBalanceChange": { - "type": "number" - } - }, - "required": [ - "tokenIn", - "tokenOut", - "amountIn", - "amountOut", - "fee", - "baseTokenBalanceChange", - "quoteTokenBalanceChange" - ] + { + "schema": { + "type": "string" + }, + "example": "FH6mP2MUobhDnLERp9z5yv5t2zMUA9WDNXPixpbvYKMv", + "in": "query", + "name": "poolAddress", + "required": true, + "description": "Meteora DAMM v2 pool address" + }, + { + "schema": { + "type": "number" + }, + "example": 0.01, + "in": "query", + "name": "baseTokenAmount", + "required": true, + "description": "Amount of base token to add" + }, + { + "schema": { + "type": "number" + }, + "example": 2, + "in": "query", + "name": "quoteTokenAmount", + "required": true, + "description": "Amount of quote token to add" + }, + { + "schema": { + "minimum": 0, + "maximum": 100, + "default": 2, + "type": "number" + }, + "example": 2, + "in": "query", + "name": "slippagePct", + "required": false, + "description": "Maximum acceptable slippage percentage" + } + ], + "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": ["signature", "status"] + "required": [ + "baseLimited", + "baseTokenAmount", + "quoteTokenAmount", + "baseTokenAmountMax", + "quoteTokenAmountMax" + ] } } } @@ -6168,52 +8961,52 @@ } } }, - "/connectors/meteora/clmm/open-position": { + "/connectors/meteora/amm/execute-swap": { "post": { "tags": ["/connector/meteora"], - "description": "Open a new Meteora position", + "description": "Execute a swap on a Meteora DAMM v2 pool", "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { + "walletAddress": { + "description": "Solana wallet address that will execute the swap", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, "network": { "description": "Solana network to use", "default": "mainnet-beta", "enum": ["devnet", "mainnet-beta"], "type": "string" }, - "walletAddress": { - "description": "Solana wallet address that will open the position", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", + "poolAddress": { + "description": "Meteora DAMM v2 pool address", "type": "string", - "example": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn" - }, - "lowerPrice": { - "description": "Lower price bound for the position", - "type": "number", - "example": 150 + "example": "FH6mP2MUobhDnLERp9z5yv5t2zMUA9WDNXPixpbvYKMv" }, - "upperPrice": { - "description": "Upper price bound for the position", - "type": "number", - "example": 250 + "baseToken": { + "description": "Base token symbol or address", + "type": "string", + "example": "SOL" }, - "poolAddress": { - "description": "Meteora DLMM pool address", + "quoteToken": { + "description": "The other token in the pair (optional - resolved from the pool if omitted)", "type": "string", - "example": "2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3" + "example": "USDC" }, - "baseTokenAmount": { - "description": "Amount of base token to deposit", + "amount": { + "description": "Amount to swap (denominated in the base token)", "type": "number", "example": 0.01 }, - "quoteTokenAmount": { - "description": "Amount of quote token to deposit", - "type": "number", - "example": 2 + "side": { + "description": "Trade direction", + "enum": ["BUY", "SELL"], + "default": "SELL", + "type": "string" }, "slippagePct": { "minimum": 0, @@ -6222,15 +9015,9 @@ "default": 2, "type": "number", "example": 2 - }, - "strategyType": { - "description": "Strategy type for the position", - "enum": [0, 1, 2], - "type": "number", - "example": 0 } }, - "required": ["lowerPrice", "upperPrice", "poolAddress"] + "required": ["poolAddress", "baseToken", "amount", "side"] } } }, @@ -6254,28 +9041,40 @@ "data": { "type": "object", "properties": { - "fee": { - "type": "number" + "tokenIn": { + "type": "string" }, - "positionAddress": { + "tokenOut": { "type": "string" }, - "positionRent": { + "amountIn": { "type": "number" }, - "baseTokenAmountAdded": { + "amountOut": { "type": "number" }, - "quoteTokenAmountAdded": { + "fee": { + "type": "number" + }, + "baseTokenBalanceChange": { + "type": "number" + }, + "quoteTokenBalanceChange": { + "type": "number" + }, + "slippagePct": { + "description": "Slippage tolerance percentage actually applied to the swap", "type": "number" } }, "required": [ + "tokenIn", + "tokenOut", + "amountIn", + "amountOut", "fee", - "positionAddress", - "positionRent", - "baseTokenAmountAdded", - "quoteTokenAmountAdded" + "baseTokenBalanceChange", + "quoteTokenBalanceChange" ] } }, @@ -6287,10 +9086,10 @@ } } }, - "/connectors/meteora/clmm/add-liquidity": { + "/connectors/meteora/amm/add-liquidity": { "post": { "tags": ["/connector/meteora"], - "description": "Add liquidity to a Meteora position", + "description": "Add liquidity to a Meteora DAMM v2 pool. Provide positionAddress to add to a specific position (NFT); omit it to open a new position.", "requestBody": { "content": { "application/json": { @@ -6304,26 +9103,29 @@ "type": "string" }, "walletAddress": { - "description": "Solana wallet address that will add liquidity", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string", - "example": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn" + "description": "Solana wallet address", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" }, - "positionAddress": { - "description": "Position NFT address", + "poolAddress": { + "description": "Meteora DAMM v2 pool address", "type": "string", - "example": "\u003Csample-position-address\u003E" + "example": "FH6mP2MUobhDnLERp9z5yv5t2zMUA9WDNXPixpbvYKMv" }, "baseTokenAmount": { - "description": "Amount of base token to deposit", + "description": "Amount of base token to add", "type": "number", "example": 0.01 }, "quoteTokenAmount": { - "description": "Amount of quote token to deposit", + "description": "Amount of quote token to add", "type": "number", "example": 2 }, + "positionAddress": { + "description": "DAMM v2 positions are NFTs; a wallet may hold several per pool. Provide a position address (from position-info) to add to that specific position; omit to open a NEW position NFT.", + "type": "string" + }, "slippagePct": { "minimum": 0, "maximum": 100, @@ -6331,15 +9133,9 @@ "default": 2, "type": "number", "example": 2 - }, - "strategyType": { - "description": "Strategy type for the position", - "enum": [0, 1, 2], - "type": "number", - "example": 0 } }, - "required": ["positionAddress"] + "required": ["poolAddress", "baseTokenAmount", "quoteTokenAmount"] } } }, @@ -6384,10 +9180,10 @@ } } }, - "/connectors/meteora/clmm/remove-liquidity": { + "/connectors/meteora/amm/remove-liquidity": { "post": { "tags": ["/connector/meteora"], - "description": "Remove liquidity from a Meteora position", + "description": "Remove liquidity from a specific position (NFT) in a Meteora DAMM v2 pool", "requestBody": { "content": { "application/json": { @@ -6401,26 +9197,28 @@ "type": "string" }, "walletAddress": { - "description": "Solana wallet address that will remove liquidity", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", + "description": "Solana wallet address", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "poolAddress": { + "description": "Meteora DAMM v2 pool address", "type": "string", - "example": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn" + "example": "FH6mP2MUobhDnLERp9z5yv5t2zMUA9WDNXPixpbvYKMv" }, "positionAddress": { - "description": "Position NFT address", - "type": "string", - "example": "\u003Csample-position-address\u003E" + "description": "Address of the specific DAMM v2 position (NFT) to remove from. Required — a wallet may hold several positions per pool; list them with position-info. This avoids silently draining only the largest position when several exist.", + "type": "string" }, - "liquidityPct": { + "percentageToRemove": { "minimum": 0, "maximum": 100, - "description": "Percentage of liquidity to remove", - "default": 100, + "description": "Percentage of this position’s liquidity to remove", "type": "number", "example": 100 } }, - "required": ["positionAddress"] + "required": ["poolAddress", "positionAddress", "percentageToRemove"] } } }, @@ -6465,10 +9263,10 @@ } } }, - "/connectors/meteora/clmm/collect-fees": { + "/connectors/meteora/amm/create-pool": { "post": { "tags": ["/connector/meteora"], - "description": "Collect fees from a Meteora position", + "description": "Create a new Meteora DAMM v2 pool and seed it with initial liquidity", "requestBody": { "content": { "application/json": { @@ -6482,18 +9280,40 @@ "type": "string" }, "walletAddress": { - "description": "Solana wallet address that will collect fees", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", + "description": "Solana wallet address that will create and seed the pool", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "baseToken": { + "description": "Base token symbol or address (becomes pool token A)", "type": "string", - "example": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn" + "example": "SOL" }, - "positionAddress": { - "description": "Position NFT address", + "quoteToken": { + "description": "Quote token symbol or address (becomes pool token B)", "type": "string", - "example": "\u003Csample-position-address\u003E" + "example": "USDC" + }, + "baseTokenAmount": { + "description": "Amount of base token to seed the pool with", + "type": "number", + "example": 0.01 + }, + "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 current market price is fetched from the swap router.", + "type": "number", + "example": 2 + }, + "initialPrice": { + "description": "Initial price as quote per base (e.g. SOL per UMBRA). Overrides quoteTokenAmount. If both are omitted, the pool is seeded at the current market price so it is not immediately arbitraged.", + "type": "number" + }, + "configAddress": { + "description": "DAMM v2 config account that defines the fee tier and pool parameters. Required — many permissionless configs are launch configs with very high starting fees, so Gateway does not auto-select one.", + "type": "string" } }, - "required": ["positionAddress"] + "required": ["baseToken", "quoteToken", "baseTokenAmount"] } } }, @@ -6514,112 +9334,31 @@ "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"] - } - } - } - } - } - } - }, - "/connectors/meteora/clmm/close-position": { - "post": { - "tags": ["/connector/meteora"], - "description": "Close a Meteora position", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "network": { - "description": "Solana network to use", - "default": "mainnet-beta", - "enum": ["devnet", "mainnet-beta"], - "type": "string" - }, - "walletAddress": { - "description": "Solana wallet address that will close the position", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", - "type": "string", - "example": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn" - }, - "positionAddress": { - "description": "Position NFT address", - "type": "string", - "example": "\u003Csample-position-address\u003E" - } - }, - "required": ["positionAddress"] - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "signature": { + "poolAddress": { + "description": "Address of the newly created pool", "type": "string" }, - "status": { - "description": "TransactionStatus enum value", + "price": { + "description": "Initial price the pool was seeded at (quote per base)", "type": "number" }, - "data": { - "type": "object", - "properties": { - "fee": { - "type": "number" - }, - "positionRentRefunded": { - "type": "number" - }, - "baseTokenAmountRemoved": { - "type": "number" - }, - "quoteTokenAmountRemoved": { + "data": { + "type": "object", + "properties": { + "fee": { "type": "number" }, - "baseFeeAmountCollected": { + "baseTokenAmountAdded": { "type": "number" }, - "quoteFeeAmountCollected": { + "quoteTokenAmountAdded": { "type": "number" } }, - "required": [ - "fee", - "positionRentRefunded", - "baseTokenAmountRemoved", - "quoteTokenAmountRemoved", - "baseFeeAmountCollected", - "quoteFeeAmountCollected" - ] + "required": ["fee", "baseTokenAmountAdded", "quoteTokenAmountAdded"] } }, - "required": ["signature", "status"] + "required": ["signature", "status", "poolAddress"] } } } @@ -6630,7 +9369,7 @@ "/connectors/orca/clmm/fetch-pools": { "get": { "tags": ["/connector/orca"], - "description": "Fetch info about Orca pools", + "description": "Fetch Orca pools from API with search and sorting", "parameters": [ { "schema": { @@ -6646,10 +9385,11 @@ { "schema": { "minimum": 1, - "default": 10, + "maximum": 100, + "default": 50, "type": "number" }, - "example": 10, + "example": 50, "in": "query", "name": "limit", "required": false, @@ -6659,21 +9399,53 @@ "schema": { "type": "string" }, - "example": "SOL", + "examples": { + "SOL": { + "value": "SOL" + }, + "USDC": { + "value": "USDC" + }, + "SOL-USDC": { + "value": "SOL-USDC" + } + }, "in": "query", - "name": "tokenA", + "name": "query", "required": false, - "description": "First token symbol or address" + "description": "Search query to match pools by name, tokens, or address" }, { "schema": { + "enum": ["volume", "tvl", "fees", "rewards", "yieldovertvl"], + "default": "volume", "type": "string" }, - "example": "USDC", "in": "query", - "name": "tokenB", + "name": "sortBy", + "required": false, + "description": "Sort by field" + }, + { + "schema": { + "enum": ["asc", "desc"], + "default": "desc", + "type": "string" + }, + "in": "query", + "name": "sortDirection", + "required": false, + "description": "Sort direction" + }, + { + "schema": { + "default": false, + "type": "boolean" + }, + "in": "query", + "name": "verifiedOnly", "required": false, - "description": "Second token symbol or address" + "description": "Only return pools with verified tokens" } ], "responses": { @@ -6682,49 +9454,98 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "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" + "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": { + "description": "Base fee percentage", + "type": "number" + }, + "price": { + "description": "Current price", + "type": "number" + }, + "tvl": { + "description": "Total value locked in USD", + "type": "number" + }, + "apr": { + "description": "Annual percentage rate", + "type": "number" + }, + "apy": { + "description": "Annual percentage yield", + "type": "number" + }, + "volume24h": { + "description": "24-hour trading volume", + "type": "number" + }, + "fees24h": { + "description": "24-hour fees collected", + "type": "number" + } + }, + "required": [ + "address", + "name", + "baseTokenAddress", + "baseTokenSymbol", + "quoteTokenAddress", + "quoteTokenSymbol", + "binStep", + "baseFee", + "price", + "tvl" + ] } }, - "required": [ - "address", - "baseTokenAddress", - "quoteTokenAddress", - "feePct", - "price", - "baseTokenAmount", - "quoteTokenAmount", - "activeBinId" - ] - } + "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"] } } } @@ -6757,6 +9578,18 @@ "name": "poolAddress", "required": true, "description": "Orca CLMM pool address" + }, + { + "schema": { + "default": 0, + "minimum": 0, + "maximum": 401, + "type": "integer" + }, + "in": "query", + "name": "binCount", + "required": false, + "description": "If > 0, include a `bins` array (per-tickSpacing token amounts around the current tick). Default 0 — pool-info skips the extra getProgramAccounts call." } ], "responses": { @@ -6794,6 +9627,27 @@ "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"] + } + }, "liquidity": { "type": "string" }, @@ -6835,7 +9689,7 @@ "/connectors/orca/clmm/positions-owned": { "get": { "tags": ["/connector/orca"], - "description": "Retrieve a list of positions owned by a user's wallet in a specific Orca pool", + "description": "Retrieve all positions owned by a user's wallet across Orca CLMM pools", "parameters": [ { "schema": { @@ -6850,24 +9704,14 @@ }, { "schema": { - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string" }, - "example": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", + "example": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "in": "query", "name": "walletAddress", "required": false, "description": "Solana wallet address to check for positions" - }, - { - "schema": { - "type": "string" - }, - "example": "Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE", - "in": "query", - "name": "poolAddress", - "required": true, - "description": "Orca CLMM pool address" } ], "responses": { @@ -6918,12 +9762,6 @@ }, "price": { "type": "number" - }, - "rewardTokenAddress": { - "type": "string" - }, - "rewardAmount": { - "type": "number" } }, "required": [ @@ -6969,7 +9807,7 @@ "schema": { "type": "string" }, - "example": "\u003Csample-position-address\u003E", + "example": "", "in": "query", "name": "positionAddress", "required": true, @@ -6977,10 +9815,10 @@ }, { "schema": { - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string" }, - "example": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", + "example": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "in": "query", "name": "walletAddress", "required": false, @@ -7033,12 +9871,6 @@ }, "price": { "type": "number" - }, - "rewardTokenAddress": { - "type": "string" - }, - "rewardAmount": { - "type": "number" } }, "required": [ @@ -7339,9 +10171,9 @@ }, "walletAddress": { "description": "Solana wallet address that will execute the swap", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string", - "example": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn" + "example": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5" }, "poolAddress": { "description": "Orca CLMM pool address (optional - can be looked up from baseToken and quoteToken)", @@ -7423,6 +10255,10 @@ }, "quoteTokenBalanceChange": { "type": "number" + }, + "slippagePct": { + "description": "Slippage tolerance percentage actually applied to the swap", + "type": "number" } }, "required": [ @@ -7436,7 +10272,98 @@ ] } }, - "required": ["signature", "status"] + "required": ["signature", "status"] + } + } + } + } + } + } + }, + "/connectors/orca/clmm/create-pool": { + "post": { + "tags": ["/connector/orca"], + "description": "Create and initialize a new Orca (Whirlpools) CLMM pool at an initial price. Does not open or seed a position.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "network": { + "description": "Solana network to use", + "default": "mainnet-beta", + "enum": ["devnet", "mainnet-beta"], + "type": "string" + }, + "walletAddress": { + "description": "Solana wallet address that will create and initialize the pool", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string", + "example": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5" + }, + "baseToken": { + "description": "Base token symbol or address (becomes the pool base)", + "type": "string", + "example": "SOL" + }, + "quoteToken": { + "description": "Quote token symbol or address (becomes the pool quote)", + "type": "string", + "example": "USDC" + }, + "tickSpacing": { + "description": "Tick spacing (fee tier) for the new Whirlpool. A FeeTier account for this config+tickSpacing must already exist on-chain. Common Orca values: 1, 2, 8, 16, 64, 128, 256.", + "minimum": 1, + "type": "integer", + "example": 64 + }, + "initialPrice": { + "description": "Initial price as quote per base. If omitted, the current market price is fetched from the unified swap router so the pool opens on-market. No position is opened; only the pool is created.", + "type": "number", + "example": 200 + } + }, + "required": ["baseToken", "quoteToken", "tickSpacing"] + } + } + }, + "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": { + "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"] } } } @@ -7462,9 +10389,9 @@ }, "walletAddress": { "description": "Solana wallet address that will open the position", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string", - "example": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn" + "example": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5" }, "lowerPrice": { "description": "Lower price bound for the position", @@ -7575,14 +10502,14 @@ }, "walletAddress": { "description": "Solana wallet address that will add liquidity", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string", - "example": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn" + "example": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5" }, "positionAddress": { "description": "Position NFT address", "type": "string", - "example": "\u003Csample-position-address\u003E" + "example": "" }, "baseTokenAmount": { "description": "Amount of base token to deposit", @@ -7665,16 +10592,16 @@ }, "walletAddress": { "description": "Solana wallet address that will remove liquidity", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string", - "example": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn" + "example": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5" }, "positionAddress": { "description": "Position NFT address", "type": "string", - "example": "\u003Csample-position-address\u003E" + "example": "" }, - "liquidityPct": { + "percentageToRemove": { "minimum": 0, "maximum": 100, "description": "Percentage of liquidity to remove", @@ -7739,7 +10666,7 @@ "/connectors/orca/clmm/collect-fees": { "post": { "tags": ["/connector/orca"], - "description": "Collect fees from an Orca position", + "description": "Collect fees and rewards from an Orca position", "requestBody": { "content": { "application/json": { @@ -7754,14 +10681,14 @@ }, "walletAddress": { "description": "Solana wallet address that will collect fees", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string", - "example": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn" + "example": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5" }, "positionAddress": { "description": "Position NFT address", "type": "string", - "example": "\u003Csample-position-address\u003E" + "example": "" } }, "required": ["positionAddress"] @@ -7827,14 +10754,14 @@ }, "walletAddress": { "description": "Solana wallet address that will close the position", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string", - "example": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn" + "example": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5" }, "positionAddress": { "description": "Position NFT address", "type": "string", - "example": "\u003Csample-position-address\u003E" + "example": "" } }, "required": ["positionAddress"] @@ -7999,7 +10926,7 @@ }, { "schema": { - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string" }, "in": "query", @@ -8039,6 +10966,29 @@ }, "price": { "type": "number" + }, + "positions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "positionAddress": { + "description": "Address of the individual position (NFT position account)", + "type": "string" + }, + "lpTokenAmount": { + "description": "Liquidity held by this position (LP units)", + "type": "number" + }, + "baseTokenAmount": { + "type": "number" + }, + "quoteTokenAmount": { + "type": "number" + } + }, + "required": ["positionAddress", "lpTokenAmount", "baseTokenAmount", "quoteTokenAmount"] + } } }, "required": [ @@ -8321,7 +11271,7 @@ "properties": { "walletAddress": { "type": "string", - "example": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn" + "example": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5" }, "network": { "type": "string", @@ -8396,6 +11346,10 @@ }, "quoteTokenBalanceChange": { "type": "number" + }, + "slippagePct": { + "description": "Slippage tolerance percentage actually applied to the swap", + "type": "number" } }, "required": [ @@ -8435,7 +11389,7 @@ }, "walletAddress": { "description": "Solana wallet address", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string" }, "poolAddress": { @@ -8525,7 +11479,7 @@ }, "walletAddress": { "description": "Solana wallet address", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string" }, "poolAddress": { @@ -8586,6 +11540,117 @@ } } }, + "/connectors/raydium/amm/create-pool": { + "post": { + "tags": ["/connector/raydium"], + "description": "Create a new Raydium CPMM (CP-Swap) pool and seed it with initial liquidity", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "network": { + "description": "Solana network to use", + "default": "mainnet-beta", + "enum": ["devnet", "mainnet-beta"], + "type": "string" + }, + "walletAddress": { + "description": "Solana wallet address that will create and seed the pool", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "baseToken": { + "description": "Base token symbol or address (becomes the pool base)", + "type": "string", + "example": "SOL" + }, + "quoteToken": { + "description": "Quote token symbol or address (becomes the pool quote)", + "type": "string", + "example": "USDC" + }, + "baseTokenAmount": { + "description": "Amount of base token to seed the pool with", + "type": "number", + "example": 0.01 + }, + "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 current market price is fetched from the swap router.", + "type": "number", + "example": 2 + }, + "initialPrice": { + "description": "Initial price as quote per base. Overrides quoteTokenAmount. If both are omitted, the pool is seeded at the current market price so it is not immediately arbitraged.", + "type": "number" + }, + "feeConfigIndex": { + "description": "Index into the CPMM fee-config list returned by the Raydium API (getCpmmConfigs). Default 0 selects the first/lowest fee tier.", + "default": 0, + "minimum": 0, + "type": "integer" + }, + "openTime": { + "description": "Unix timestamp (seconds) when trading opens. Default 0 opens the pool immediately on confirmation.", + "default": 0, + "minimum": 0, + "type": "integer" + } + }, + "required": ["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": { + "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"] + } + } + } + } + } + } + }, "/connectors/raydium/clmm/pool-info": { "get": { "tags": ["/connector/raydium"], @@ -8611,6 +11676,18 @@ "name": "poolAddress", "required": true, "description": "Raydium CLMM pool address" + }, + { + "schema": { + "default": 0, + "minimum": 0, + "maximum": 401, + "type": "integer" + }, + "in": "query", + "name": "binCount", + "required": false, + "description": "If > 0, include a `bins` array (per-tickSpacing token amounts around the current tick), mirroring Meteora pool-info.bins[]. Default 0 — pool-info skips the extra tick-array fetch." } ], "responses": { @@ -8647,6 +11724,27 @@ }, "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"] + } } }, "required": [ @@ -8686,7 +11784,7 @@ "schema": { "type": "string" }, - "example": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", + "example": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "in": "query", "name": "walletAddress", "required": true, @@ -8741,12 +11839,6 @@ }, "price": { "type": "number" - }, - "rewardTokenAddress": { - "type": "string" - }, - "rewardAmount": { - "type": "number" } }, "required": [ @@ -8792,7 +11884,7 @@ "schema": { "type": "string" }, - "example": "\u003Csample-position-address\u003E", + "example": "", "in": "query", "name": "positionAddress", "required": true, @@ -8800,7 +11892,7 @@ }, { "schema": { - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string" }, "in": "query", @@ -8855,12 +11947,6 @@ }, "price": { "type": "number" - }, - "rewardTokenAddress": { - "type": "string" - }, - "rewardAmount": { - "type": "number" } }, "required": [ @@ -9154,9 +12240,9 @@ "properties": { "walletAddress": { "description": "Solana wallet address", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string", - "example": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn" + "example": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5" }, "network": { "description": "Solana network to use", @@ -9244,6 +12330,10 @@ }, "quoteTokenBalanceChange": { "type": "number" + }, + "slippagePct": { + "description": "Slippage tolerance percentage actually applied to the swap", + "type": "number" } }, "required": [ @@ -9252,8 +12342,120 @@ "amountIn", "amountOut", "fee", - "baseTokenBalanceChange", - "quoteTokenBalanceChange" + "baseTokenBalanceChange", + "quoteTokenBalanceChange" + ] + } + }, + "required": ["signature", "status"] + } + } + } + } + } + } + }, + "/connectors/raydium/clmm/open-position": { + "post": { + "tags": ["/connector/raydium"], + "description": "Open a new Raydium CLMM position", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "network": { + "description": "Solana network to use", + "default": "mainnet-beta", + "enum": ["devnet", "mainnet-beta"], + "type": "string" + }, + "walletAddress": { + "description": "Solana wallet address", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "lowerPrice": { + "description": "Lower price bound for the position", + "type": "number", + "example": 100 + }, + "upperPrice": { + "description": "Upper price bound for the position", + "type": "number", + "example": 300 + }, + "poolAddress": { + "description": "Raydium CLMM pool address", + "type": "string", + "example": "3ucNos4NbumPLZNWztqGHNFFgkHeRMBQAVemeeomsUxv" + }, + "baseTokenAmount": { + "description": "Amount of base token to deposit", + "type": "number", + "example": 0.01 + }, + "quoteTokenAmount": { + "description": "Amount of quote token to deposit", + "type": "number", + "example": 2 + }, + "slippagePct": { + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage", + "default": 2, + "type": "number", + "example": 2 + } + }, + "required": ["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" ] } }, @@ -9265,10 +12467,10 @@ } } }, - "/connectors/raydium/clmm/open-position": { + "/connectors/raydium/clmm/create-pool": { "post": { "tags": ["/connector/raydium"], - "description": "Open a new Raydium CLMM position", + "description": "Create and initialize a new Raydium CLMM pool at an initial price. Does not open or seed a position.", "requestBody": { "content": { "application/json": { @@ -9282,45 +12484,33 @@ "type": "string" }, "walletAddress": { - "description": "Solana wallet address", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", + "description": "Solana wallet address that will create and initialize the pool", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string" }, - "lowerPrice": { - "description": "Lower price bound for the position", - "type": "number", - "example": 100 - }, - "upperPrice": { - "description": "Upper price bound for the position", - "type": "number", - "example": 300 - }, - "poolAddress": { - "description": "Raydium CLMM pool address", + "baseToken": { + "description": "Base token symbol or address (becomes the pool base)", "type": "string", - "example": "3ucNos4NbumPLZNWztqGHNFFgkHeRMBQAVemeeomsUxv" + "example": "SOL" }, - "baseTokenAmount": { - "description": "Amount of base token to deposit", - "type": "number", - "example": 0.01 + "quoteToken": { + "description": "Quote token symbol or address (becomes the pool quote)", + "type": "string", + "example": "USDC" }, - "quoteTokenAmount": { - "description": "Amount of quote token to deposit", + "initialPrice": { + "description": "Initial price as quote per base. If omitted, the current market price is fetched from the unified swap router so the pool opens on-market. No position is opened; only the pool is created.", "type": "number", - "example": 2 + "example": 200 }, - "slippagePct": { + "ammConfigIndex": { + "description": "Index into the CLMM amm-config list returned by the Raydium API (getClmmConfigs). Each config carries a fee tier and tickSpacing. Default 0 selects the first/lowest tier.", + "default": 0, "minimum": 0, - "maximum": 100, - "description": "Maximum acceptable slippage percentage", - "default": 2, - "type": "number", - "example": 2 + "type": "integer" } }, - "required": ["lowerPrice", "upperPrice", "poolAddress"] + "required": ["baseToken", "quoteToken"] } } }, @@ -9341,35 +12531,25 @@ "description": "TransactionStatus enum value", "type": "number" }, + "poolAddress": { + "description": "Address of the newly created pool", + "type": "string" + }, + "price": { + "description": "Initial price the pool was initialized at (quote per base)", + "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": ["fee"] } }, - "required": ["signature", "status"] + "required": ["signature", "status", "poolAddress"] } } } @@ -9395,13 +12575,13 @@ }, "walletAddress": { "description": "Solana wallet address", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string" }, "positionAddress": { "description": "Position NFT address", "type": "string", - "example": "\u003Csample-position-address\u003E" + "example": "" }, "baseTokenAmount": { "description": "Amount of base token to add", @@ -9485,7 +12665,7 @@ }, "walletAddress": { "description": "Solana wallet address", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string" }, "positionAddress": { @@ -9562,7 +12742,7 @@ }, "walletAddress": { "type": "string", - "example": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn" + "example": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5" }, "positionAddress": { "type": "string" @@ -9631,7 +12811,7 @@ }, "walletAddress": { "description": "Solana wallet address", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string" }, "positionAddress": { @@ -9709,7 +12889,18 @@ { "schema": { "default": "mainnet", - "enum": ["arbitrum", "avalanche", "base", "bsc", "celo", "mainnet", "optimism", "polygon"], + "enum": [ + "arbitrum", + "avalanche", + "base", + "bsc", + "celo", + "mainnet", + "optimism", + "polygon", + "robinhoodchain", + "unichain" + ], "type": "string" }, "in": "query", @@ -9771,7 +12962,7 @@ }, { "schema": { - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", + "default": "0xDA50C69342216b538Daf06FfECDa7363E0B96684", "type": "string" }, "in": "query", @@ -9859,14 +13050,25 @@ "properties": { "walletAddress": { "description": "Wallet address that will execute the swap", - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", + "default": "0xDA50C69342216b538Daf06FfECDa7363E0B96684", "type": "string", - "example": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50" + "example": "0xDA50C69342216b538Daf06FfECDa7363E0B96684" }, "network": { "description": "The blockchain network to use", "default": "mainnet", - "enum": ["arbitrum", "avalanche", "base", "bsc", "celo", "mainnet", "optimism", "polygon"], + "enum": [ + "arbitrum", + "avalanche", + "base", + "bsc", + "celo", + "mainnet", + "optimism", + "polygon", + "robinhoodchain", + "unichain" + ], "type": "string" }, "quoteId": { @@ -9927,6 +13129,10 @@ "quoteTokenBalanceChange": { "description": "Change in quote token balance (negative for decrease)", "type": "number" + }, + "slippagePct": { + "description": "Slippage tolerance percentage actually applied to the swap", + "type": "number" } }, "required": [ @@ -9960,14 +13166,25 @@ "properties": { "walletAddress": { "description": "Wallet address that will execute the swap", - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", + "default": "0xDA50C69342216b538Daf06FfECDa7363E0B96684", "type": "string", - "example": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50" + "example": "0xDA50C69342216b538Daf06FfECDa7363E0B96684" }, "network": { "description": "The blockchain network to use", "default": "mainnet", - "enum": ["arbitrum", "avalanche", "base", "bsc", "celo", "mainnet", "optimism", "polygon"], + "enum": [ + "arbitrum", + "avalanche", + "base", + "bsc", + "celo", + "mainnet", + "optimism", + "polygon", + "robinhoodchain", + "unichain" + ], "type": "string" }, "baseToken": { @@ -10051,6 +13268,10 @@ "quoteTokenBalanceChange": { "description": "Change in quote token balance (negative for decrease)", "type": "number" + }, + "slippagePct": { + "description": "Slippage tolerance percentage actually applied to the swap", + "type": "number" } }, "required": [ @@ -10080,7 +13301,18 @@ { "schema": { "default": "mainnet", - "enum": ["arbitrum", "avalanche", "base", "bsc", "celo", "mainnet", "optimism", "polygon"], + "enum": [ + "arbitrum", + "avalanche", + "base", + "bsc", + "celo", + "mainnet", + "optimism", + "polygon", + "robinhoodchain", + "unichain" + ], "type": "string" }, "in": "query", @@ -10163,7 +13395,7 @@ "schema": { "type": "string" }, - "example": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", + "example": "0xDA50C69342216b538Daf06FfECDa7363E0B96684", "in": "query", "name": "walletAddress", "required": false @@ -10227,6 +13459,29 @@ }, "price": { "type": "number" + }, + "positions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "positionAddress": { + "description": "Address of the individual position (NFT position account)", + "type": "string" + }, + "lpTokenAmount": { + "description": "Liquidity held by this position (LP units)", + "type": "number" + }, + "baseTokenAmount": { + "type": "number" + }, + "quoteTokenAmount": { + "type": "number" + } + }, + "required": ["positionAddress", "lpTokenAmount", "baseTokenAmount", "quoteTokenAmount"] + } } }, "required": [ @@ -10492,13 +13747,24 @@ "properties": { "walletAddress": { "description": "Wallet address that will execute the swap", - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", + "default": "0xDA50C69342216b538Daf06FfECDa7363E0B96684", "type": "string" }, "network": { "description": "The EVM network to use", "default": "mainnet", - "enum": ["arbitrum", "avalanche", "base", "bsc", "celo", "mainnet", "optimism", "polygon"], + "enum": [ + "arbitrum", + "avalanche", + "base", + "bsc", + "celo", + "mainnet", + "optimism", + "polygon", + "robinhoodchain", + "unichain" + ], "type": "string" }, "poolAddress": { @@ -10586,6 +13852,10 @@ "quoteTokenBalanceChange": { "description": "Change in quote token balance (negative for decrease)", "type": "number" + }, + "slippagePct": { + "description": "Slippage tolerance percentage actually applied to the swap", + "type": "number" } }, "required": [ @@ -10620,12 +13890,23 @@ "network": { "description": "The EVM network to use", "default": "mainnet", - "enum": ["arbitrum", "avalanche", "base", "bsc", "celo", "mainnet", "optimism", "polygon"], + "enum": [ + "arbitrum", + "avalanche", + "base", + "bsc", + "celo", + "mainnet", + "optimism", + "polygon", + "robinhoodchain", + "unichain" + ], "type": "string" }, "walletAddress": { "description": "Wallet address that will add liquidity", - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", + "default": "0xDA50C69342216b538Daf06FfECDa7363E0B96684", "type": "string" }, "poolAddress": { @@ -10646,18 +13927,116 @@ "description": "Maximum acceptable slippage percentage", "default": 2, "type": "number" + } + }, + "required": ["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": { + "type": "number" + }, + "baseTokenAmountAdded": { + "type": "number" + }, + "quoteTokenAmountAdded": { + "type": "number" + } + }, + "required": ["fee", "baseTokenAmountAdded", "quoteTokenAmountAdded"] + } + }, + "required": ["signature", "status"] + } + } + } + } + } + } + }, + "/connectors/uniswap/amm/create-pool": { + "post": { + "tags": ["/connector/uniswap"], + "description": "Create a new Uniswap V2 (AMM) pool and seed it with initial liquidity (fixed 0.30% fee)", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "network": { + "description": "The EVM network to use", + "default": "mainnet", + "enum": [ + "arbitrum", + "avalanche", + "base", + "bsc", + "celo", + "mainnet", + "optimism", + "polygon", + "robinhoodchain", + "unichain" + ], + "type": "string" }, - "gasPrice": { - "description": "Gas price in wei for the transaction", + "walletAddress": { + "description": "Wallet address that will create and seed the pool", + "default": "0xDA50C69342216b538Daf06FfECDa7363E0B96684", "type": "string" }, - "maxGas": { - "description": "Maximum gas limit for the transaction", - "type": "number", - "example": 300000 + "baseToken": { + "description": "Base token symbol or address (becomes the pool base)", + "type": "string", + "example": "WETH" + }, + "quoteToken": { + "description": "Quote token symbol or address (becomes the pool quote)", + "type": "string", + "example": "USDC" + }, + "baseTokenAmount": { + "description": "Amount of base token to seed the pool with", + "type": "number" + }, + "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 current market price is fetched from the unified swap router.", + "type": "number" + }, + "initialPrice": { + "description": "Initial price as quote per base. Overrides quoteTokenAmount. If both are omitted, the pool is seeded at the current market price so it is not immediately arbitraged.", + "type": "number" + }, + "slippagePct": { + "minimum": 0, + "maximum": 100, + "description": "Maximum acceptable slippage percentage", + "default": 2, + "type": "number" } }, - "required": ["poolAddress", "baseTokenAmount", "quoteTokenAmount"] + "required": ["baseToken", "quoteToken", "baseTokenAmount"] } } }, @@ -10678,6 +14057,14 @@ "description": "TransactionStatus enum value", "type": "number" }, + "poolAddress": { + "description": "Address of the newly created pool", + "type": "string" + }, + "price": { + "description": "Initial price the pool was seeded at (quote per base)", + "type": "number" + }, "data": { "type": "object", "properties": { @@ -10694,7 +14081,7 @@ "required": ["fee", "baseTokenAmountAdded", "quoteTokenAmountAdded"] } }, - "required": ["signature", "status"] + "required": ["signature", "status", "poolAddress"] } } } @@ -10715,12 +14102,23 @@ "network": { "description": "The EVM network to use", "default": "mainnet", - "enum": ["arbitrum", "avalanche", "base", "bsc", "celo", "mainnet", "optimism", "polygon"], + "enum": [ + "arbitrum", + "avalanche", + "base", + "bsc", + "celo", + "mainnet", + "optimism", + "polygon", + "robinhoodchain", + "unichain" + ], "type": "string" }, "walletAddress": { "description": "Wallet address that will remove liquidity", - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", + "default": "0xDA50C69342216b538Daf06FfECDa7363E0B96684", "type": "string" }, "poolAddress": { @@ -10732,15 +14130,6 @@ "maximum": 100, "description": "Percentage of liquidity to remove", "type": "number" - }, - "gasPrice": { - "description": "Gas price in wei for the transaction", - "type": "string" - }, - "maxGas": { - "description": "Maximum gas limit for the transaction", - "type": "number", - "example": 300000 } }, "required": ["poolAddress", "percentageToRemove"] @@ -10796,7 +14185,18 @@ { "schema": { "default": "mainnet", - "enum": ["arbitrum", "avalanche", "base", "bsc", "celo", "mainnet", "optimism", "polygon"], + "enum": [ + "arbitrum", + "avalanche", + "base", + "bsc", + "celo", + "mainnet", + "optimism", + "polygon", + "robinhoodchain", + "unichain" + ], "type": "string" }, "in": "query", @@ -10813,6 +14213,18 @@ "name": "poolAddress", "required": true, "description": "Uniswap V3 pool address" + }, + { + "schema": { + "default": 0, + "minimum": 0, + "maximum": 401, + "type": "integer" + }, + "in": "query", + "name": "binCount", + "required": false, + "description": "If > 0, include a `bins` array (per-tickSpacing token amounts around the current tick), mirroring Meteora pool-info.bins[]. Default 0 — pool-info skips the extra eth_calls." } ], "responses": { @@ -10849,6 +14261,27 @@ }, "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"] + } } }, "required": [ @@ -10939,12 +14372,6 @@ }, "price": { "type": "number" - }, - "rewardTokenAddress": { - "type": "string" - }, - "rewardAmount": { - "type": "number" } }, "required": [ @@ -10988,7 +14415,7 @@ "schema": { "type": "string" }, - "example": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", + "example": "0xDA50C69342216b538Daf06FfECDa7363E0B96684", "in": "query", "name": "walletAddress", "required": true @@ -11042,12 +14469,6 @@ }, "price": { "type": "number" - }, - "rewardTokenAddress": { - "type": "string" - }, - "rewardAmount": { - "type": "number" } }, "required": [ @@ -11323,14 +14744,25 @@ "properties": { "walletAddress": { "description": "Wallet address that will execute the swap", - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", + "default": "0xDA50C69342216b538Daf06FfECDa7363E0B96684", "type": "string", - "example": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50" + "example": "0xDA50C69342216b538Daf06FfECDa7363E0B96684" }, "network": { "description": "The blockchain network to use", "default": "mainnet", - "enum": ["arbitrum", "avalanche", "base", "bsc", "celo", "mainnet", "optimism", "polygon"], + "enum": [ + "arbitrum", + "avalanche", + "base", + "bsc", + "celo", + "mainnet", + "optimism", + "polygon", + "robinhoodchain", + "unichain" + ], "type": "string" }, "baseToken": { @@ -11414,6 +14846,10 @@ "quoteTokenBalanceChange": { "description": "Change in quote token balance (negative for decrease)", "type": "number" + }, + "slippagePct": { + "description": "Slippage tolerance percentage actually applied to the swap", + "type": "number" } }, "required": [ @@ -11451,7 +14887,7 @@ }, "walletAddress": { "type": "string", - "example": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50" + "example": "0xDA50C69342216b538Daf06FfECDa7363E0B96684" }, "lowerPrice": { "type": "number", @@ -11535,6 +14971,106 @@ } } }, + "/connectors/uniswap/clmm/create-pool": { + "post": { + "tags": ["/connector/uniswap"], + "description": "Create and initialize a new Uniswap V3 (CLMM) pool at an initial price (no liquidity seeded)", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "network": { + "description": "The EVM network to use", + "default": "mainnet", + "enum": [ + "arbitrum", + "avalanche", + "base", + "bsc", + "celo", + "mainnet", + "optimism", + "polygon", + "robinhoodchain", + "unichain" + ], + "type": "string" + }, + "walletAddress": { + "description": "Wallet address that will create and initialize the pool", + "default": "0xDA50C69342216b538Daf06FfECDa7363E0B96684", + "type": "string" + }, + "baseToken": { + "description": "Base token symbol or address (becomes the pool base)", + "type": "string", + "example": "WETH" + }, + "quoteToken": { + "description": "Quote token symbol or address (becomes the pool quote)", + "type": "string", + "example": "USDC" + }, + "fee": { + "description": "Fee tier in hundredths of a bip: 100 (0.01%), 500 (0.05%), 3000 (0.30%), or 10000 (1.00%)", + "enum": [100, 500, 3000, 10000], + "type": "number", + "example": 3000 + }, + "initialPrice": { + "description": "Initial price as quote per base. If omitted, the current market price is fetched from the unified swap router so the pool opens on-market and is not immediately arbitraged.", + "type": "number" + } + }, + "required": ["baseToken", "quoteToken", "fee"] + } + } + }, + "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": { + "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"] + } + } + } + } + } + } + }, "/connectors/uniswap/clmm/add-liquidity": { "post": { "tags": ["/connector/uniswap"], @@ -11548,12 +15084,23 @@ "network": { "description": "The EVM network to use", "default": "mainnet", - "enum": ["arbitrum", "avalanche", "base", "bsc", "celo", "mainnet", "optimism", "polygon"], + "enum": [ + "arbitrum", + "avalanche", + "base", + "bsc", + "celo", + "mainnet", + "optimism", + "polygon", + "robinhoodchain", + "unichain" + ], "type": "string" }, "walletAddress": { "description": "Wallet address that will add liquidity", - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", + "default": "0xDA50C69342216b538Daf06FfECDa7363E0B96684", "type": "string" }, "positionAddress": { @@ -11574,15 +15121,6 @@ "description": "Maximum acceptable slippage percentage", "default": 2, "type": "number" - }, - "gasPrice": { - "description": "Gas price in wei for the transaction", - "type": "string" - }, - "maxGas": { - "description": "Maximum gas limit for the transaction", - "type": "number", - "example": 300000 } }, "required": ["positionAddress", "baseTokenAmount", "quoteTokenAmount"] @@ -11646,7 +15184,7 @@ }, "walletAddress": { "type": "string", - "example": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50" + "example": "0xDA50C69342216b538Daf06FfECDa7363E0B96684" }, "positionAddress": { "type": "string", @@ -11721,7 +15259,7 @@ }, "walletAddress": { "type": "string", - "example": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50" + "example": "0xDA50C69342216b538Daf06FfECDa7363E0B96684" }, "positionAddress": { "type": "string", @@ -12048,7 +15586,7 @@ "properties": { "walletAddress": { "description": "Wallet address that will execute the swap", - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", + "default": "0xDA50C69342216b538Daf06FfECDa7363E0B96684", "type": "string" }, "network": { @@ -12062,15 +15600,6 @@ "description": "ID of the quote to execute", "type": "string", "example": "123e4567-e89b-12d3-a456-426614174000" - }, - "gasPrice": { - "description": "Gas price in wei for the transaction", - "type": "string" - }, - "maxGas": { - "description": "Maximum gas limit for the transaction", - "type": "number", - "example": 1000000 } }, "required": ["quoteId"] @@ -12125,6 +15654,10 @@ "quoteTokenBalanceChange": { "description": "Change in quote token balance (negative for decrease)", "type": "number" + }, + "slippagePct": { + "description": "Slippage tolerance percentage actually applied to the swap", + "type": "number" } }, "required": [ @@ -12158,9 +15691,9 @@ "properties": { "walletAddress": { "description": "Wallet address that will execute the swap", - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", + "default": "0xDA50C69342216b538Daf06FfECDa7363E0B96684", "type": "string", - "example": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50" + "example": "0xDA50C69342216b538Daf06FfECDa7363E0B96684" }, "network": { "description": "The blockchain network to use", @@ -12195,15 +15728,6 @@ "description": "Maximum acceptable slippage percentage", "type": "number", "example": 1 - }, - "gasPrice": { - "description": "Gas price in wei for the transaction", - "type": "string" - }, - "maxGas": { - "description": "Maximum gas limit for the transaction", - "type": "number", - "example": 300000 } }, "required": ["baseToken", "quoteToken", "amount", "side"] @@ -12258,6 +15782,10 @@ "quoteTokenBalanceChange": { "description": "Change in quote token balance (negative for decrease)", "type": "number" + }, + "slippagePct": { + "description": "Slippage tolerance percentage actually applied to the swap", + "type": "number" } }, "required": [ @@ -12349,7 +15877,7 @@ }, { "schema": { - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", + "default": "0xDA50C69342216b538Daf06FfECDa7363E0B96684", "type": "string" }, "in": "query", @@ -12437,9 +15965,9 @@ "properties": { "walletAddress": { "description": "Wallet address that will execute the swap", - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", + "default": "0xDA50C69342216b538Daf06FfECDa7363E0B96684", "type": "string", - "example": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50" + "example": "0xDA50C69342216b538Daf06FfECDa7363E0B96684" }, "network": { "description": "The blockchain network to use", @@ -12505,6 +16033,10 @@ "quoteTokenBalanceChange": { "description": "Change in quote token balance (negative for decrease)", "type": "number" + }, + "slippagePct": { + "description": "Slippage tolerance percentage actually applied to the swap", + "type": "number" } }, "required": [ @@ -12538,9 +16070,9 @@ "properties": { "walletAddress": { "description": "Wallet address that will execute the swap", - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", + "default": "0xDA50C69342216b538Daf06FfECDa7363E0B96684", "type": "string", - "example": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50" + "example": "0xDA50C69342216b538Daf06FfECDa7363E0B96684" }, "network": { "description": "The blockchain network to use", @@ -12629,6 +16161,10 @@ "quoteTokenBalanceChange": { "description": "Change in quote token balance (negative for decrease)", "type": "number" + }, + "slippagePct": { + "description": "Slippage tolerance percentage actually applied to the swap", + "type": "number" } }, "required": [ @@ -12741,7 +16277,7 @@ "schema": { "type": "string" }, - "example": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", + "example": "0xDA50C69342216b538Daf06FfECDa7363E0B96684", "in": "query", "name": "walletAddress", "required": false @@ -12805,6 +16341,29 @@ }, "price": { "type": "number" + }, + "positions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "positionAddress": { + "description": "Address of the individual position (NFT position account)", + "type": "string" + }, + "lpTokenAmount": { + "description": "Liquidity held by this position (LP units)", + "type": "number" + }, + "baseTokenAmount": { + "type": "number" + }, + "quoteTokenAmount": { + "type": "number" + } + }, + "required": ["positionAddress", "lpTokenAmount", "baseTokenAmount", "quoteTokenAmount"] + } } }, "required": [ @@ -13070,7 +16629,7 @@ "properties": { "walletAddress": { "description": "Wallet address that will execute the swap", - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", + "default": "0xDA50C69342216b538Daf06FfECDa7363E0B96684", "type": "string" }, "network": { @@ -13164,6 +16723,10 @@ "quoteTokenBalanceChange": { "description": "Change in quote token balance (negative for decrease)", "type": "number" + }, + "slippagePct": { + "description": "Slippage tolerance percentage actually applied to the swap", + "type": "number" } }, "required": [ @@ -13203,7 +16766,7 @@ }, "walletAddress": { "description": "Wallet address that will add liquidity", - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", + "default": "0xDA50C69342216b538Daf06FfECDa7363E0B96684", "type": "string" }, "poolAddress": { @@ -13224,15 +16787,6 @@ "description": "Maximum acceptable slippage percentage", "default": 2, "type": "number" - }, - "gasPrice": { - "description": "Gas price in wei for the transaction", - "type": "string" - }, - "maxGas": { - "description": "Maximum gas limit for the transaction", - "type": "number", - "example": 300000 } }, "required": ["poolAddress", "baseTokenAmount", "quoteTokenAmount"] @@ -13298,30 +16852,117 @@ }, "walletAddress": { "description": "Wallet address that will remove liquidity", - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", + "default": "0xDA50C69342216b538Daf06FfECDa7363E0B96684", "type": "string" }, "poolAddress": { "description": "Address of the Pancakeswap V2 pool", "type": "string" }, - "percentageToRemove": { + "percentageToRemove": { + "minimum": 0, + "maximum": 100, + "description": "Percentage of liquidity to remove", + "type": "number" + } + }, + "required": ["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"] + } + } + } + } + } + } + }, + "/connectors/pancakeswap/amm/create-pool": { + "post": { + "tags": ["/connector/pancakeswap"], + "description": "Create a new Pancakeswap V2 (AMM) pool and seed it with initial liquidity (fixed 0.25% fee)", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "network": { + "description": "The EVM network to use", + "default": "mainnet", + "enum": ["arbitrum", "base", "bsc", "mainnet"], + "type": "string" + }, + "walletAddress": { + "description": "Wallet address that will create and seed the pool", + "default": "0xDA50C69342216b538Daf06FfECDa7363E0B96684", + "type": "string" + }, + "baseToken": { + "description": "Base token symbol or address (becomes the pool base)", + "type": "string", + "example": "USDT" + }, + "quoteToken": { + "description": "Quote token symbol or address (becomes the pool quote)", + "type": "string", + "example": "WBNB" + }, + "baseTokenAmount": { + "description": "Amount of base token to seed the pool with", + "type": "number" + }, + "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 current market price is fetched from the unified swap router.", + "type": "number" + }, + "initialPrice": { + "description": "Initial price as quote per base. Overrides quoteTokenAmount. If both are omitted, the pool is seeded at the current market price so it is not immediately arbitraged.", + "type": "number" + }, + "slippagePct": { "minimum": 0, "maximum": 100, - "description": "Percentage of liquidity to remove", + "description": "Maximum acceptable slippage percentage", + "default": 2, "type": "number" - }, - "gasPrice": { - "description": "Gas price in wei for the transaction", - "type": "string" - }, - "maxGas": { - "description": "Maximum gas limit for the transaction", - "type": "number", - "example": 300000 } }, - "required": ["poolAddress", "percentageToRemove"] + "required": ["baseToken", "quoteToken", "baseTokenAmount"] } } }, @@ -13342,23 +16983,31 @@ "description": "TransactionStatus enum value", "type": "number" }, + "poolAddress": { + "description": "Address of the newly created pool", + "type": "string" + }, + "price": { + "description": "Initial price the pool was seeded at (quote per base)", + "type": "number" + }, "data": { "type": "object", "properties": { "fee": { "type": "number" }, - "baseTokenAmountRemoved": { + "baseTokenAmountAdded": { "type": "number" }, - "quoteTokenAmountRemoved": { + "quoteTokenAmountAdded": { "type": "number" } }, - "required": ["fee", "baseTokenAmountRemoved", "quoteTokenAmountRemoved"] + "required": ["fee", "baseTokenAmountAdded", "quoteTokenAmountAdded"] } }, - "required": ["signature", "status"] + "required": ["signature", "status", "poolAddress"] } } } @@ -13392,6 +17041,18 @@ "name": "poolAddress", "required": true, "description": "Pancakeswap V3 pool address" + }, + { + "schema": { + "default": 0, + "minimum": 0, + "maximum": 401, + "type": "integer" + }, + "in": "query", + "name": "binCount", + "required": false, + "description": "If > 0, include a `bins` array (per-tickSpacing token amounts around the current tick), mirroring Meteora pool-info.bins[]. Default 0 — pool-info skips the extra eth_calls." } ], "responses": { @@ -13428,6 +17089,27 @@ }, "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"] + } } }, "required": [ @@ -13519,12 +17201,6 @@ }, "price": { "type": "number" - }, - "rewardTokenAddress": { - "type": "string" - }, - "rewardAmount": { - "type": "number" } }, "required": [ @@ -13568,7 +17244,7 @@ "schema": { "type": "string" }, - "example": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", + "example": "0xDA50C69342216b538Daf06FfECDa7363E0B96684", "in": "query", "name": "walletAddress", "required": true @@ -13622,12 +17298,6 @@ }, "price": { "type": "number" - }, - "rewardTokenAddress": { - "type": "string" - }, - "rewardAmount": { - "type": "number" } }, "required": [ @@ -13904,9 +17574,9 @@ "properties": { "walletAddress": { "description": "Wallet address that will execute the swap", - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", + "default": "0xDA50C69342216b538Daf06FfECDa7363E0B96684", "type": "string", - "example": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50" + "example": "0xDA50C69342216b538Daf06FfECDa7363E0B96684" }, "network": { "description": "The blockchain network to use", @@ -13995,6 +17665,10 @@ "quoteTokenBalanceChange": { "description": "Change in quote token balance (negative for decrease)", "type": "number" + }, + "slippagePct": { + "description": "Slippage tolerance percentage actually applied to the swap", + "type": "number" } }, "required": [ @@ -14033,7 +17707,7 @@ }, "walletAddress": { "type": "string", - "example": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50" + "example": "0xDA50C69342216b538Daf06FfECDa7363E0B96684" }, "lowerPrice": { "type": "number", @@ -14137,7 +17811,7 @@ }, "walletAddress": { "description": "Wallet address that will add liquidity", - "default": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50", + "default": "0xDA50C69342216b538Daf06FfECDa7363E0B96684", "type": "string" }, "positionAddress": { @@ -14158,15 +17832,6 @@ "description": "Maximum acceptable slippage percentage", "default": 2, "type": "number" - }, - "gasPrice": { - "description": "Gas price in wei for the transaction", - "type": "string" - }, - "maxGas": { - "description": "Maximum gas limit for the transaction", - "type": "number", - "example": 300000 } }, "required": ["positionAddress", "baseTokenAmount", "quoteTokenAmount"] @@ -14231,7 +17896,7 @@ }, "walletAddress": { "type": "string", - "example": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50" + "example": "0xDA50C69342216b538Daf06FfECDa7363E0B96684" }, "positionAddress": { "type": "string", @@ -14307,7 +17972,7 @@ }, "walletAddress": { "type": "string", - "example": "0xB6B3140Eb3953BCE564f937948f98Ab5A8286a50" + "example": "0xDA50C69342216b538Daf06FfECDa7363E0B96684" }, "positionAddress": { "type": "string", @@ -14441,6 +18106,96 @@ } } }, + "/connectors/pancakeswap/clmm/create-pool": { + "post": { + "tags": ["/connector/pancakeswap"], + "description": "Create and initialize a new Pancakeswap V3 (CLMM) pool at an initial price (no liquidity seeded)", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "network": { + "description": "The EVM network to use", + "default": "bsc", + "enum": ["arbitrum", "base", "bsc", "mainnet"], + "type": "string", + "example": "bsc" + }, + "walletAddress": { + "description": "Wallet address that will create and initialize the pool", + "default": "0xDA50C69342216b538Daf06FfECDa7363E0B96684", + "type": "string" + }, + "baseToken": { + "description": "Base token symbol or address (becomes the pool base)", + "type": "string", + "example": "USDT" + }, + "quoteToken": { + "description": "Quote token symbol or address (becomes the pool quote)", + "type": "string", + "example": "WBNB" + }, + "fee": { + "description": "Pancakeswap V3 fee tier in hundredths of a bip: 100 (0.01%), 500 (0.05%), 2500 (0.25%), or 10000 (1.00%)", + "enum": [100, 500, 2500, 10000], + "type": "number", + "example": 2500 + }, + "initialPrice": { + "description": "Initial price as quote per base. If omitted, the current market price is fetched from the unified swap router so the pool opens on-market and is not immediately arbitraged.", + "type": "number" + } + }, + "required": ["baseToken", "quoteToken", "fee"] + } + } + }, + "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": { + "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"] + } + } + } + } + } + } + }, "/connectors/pancakeswap-sol/clmm/pool-info": { "get": { "tags": ["/connector/pancakeswap-sol"], @@ -14466,6 +18221,18 @@ "name": "poolAddress", "required": true, "description": "PancakeSwap CLMM pool 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. Default 0 = skip the bin fetch." } ], "responses": { @@ -14502,6 +18269,27 @@ }, "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"] + } } }, "required": [ @@ -14594,12 +18382,6 @@ }, "price": { "type": "number" - }, - "rewardTokenAddress": { - "type": "string" - }, - "rewardAmount": { - "type": "number" } }, "required": [ @@ -14644,7 +18426,7 @@ "schema": { "type": "string" }, - "example": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", + "example": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "in": "query", "name": "walletAddress", "required": true, @@ -14709,12 +18491,6 @@ }, "price": { "type": "number" - }, - "rewardTokenAddress": { - "type": "string" - }, - "rewardAmount": { - "type": "number" } }, "required": [ @@ -15015,7 +18791,7 @@ }, "walletAddress": { "description": "Solana wallet address", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string" }, "poolAddress": { @@ -15098,6 +18874,10 @@ }, "quoteTokenBalanceChange": { "type": "number" + }, + "slippagePct": { + "description": "Slippage tolerance percentage actually applied to the swap", + "type": "number" } }, "required": [ @@ -15119,6 +18899,97 @@ } } }, + "/connectors/pancakeswap-sol/clmm/create-pool": { + "post": { + "tags": ["/connector/pancakeswap-sol"], + "description": "Create and initialize a new PancakeSwap Solana CLMM pool at an initial price. Does not open or seed a position.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "network": { + "description": "Solana network to use", + "default": "mainnet-beta", + "enum": ["devnet", "mainnet-beta"], + "type": "string" + }, + "walletAddress": { + "description": "Solana wallet address that will create and initialize the pool", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", + "type": "string" + }, + "baseToken": { + "description": "Base token symbol or address (becomes the pool base)", + "type": "string", + "example": "SOL" + }, + "quoteToken": { + "description": "Quote token symbol or address (becomes the pool quote)", + "type": "string", + "example": "USDC" + }, + "initialPrice": { + "description": "Initial price as quote per base. If omitted, the current market price is fetched from the unified swap router so the pool opens on-market. No position is opened; only the pool is created.", + "type": "number", + "example": 200 + }, + "ammConfigIndex": { + "description": "Fee-config index; resolves to the amm_config PDA ([\"amm_config\", index]) and is validated on-chain. Each index is a fee tier created by the program admin. Default 0.", + "default": 0, + "minimum": 0, + "maximum": 65535, + "type": "integer" + } + }, + "required": ["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": { + "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"] + } + } + } + } + } + } + }, "/connectors/pancakeswap-sol/clmm/open-position": { "post": { "tags": ["/connector/pancakeswap-sol"], @@ -15137,7 +19008,7 @@ }, "walletAddress": { "description": "Solana wallet address", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string" }, "poolAddress": { @@ -15249,7 +19120,7 @@ }, "walletAddress": { "description": "Solana wallet address", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string" }, "positionAddress": { @@ -15339,7 +19210,7 @@ }, "walletAddress": { "description": "Solana wallet address", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string" }, "positionAddress": { @@ -15403,7 +19274,7 @@ "/connectors/pancakeswap-sol/clmm/collect-fees": { "post": { "tags": ["/connector/pancakeswap-sol"], - "description": "Collect accumulated fees from a PancakeSwap Solana CLMM position (removes 1% liquidity)", + "description": "Collect accumulated fees from a PancakeSwap Solana CLMM position (zero-liquidity decrease; liquidity is not touched)", "requestBody": { "content": { "application/json": { @@ -15418,7 +19289,7 @@ }, "walletAddress": { "description": "Solana wallet address", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string" }, "positionAddress": { @@ -15490,7 +19361,7 @@ }, "walletAddress": { "description": "Solana wallet address", - "default": "Fxu7UU5D9ry1qDQj41Yp3oTeAdqxShzVYna44JE7E1gn", + "default": "82SggYRE2Vo4jN4a2pk3aQ4SET4ctafZJGbowmCqyHx5", "type": "string" }, "positionAddress": { @@ -15563,7 +19434,7 @@ }, "servers": [ { - "url": "http://localhost:15888" + "url": "http://localhost:15889" } ], "tags": [ @@ -15591,6 +19462,10 @@ "name": "/trading/clmm", "description": "Unified cross-chain CLMM (Concentrated Liquidity) endpoints" }, + { + "name": "/trading/amm", + "description": "Unified cross-connector AMM endpoints (pool creation)" + }, { "name": "/chain/solana", "description": "Solana and SVM-based chain endpoints" @@ -15630,6 +19505,18 @@ { "name": "/connector/pancakeswap", "description": "PancakeSwap EVM connector endpoints" + }, + { + "name": "/connector/dflow", + "description": "DFlow connector endpoints" + }, + { + "name": "/connector/okx", + "description": "OKX DEX aggregator connector endpoints" + }, + { + "name": "/connector/titan", + "description": "Titan connector endpoints" } ] } diff --git a/src/app.ts b/src/app.ts index 5ef6fe21dd..0eff148624 100644 --- a/src/app.ts +++ b/src/app.ts @@ -45,7 +45,7 @@ import { logger } from './services/logger'; import { quoteCache } from './services/quote-cache'; import { displayChainConfigurations } from './services/startup-banner'; import { tokensRoutes } from './tokens/tokens.routes'; -import { tradingRoutes, tradingClmmRoutes, tradingAmmRoutes } from './trading/trading.routes'; +import { tradingSwapRoutes, tradingClmmRoutes, tradingAmmRoutes } from './trading/trading.routes'; import { GATEWAY_VERSION } from './version'; import { walletRoutes } from './wallet/wallet.routes'; @@ -292,7 +292,7 @@ const configureGatewayServer = () => { app.register(poolRoutes, { prefix: '/pools' }); // Register trading routes (unified cross-chain swap) - app.register(tradingRoutes, { prefix: '/trading/swap' }); + app.register(tradingSwapRoutes, { prefix: '/trading/swap' }); // Register trading CLMM routes (unified cross-chain concentrated liquidity) app.register(tradingClmmRoutes, { prefix: '/trading/clmm' }); diff --git a/src/chains/ethereum/ethereum.ts b/src/chains/ethereum/ethereum.ts index ec3e34a931..490b469b49 100644 --- a/src/chains/ethereum/ethereum.ts +++ b/src/chains/ethereum/ethereum.ts @@ -10,6 +10,7 @@ import { RPCProvider } from '../../rpc/rpc-provider-base'; import { TokenValue, tokenValueToString } from '../../services/base'; import { ConfigManagerCertPassphrase } from '../../services/config-manager-cert-passphrase'; import { ConfigManagerV2 } from '../../services/config-manager-v2'; +import { transactionFailed } from '../../services/error-handler'; import { logger, redactUrl } from '../../services/logger'; import { TokenService } from '../../services/token-service'; import { walletPath, isHardwareWallet as checkIsHardwareWallet } from '../../wallet/utils'; @@ -29,6 +30,15 @@ export interface TokenInfo { export type NewBlockHandler = (bn: number) => void; export type NewDebugMsgHandler = (msg: any) => void; +/** + * Outcome of an EVM liquidity/pool transaction as a route is allowed to report it. + * A revert is not one of the cases: it throws, so it can never be mistaken for PENDING. + * See {@link Ethereum.handleTransactionConfirmation}. + */ +export type EthereumTransactionOutcome = + | { confirmed: false; signature: string } + | { confirmed: true; signature: string; receipt: providers.TransactionReceipt; fee: number }; + // Networks that support EIP-1559 (type 2) transactions export const EIP1559_NETWORKS = [ 'mainnet', @@ -1026,6 +1036,60 @@ export class Ethereum { return null; } + /** + * Single confirmation gate for EVM liquidity and pool transactions (open/close position, + * add/remove liquidity, collect fees, create pool, execute swap). + * + * `handleTransactionExecution` has three outcomes but only two of them are a valid response + * body, so every caller used to have to remember two separate checks — and almost none did: + * + * - no receipt (still pending after the extended poll) — dereferencing it throws a TypeError + * that the route catch turns into a generic 500, losing the transaction hash and with it + * any chance of reconciling a transaction that lands a minute later. + * - `receipt.status === 0` (reverted on-chain) — forwarding it verbatim as the response + * `status` reports the revert as {@link TransactionStatus.PENDING}, which is also 0, so a + * poller waits on it forever while the route's pre-send amounts are booked as if the + * tokens had moved. + * + * This helper resolves both: + * + * - still pending -> `{ confirmed: false, signature: tx.hash }`. The caller returns + * `{ signature, status: TransactionStatus.PENDING }` with NO `data` — the amounts it + * computed before sending have not moved and must not be reported as if they had. + * - reverted -> throws the shared 400 TRANSACTION_FAILED, the same terminal, non-retryable + * error the Solana routes throw for a landed-but-failed transaction. + * - confirmed -> `{ confirmed: true, signature, receipt, fee }`, fee already converted from + * gas units to the chain's native currency. + */ + public async handleTransactionConfirmation(tx: TransactionResponse): Promise { + const receipt = await this.handleTransactionExecution(tx); + + if (!receipt) { + logger.warn(`Transaction ${tx.hash} still pending — reporting PENDING so the caller can reconcile it later`); + return { confirmed: false, signature: tx.hash }; + } + + if (receipt.status === 0) { + throw transactionFailed( + `Transaction ${receipt.transactionHash} reverted on-chain. Gas was spent; no tokens moved.`, + ); + } + + if (receipt.status !== 1) { + // No status on the receipt (pre-Byzantium chain or a provider quirk): neither a + // confirmation nor a revert, so report it as pending rather than guessing. + logger.warn(`Transaction ${receipt.transactionHash} has no receipt status — reporting PENDING`); + return { confirmed: false, signature: receipt.transactionHash }; + } + + return { + confirmed: true, + signature: receipt.transactionHash, + receipt, + fee: parseFloat(utils.formatUnits(receipt.gasUsed.mul(receipt.effectiveGasPrice), 18)), + }; + } + /** * Handle transaction confirmation status and return appropriate response * Similar to Solana's handleConfirmation helper @@ -1045,6 +1109,7 @@ export class Ethereum { expectedAmountOut: number, side?: 'BUY' | 'SELL', txHash?: string, // Optional tx hash for pending transactions + slippagePct?: number, // Slippage tolerance actually applied to the swap (echoed in data) ): { signature: string; status: number; @@ -1056,6 +1121,7 @@ export class Ethereum { fee: number; baseTokenBalanceChange: number; quoteTokenBalanceChange: number; + slippagePct?: number; }; } { if (!txReceipt) { @@ -1118,6 +1184,7 @@ export class Ethereum { fee, baseTokenBalanceChange, quoteTokenBalanceChange, + slippagePct, }, }; } diff --git a/src/chains/ethereum/routes/approve.ts b/src/chains/ethereum/routes/approve.ts index 026b6be472..2e6107b7ea 100644 --- a/src/chains/ethereum/routes/approve.ts +++ b/src/chains/ethereum/routes/approve.ts @@ -3,6 +3,7 @@ import { FastifyPluginAsync, FastifyInstance } from 'fastify'; import { getSpender as pancakeswapSpender } from '../../../connectors/pancakeswap/pancakeswap.contracts'; import { getSpender as uniswapSpender } from '../../../connectors/uniswap/uniswap.contracts'; +import { TransactionStatus } from '../../../schemas/chain-schema'; import { bigNumberWithDecimalToStr } from '../../../services/base'; import { logger } from '../../../services/logger'; import { Ethereum } from '../ethereum'; @@ -178,17 +179,20 @@ export async function approveEthereumToken( const txResponse = await ethereum.provider.sendTransaction(signedTx); // Wait for confirmation with timeout - const receipt = await ethereum.handleTransactionExecution(txResponse); + // A revert throws 400 TRANSACTION_FAILED out of the helper; only a still-pending + // transaction comes back unconfirmed, and an approval cannot be reported without a + // confirmed allowance. + const outcome = await ethereum.handleTransactionConfirmation(txResponse); - if (!receipt || receipt.status === -1) { + if (!outcome.confirmed) { throw new Error('Transaction timed out or failed to get receipt'); } approval = { - hash: receipt.transactionHash, + hash: outcome.signature, nonce: nonce, - gasUsed: receipt.gasUsed, - effectiveGasPrice: receipt.effectiveGasPrice, + gasUsed: outcome.receipt.gasUsed, + effectiveGasPrice: outcome.receipt.effectiveGasPrice, }; } else { // Regular wallet flow @@ -207,18 +211,17 @@ export async function approveEthereumToken( const tx = await ethereum.approveERC20(contract, wallet, spenderAddress, amountBigNumber); // Wait for the transaction to be mined with timeout (60 seconds for approvals) - const receipt = await ethereum.handleTransactionExecution(tx); + const outcome = await ethereum.handleTransactionConfirmation(tx); - if (!receipt || receipt.status === -1) { + if (!outcome.confirmed) { throw new Error('Transaction timed out or failed to get receipt'); } approval = { hash: tx.hash, nonce: tx.nonce, - gasUsed: receipt.gasUsed, - effectiveGasPrice: receipt.effectiveGasPrice, - status: receipt.status, + gasUsed: outcome.receipt.gasUsed, + effectiveGasPrice: outcome.receipt.effectiveGasPrice, }; } } else { @@ -229,7 +232,6 @@ export async function approveEthereumToken( nonce: 0, gasUsed: ethers.BigNumber.from('0'), effectiveGasPrice: ethers.BigNumber.from('0'), - status: 1, }; } @@ -291,20 +293,18 @@ export async function approveEthereumToken( const txResponse = await ethereum.provider.sendTransaction(signedTx); // Wait for confirmation with extended timeout - const permit2Receipt = await ethereum.handleTransactionExecution(txResponse); + const permit2Outcome = await ethereum.handleTransactionConfirmation(txResponse); - if (!permit2Receipt) { + if (!permit2Outcome.confirmed) { throw new Error('Permit2 transaction timed out or failed to get receipt'); } - logger.info(`Permit2 approval transaction confirmed: ${permit2Receipt.transactionHash}`); + logger.info(`Permit2 approval transaction confirmed: ${permit2Outcome.signature}`); // Update fee to include both transactions - if (permit2Receipt.gasUsed && permit2Receipt.effectiveGasPrice) { - const permit2FeeInWei = permit2Receipt.gasUsed.mul(permit2Receipt.effectiveGasPrice); - const totalFeeInWei = approval.gasUsed.mul(approval.effectiveGasPrice).add(permit2FeeInWei); - feeInEth = utils.formatEther(totalFeeInWei); - } + const permit2FeeInWei = permit2Outcome.receipt.gasUsed.mul(permit2Outcome.receipt.effectiveGasPrice); + const totalFeeInWei = approval.gasUsed.mul(approval.effectiveGasPrice).add(permit2FeeInWei); + feeInEth = utils.formatEther(totalFeeInWei); } else { // Regular wallet flow for Permit2 approve const wallet = await ethereum.getWallet(address); @@ -326,20 +326,18 @@ export async function approveEthereumToken( ); // Wait for confirmation with extended timeout - const permit2Receipt = await ethereum.handleTransactionExecution(permit2Tx); + const permit2Outcome = await ethereum.handleTransactionConfirmation(permit2Tx); - if (!permit2Receipt || permit2Receipt.status === -1) { + if (!permit2Outcome.confirmed) { throw new Error('Permit2 transaction timed out or failed to get receipt'); } - logger.info(`Permit2 approval transaction confirmed: ${permit2Receipt.transactionHash}`); + logger.info(`Permit2 approval transaction confirmed: ${permit2Outcome.signature}`); // Update fee to include both transactions - if (permit2Receipt.gasUsed && permit2Receipt.effectiveGasPrice) { - const permit2FeeInWei = permit2Receipt.gasUsed.mul(permit2Receipt.effectiveGasPrice); - const totalFeeInWei = approval.gasUsed.mul(approval.effectiveGasPrice).add(permit2FeeInWei); - feeInEth = utils.formatEther(totalFeeInWei); - } + const permit2FeeInWei = permit2Outcome.receipt.gasUsed.mul(permit2Outcome.receipt.effectiveGasPrice); + const totalFeeInWei = approval.gasUsed.mul(approval.effectiveGasPrice).add(permit2FeeInWei); + feeInEth = utils.formatEther(totalFeeInWei); } logger.info( @@ -349,7 +347,10 @@ export async function approveEthereumToken( return { signature: approval.hash, - status: approval.status ?? -1, + // Every path that reaches here confirmed: the helper throws on a revert and the + // branches above bail out while a transaction is still pending. `approval.status ?? -1` + // used to report a confirmed Ledger approval (which never carried a status) as FAILED. + status: TransactionStatus.CONFIRMED, data: { tokenAddress: fullToken.address, spender: isUniversalRouter ? universalRouterAddress || spenderAddress : spenderAddress, diff --git a/src/chains/ethereum/routes/poll.ts b/src/chains/ethereum/routes/poll.ts index 3d90df9e06..adf9a77803 100644 --- a/src/chains/ethereum/routes/poll.ts +++ b/src/chains/ethereum/routes/poll.ts @@ -1,7 +1,12 @@ import { ethers } from 'ethers'; import { FastifyPluginAsync, FastifyInstance } from 'fastify'; -import { PollRequestType, PollResponseType, PollResponseSchema } from '../../../schemas/chain-schema'; +import { + PollRequestType, + PollResponseType, + PollResponseSchema, + TransactionStatusCode, +} from '../../../schemas/chain-schema'; import { getConnector } from '../../../services/connection-manager'; import { logger } from '../../../services/logger'; import { Ethereum } from '../ethereum'; @@ -36,57 +41,25 @@ export async function pollEthereumTransaction( const ethereum = await Ethereum.getInstance(network); const currentBlock = await ethereum.getCurrentBlockNumber(); - let txData = await ethereum.getTransaction(signature); + const txData = await ethereum.getTransaction(signature); let txBlock, txReceipt, txStatus; if (!txData) { - const MAX_RETRIES = 3; - const RETRY_DELAY_MS = 1000; - let retryCount = 0; - - while (retryCount < MAX_RETRIES) { - await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS)); - txData = await ethereum.getTransaction(signature); - if (txData) break; - retryCount++; - } - - if (!txData) { - // tx not found after retries - logger.info(`Transaction ${signature} not found in mempool or does not exist after ${MAX_RETRIES} retries.`); - txBlock = -1; - txReceipt = null; - txStatus = -1; - } - } - - if (txData) { + // Unknown to the node: never received or dropped. eth_getTransactionByHash + // returns mempool transactions, so not-found is distinct from pending. + logger.info(`Transaction ${signature} not found in mempool or on-chain.`); + txBlock = -1; + txReceipt = null; + txStatus = TransactionStatusCode.NOT_FOUND; + } else { txReceipt = await ethereum.getTransactionReceipt(signature); if (txReceipt === null) { - // tx is in the mempool + // In the mempool, awaiting inclusion txBlock = -1; - txReceipt = null; - - // In stateless approach, we simply check if the transaction is still pending - // We use a basic status code of 0 for pending transactions in mempool - txStatus = 0; - - // Check if transaction is likely to be processed based on gas price - if (txData.gasPrice) { - const currentGasPrice = await ethereum.estimateGasPrice(); - // Convert current gas price from GWEI to wei for comparison - const currentGasPriceWei = currentGasPrice * 1e9; - // If the transaction's gas price is significantly lower than current gas price, - // it might be stuck (status 3), otherwise it's likely to be processed (status 2) - if (txData.gasPrice.toNumber() < currentGasPriceWei * 0.8) { - txStatus = 3; // Likely stuck - } else { - txStatus = 2; // Likely to be processed - } - } + txStatus = TransactionStatusCode.PENDING; } else { - // tx has been processed txBlock = txReceipt.blockNumber; - txStatus = typeof txReceipt.status === 'number' ? 1 : -1; + // Receipt status 0 = reverted, 1 = success (undefined only pre-Byzantium) + txStatus = txReceipt.status === 0 ? TransactionStatusCode.FAILED : TransactionStatusCode.CONFIRMED; // decode logs if (connector) { diff --git a/src/chains/ethereum/routes/unwrap.ts b/src/chains/ethereum/routes/unwrap.ts index 54001c7a03..05c939b282 100644 --- a/src/chains/ethereum/routes/unwrap.ts +++ b/src/chains/ethereum/routes/unwrap.ts @@ -1,9 +1,10 @@ import { ethers, utils } from 'ethers'; import { FastifyPluginAsync, FastifyInstance } from 'fastify'; +import { TransactionStatus } from '../../../schemas/chain-schema'; import { bigNumberWithDecimalToStr } from '../../../services/base'; import { logger } from '../../../services/logger'; -import { Ethereum } from '../ethereum'; +import { Ethereum, EthereumTransactionOutcome } from '../ethereum'; import { EthereumLedger } from '../ethereum-ledger'; import { UnwrapRequestSchema, UnwrapResponseSchema, UnwrapRequestType, UnwrapResponseType } from '../schemas'; @@ -72,9 +73,9 @@ export async function unwrapEthereum(fastify: FastifyInstance, network: string, const amountInWei = utils.parseEther(amount); try { - let transaction; + let transaction: ethers.providers.TransactionResponse; let nonce: number; - let receipt; + let outcome: EthereumTransactionOutcome; if (isHardware) { // Hardware wallet flow @@ -117,12 +118,7 @@ export async function unwrapEthereum(fastify: FastifyInstance, network: string, const txResponse = await ethereum.provider.sendTransaction(signedTx); // Wait for confirmation with timeout - receipt = await ethereum.handleTransactionExecution(txResponse); - - transaction = { - hash: receipt.transactionHash, - nonce: nonce, - }; + outcome = await ethereum.handleTransactionConfirmation(txResponse); } else { // Regular wallet flow let wallet: ethers.Wallet; @@ -157,19 +153,21 @@ export async function unwrapEthereum(fastify: FastifyInstance, network: string, nonce = transaction.nonce; // Wait for transaction confirmation with timeout - receipt = await ethereum.handleTransactionExecution(transaction); + outcome = await ethereum.handleTransactionConfirmation(transaction); } - // Calculate actual fee from receipt - let feeInEth = '0'; - if (receipt.gasUsed && receipt.effectiveGasPrice) { - const feeInWei = receipt.gasUsed.mul(receipt.effectiveGasPrice); - feeInEth = utils.formatEther(feeInWei); + // A revert threw out of the confirmation helper as a 400 TRANSACTION_FAILED — it is never + // reported as PENDING (receipt.status 0 and TransactionStatus.PENDING are the same number). + if (!outcome.confirmed) { + return { signature: outcome.signature, status: TransactionStatus.PENDING }; } + // Calculate actual fee from the confirmed receipt + const feeInEth = utils.formatEther(outcome.receipt.gasUsed.mul(outcome.receipt.effectiveGasPrice)); + return { - signature: transaction.hash, - status: receipt.status, + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, data: { nonce: nonce, fee: feeInEth, diff --git a/src/chains/ethereum/routes/wrap.ts b/src/chains/ethereum/routes/wrap.ts index b726f9cd6f..a3230047f5 100644 --- a/src/chains/ethereum/routes/wrap.ts +++ b/src/chains/ethereum/routes/wrap.ts @@ -1,9 +1,10 @@ import { ethers, utils } from 'ethers'; import { FastifyPluginAsync, FastifyInstance } from 'fastify'; +import { TransactionStatus } from '../../../schemas/chain-schema'; import { bigNumberWithDecimalToStr } from '../../../services/base'; import { logger } from '../../../services/logger'; -import { Ethereum } from '../ethereum'; +import { Ethereum, EthereumTransactionOutcome } from '../ethereum'; import { EthereumLedger } from '../ethereum-ledger'; import { WrapRequestSchema, WrapResponseSchema, WrapRequestType, WrapResponseType } from '../schemas'; @@ -73,9 +74,9 @@ export async function wrapEthereum(fastify: FastifyInstance, network: string, ad const amountInWei = utils.parseEther(amount); try { - let transaction; + let transaction: ethers.providers.TransactionResponse; let nonce: number; - let receipt; + let outcome: EthereumTransactionOutcome; if (isHardware) { // Hardware wallet flow @@ -110,12 +111,7 @@ export async function wrapEthereum(fastify: FastifyInstance, network: string, ad const txResponse = await ethereum.provider.sendTransaction(signedTx); // Wait for confirmation with timeout - receipt = await ethereum.handleTransactionExecution(txResponse); - - transaction = { - hash: receipt.transactionHash, - nonce: nonce, - }; + outcome = await ethereum.handleTransactionConfirmation(txResponse); } else { // Regular wallet flow let wallet: ethers.Wallet; @@ -143,19 +139,21 @@ export async function wrapEthereum(fastify: FastifyInstance, network: string, ad nonce = transaction.nonce; // Wait for transaction confirmation with timeout - receipt = await ethereum.handleTransactionExecution(transaction); + outcome = await ethereum.handleTransactionConfirmation(transaction); } - // Calculate actual fee from receipt - let feeInEth = '0'; - if (receipt.gasUsed && receipt.effectiveGasPrice) { - const feeInWei = receipt.gasUsed.mul(receipt.effectiveGasPrice); - feeInEth = utils.formatEther(feeInWei); + // A revert threw out of the confirmation helper as a 400 TRANSACTION_FAILED — it is never + // reported as PENDING (receipt.status 0 and TransactionStatus.PENDING are the same number). + if (!outcome.confirmed) { + return { signature: outcome.signature, status: TransactionStatus.PENDING }; } + // Calculate actual fee from the confirmed receipt + const feeInEth = utils.formatEther(outcome.receipt.gasUsed.mul(outcome.receipt.effectiveGasPrice)); + return { - signature: transaction.hash, - status: receipt.status, + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, data: { nonce: nonce, fee: feeInEth, diff --git a/src/chains/solana/routes/poll.ts b/src/chains/solana/routes/poll.ts index b9f0266d70..3351d3b6cc 100644 --- a/src/chains/solana/routes/poll.ts +++ b/src/chains/solana/routes/poll.ts @@ -1,6 +1,11 @@ import { FastifyPluginAsync, FastifyInstance } from 'fastify'; -import { PollRequestType, PollResponseType, PollResponseSchema } from '../../../schemas/chain-schema'; +import { + PollRequestType, + PollResponseType, + PollResponseSchema, + TransactionStatusCode, +} from '../../../schemas/chain-schema'; import { logger } from '../../../services/logger'; import { SolanaPollRequest } from '../schemas'; import { Solana } from '../solana'; @@ -16,13 +21,15 @@ export async function pollSolanaTransaction( try { const currentBlock = await solana.getCurrentBlockNumber(); - // Validate transaction signature format + // Validate transaction signature format. A malformed signature can never + // resolve, so report NOT_FOUND rather than a pending status a poller would + // wait on forever. if (!signature || typeof signature !== 'string' || !signature.match(/^[A-Za-z0-9]{43,88}$/)) { return { currentBlock, signature, txBlock: null, - txStatus: 0, + txStatus: TransactionStatusCode.NOT_FOUND, fee: null, error: 'INVALID_INPUT: Invalid transaction signature format', txData: null, @@ -32,11 +39,16 @@ export async function pollSolanaTransaction( const txData = await solana.getTransaction(signature); if (!txData) { + // Null txData means either "seen but awaiting confirmation" or "unknown to + // the cluster" (dropped, or never received). Only the signature-status + // cache separates them: UNCONFIRMED is worth polling again, NOT_FOUND is + // terminal once the transaction's blockhash has expired. + const txStatus = await solana.getSignatureStatus(signature); return { currentBlock, signature, txBlock: null, - txStatus: 0, + txStatus, fee: null, error: null, txData: null, @@ -48,10 +60,13 @@ export async function pollSolanaTransaction( // Extract fee from transaction const fee = txData.meta?.fee ? txData.meta.fee / 1e9 : 0; // Convert lamports to SOL - // Check for transaction error and parse it + // Check for transaction error and parse it. The err object carries the code but + // names no program, so parse it together with the program logs — attribution + // comes from the "Program X failed: custom program error" line, and without it + // every program-specific code (e.g. Orca 6018) falls through to UNKNOWN. let error: string | null = null; if (txData.meta?.err) { - const errorStr = JSON.stringify(txData.meta.err); + const errorStr = [JSON.stringify(txData.meta.err), ...(txData.meta.logMessages ?? [])].join('\n'); const parsed = parseSolanaError(errorStr); error = `${parsed.type} (${parsed.errorCodeHex || 'unknown'}): ${parsed.message}`; logger.info(`Transaction ${signature} failed: ${error}`); @@ -69,14 +84,16 @@ export async function pollSolanaTransaction( txData, }; } catch (err) { + // Transient failure (RPC error, etc.) — the transaction's fate is unknown, so + // report pending rather than NOT_FOUND: the caller should poll again, not give up. logger.error(`Error polling transaction ${signature}: ${(err as Error).message}`); return { currentBlock: await solana.getCurrentBlockNumber(), signature, txBlock: null, - txStatus: 0, + txStatus: TransactionStatusCode.PENDING, fee: null, - error: 'Transaction not found or invalid', + error: `Error polling transaction: ${(err as Error).message}`, txData: null, }; } diff --git a/src/chains/solana/solana-error-parser.ts b/src/chains/solana/solana-error-parser.ts index f8fec795f3..6d58175a0c 100644 --- a/src/chains/solana/solana-error-parser.ts +++ b/src/chains/solana/solana-error-parser.ts @@ -90,8 +90,12 @@ const PROGRAM_ERROR_CODES: Record { + // returns a Solana TransactionStatusCode for a txData. + public async getTransactionStatusCode(txData: TransactionResponse | null): Promise { let txStatus; if (!txData) { // tx not yet confirmed by validator - txStatus = TransactionResponseStatusCode.UNCONFIRMED; + txStatus = TransactionStatusCode.PENDING; } else { // If txData exists, check if there's an error in the metadata - txStatus = - txData.meta?.err == null ? TransactionResponseStatusCode.CONFIRMED : TransactionResponseStatusCode.FAILED; + txStatus = txData.meta?.err == null ? TransactionStatusCode.CONFIRMED : TransactionStatusCode.FAILED; } return txStatus; } + // Distinguishes a signature the cluster has seen from one it does not know at all. + // getTransaction (commitment 'confirmed') returns null for both a tx awaiting + // confirmation and a tx that was dropped, so pollers cannot tell them apart from + // txData alone. A signature that stays NOT_FOUND after its blockhash expires + // (~90s) can never land. + public async getSignatureStatus(signature: string): Promise { + const { value } = await this.connection.getSignatureStatuses([signature], { + searchTransactionHistory: true, + }); + const status = value[0]; + if (!status) { + return TransactionStatusCode.NOT_FOUND; + } + if (status.err) { + return TransactionStatusCode.FAILED; + } + // Seen by the cluster: 'processed', or confirmed/finalized racing ahead of + // getTransaction visibility — report unconfirmed and let the next poll resolve it. + return TransactionStatusCode.PENDING; + } + // returns the current block number async getCurrentBlockNumber(): Promise { return await this.connection.getSlot('processed'); @@ -1325,6 +1341,30 @@ export class Solana { return totalFee; } + private static throwIfSimulationReturnedError(simulationResult: { err: unknown; logs?: string[] | null }): void { + if (!simulationResult.err) return; + + const logs = simulationResult.logs ?? []; + const errorMessage = `${SIMULATION_ERROR_MESSAGE}\nError: ${JSON.stringify(simulationResult.err)}\nProgram Logs: ${logs.join('\n')}`; + const parsedError = parseSolanaError(errorMessage); + const detail = [ + parsedError.message, + parsedError.instructionIndex !== null ? `Failing instruction index: ${parsedError.instructionIndex}.` : '', + logs.length > 0 ? `Program logs:\n${logs.slice(-12).join('\n')}` : '', + ] + .filter(Boolean) + .join('\n'); + + logger.error(errorMessage); + if (parsedError.type === 'SLIPPAGE_EXCEEDED') { + throw httpErrors.slippageExceeded(detail); + } + if (parsedError.type === 'INSUFFICIENT_BALANCE') { + throw httpErrors.insufficientBalance(detail); + } + throw httpErrors.simulationFailed(detail); + } + public async sendAndConfirmTransaction( tx: Transaction | VersionedTransaction, signers: Signer[] = [], @@ -1334,10 +1374,9 @@ export class Solana { const currentPriorityFee = priorityFeePerCU ?? (await this.estimateGasPrice()); // Always simulate transaction to get actual compute units - let computeUnitsToUse: number; + let computeUnitsToUse = this.config.defaultComputeUnits; + let simulationResult: { err: unknown; logs?: string[] | null; unitsConsumed?: number } | undefined; try { - let simulationResult; - if (tx instanceof Transaction) { // For regular transactions, simulate with the Transaction object const result = await this.connection.simulateTransaction(tx); @@ -1350,7 +1389,12 @@ export class Solana { }); simulationResult = result.value; } + } catch (error) { + logger.warn(`Failed to simulate for compute units: ${error.message}, using default`); + } + if (simulationResult) { + Solana.throwIfSimulationReturnedError(simulationResult); if (simulationResult.unitsConsumed) { // Add 10% margin for safety computeUnitsToUse = Math.ceil(simulationResult.unitsConsumed * 1.1); @@ -1358,13 +1402,8 @@ export class Solana { `Simulation consumed ${simulationResult.unitsConsumed} units, using ${computeUnitsToUse} with 10% margin`, ); } else { - // Fallback to default if simulation doesn't return units - computeUnitsToUse = this.config.defaultComputeUnits; logger.warn('Simulation did not return units consumed, using default'); } - } catch (error) { - logger.warn(`Failed to simulate for compute units: ${error.message}, using default`); - computeUnitsToUse = this.config.defaultComputeUnits; } const basePriorityFeeLamports = currentPriorityFee * computeUnitsToUse; @@ -1408,28 +1447,54 @@ export class Solana { } /** - * If a broadcast transaction landed on-chain but failed, throw the parsed program error; - * return silently when the transaction is missing or succeeded so the caller can apply - * its own confirmation handling. The confirmation helpers report a landed-and-failed - * transaction as unconfirmed, which callers would otherwise misreport as a timeout. + * If a broadcast transaction landed on-chain but failed, throw the parsed program error + * (400 TRANSACTION_FAILED — fees were paid, this is terminal, not retryable); return + * silently when the transaction is missing or succeeded so the caller can apply its own + * confirmation handling. The confirmation helpers report a landed-and-failed transaction + * as unconfirmed, which callers would otherwise misreport as a timeout or as PENDING. + * + * Pass `txData` when it is already at hand to skip the re-fetch; when the caller only + * has a null/absent txData the transaction is looked up once more, because the send + * helpers can report a landed-and-failed transaction with no data attached. */ - private async throwIfLandedWithError(signature: string): Promise { + public async throwIfLandedWithError(signature: string, txData?: any): Promise { if (!signature) return; - let txData: any = null; - try { - txData = await this.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); - } catch { - return; + if (!txData) { + try { + txData = await this.connection.getTransaction(signature, { + commitment: 'confirmed', + maxSupportedTransactionVersion: 0, + }); + } catch { + return; + } } if (!txData?.meta?.err) return; - const { simulationFailed } = await import('../../services/error-handler'); + throw await this.buildLandedWithErrorException(signature, txData); + } + + /** Build the shared landed-but-failed error from a transaction's on-chain data. */ + private async buildLandedWithErrorException(signature: string, txData: any): Promise { + const { transactionFailed } = await import('../../services/error-handler'); const { parseSolanaError } = await import('./solana-error-parser'); const logs: string[] = txData.meta.logMessages ?? []; const parsed = parseSolanaError([JSON.stringify(txData.meta.err), ...logs].join('\n')); - throw simulationFailed(`Transaction ${signature} landed on-chain but failed: ${parsed.message}`); + return transactionFailed(`Transaction ${signature} landed on-chain but failed: ${parsed.message}`); + } + + /** + * Route-level re-fetch of a just-sent transaction. Uses the retrying fetch (data can lag + * RPC visibility right after confirmation, so a single getTransaction call misreports a + * confirmed transaction as PENDING) and throws the shared landed-but-failed error when + * the transaction landed with an error. Returns null only when the transaction is + * genuinely not visible on-chain yet. + */ + public async getConfirmedTransactionData(signature: string): Promise { + const txData = await this._fetchTransactionWithRetry(signature); + if (txData?.meta?.err != null) { + throw await this.buildLandedWithErrorException(signature, txData); + } + return txData; } public async sendAndConfirmTransactionForWallet( @@ -1469,18 +1534,22 @@ export class Solana { // External wallets (hardware/Ledger): add the compute budget and sign any extra // keypairs, then sign the fee payer externally. const currentPriorityFee = priorityFeePerCU ?? (await this.estimateGasPrice()); - let computeUnitsToUse: number; + let computeUnitsToUse = this.config.defaultComputeUnits; + let simulationResult: { err: unknown; logs?: string[] | null; unitsConsumed?: number } | undefined; try { const sim = tx instanceof VersionedTransaction ? await this.connection.simulateTransaction(tx, { replaceRecentBlockhash: true, sigVerify: false }) : await this.connection.simulateTransaction(tx); - computeUnitsToUse = sim.value.unitsConsumed - ? Math.ceil(sim.value.unitsConsumed * 1.1) - : this.config.defaultComputeUnits; + simulationResult = sim.value; } catch (error) { logger.warn(`Failed to simulate for compute units: ${(error as Error).message}, using default`); - computeUnitsToUse = this.config.defaultComputeUnits; + } + if (simulationResult) { + Solana.throwIfSimulationReturnedError(simulationResult); + computeUnitsToUse = simulationResult.unitsConsumed + ? Math.ceil(simulationResult.unitsConsumed * 1.1) + : this.config.defaultComputeUnits; } let prepared: Transaction | VersionedTransaction; @@ -2208,24 +2277,28 @@ export class Solana { /** * Helper function to handle transaction confirmation results - * Returns appropriate response object based on confirmation status + * Returns appropriate response object based on the transaction's on-chain data * @param signature Transaction signature - * @param confirmed Whether transaction was confirmed - * @param txData Transaction data (if available) + * @param txData Transaction data from the route-level re-fetch (pass null when the + * caller has none — the helper re-fetches with retry so a just-confirmed transaction + * whose data lags RPC visibility is not misreported as PENDING) * @param tokenIn Input token address * @param tokenOut Output token address * @param walletAddress Wallet address for balance changes * @param side Trade side (optional, for AMM/CLMM swaps) - * @returns Response object with status and data + * @param slippagePct Slippage tolerance actually applied to the swap (echoed in data) + * @returns Response object with status and data; throws the shared landed-but-failed + * error when the transaction landed on-chain with an error — existence of txData is + * never treated as confirmation by itself */ public async handleConfirmation( signature: string, - confirmed: boolean, txData: any, tokenIn: string, tokenOut: string, walletAddress: string, side?: 'BUY' | 'SELL', + slippagePct?: number, ): Promise<{ signature: string; status: number; @@ -2237,9 +2310,20 @@ export class Solana { fee: number; baseTokenBalanceChange: number; quoteTokenBalanceChange: number; + slippagePct?: number; }; }> { - if (confirmed && txData) { + if (!txData) { + txData = await this._fetchTransactionWithRetry(signature); + } + + // Defense: a landed-but-failed transaction must throw — never report it as + // confirmed (its data exists) or as pending. + if (txData?.meta?.err != null) { + throw await this.buildLandedWithErrorException(signature, txData); + } + + if (txData) { // Transaction confirmed, extract balance changes const { balanceChanges, fee } = await this.extractBalanceChangesAndFee(signature, walletAddress, [ tokenIn, @@ -2278,25 +2362,7 @@ export class Solana { fee, baseTokenBalanceChange: baseTokenBalanceChange!, quoteTokenBalanceChange: quoteTokenBalanceChange!, - }, - }; - } else if (txData && !confirmed) { - // Transaction exists but not confirmed - extract fee from txData - const fee = this.getFee(txData); - - logger.warn(`Transaction ${signature} not confirmed. May need higher priority fee.`); - - return { - signature, - status: -1, // NOT_CONFIRMED - data: { - tokenIn, - tokenOut, - amountIn: 0, - amountOut: 0, - fee, - baseTokenBalanceChange: 0, - quoteTokenBalanceChange: 0, + slippagePct, }, }; } else { diff --git a/src/connectors/0x/router-routes/executeQuote.ts b/src/connectors/0x/router-routes/executeQuote.ts index f49f185496..0318597212 100644 --- a/src/connectors/0x/router-routes/executeQuote.ts +++ b/src/connectors/0x/router-routes/executeQuote.ts @@ -9,13 +9,7 @@ import { quoteCache } from '../../../services/quote-cache'; import { ZeroX } from '../0x'; import { ZeroXExecuteQuoteRequest } from '../schemas'; -async function executeQuote( - walletAddress: string, - network: string, - quoteId: string, - gasPrice?: string, - maxGas?: number, -): Promise { +async function executeQuote(walletAddress: string, network: string, quoteId: string): Promise { // Retrieve cached quote from global cache const quote = quoteCache.get(quoteId); if (!quote) { @@ -56,8 +50,7 @@ async function executeQuote( to: quote.to, data: quote.data, value: quote.value, - gasLimit: maxGas || parseInt(quote.estimatedGas || quote.gas), - ...(gasPrice && { gasPrice: BigNumber.from(gasPrice) }), + gasLimit: parseInt(quote.estimatedGas || quote.gas), }; const txResponse = await wallet.sendTransaction(txData); @@ -82,6 +75,9 @@ async function executeQuote( quote.buyTokenAddress, expectedAmountIn, expectedAmountOut, + undefined, + undefined, + quoteCache.getRequest(quoteId)?.slippagePct, ); // Handle different transaction states @@ -125,10 +121,9 @@ export const executeQuoteRoute: FastifyPluginAsync = async (fastify) => { }, async (request) => { try { - const { walletAddress, network, quoteId, gasPrice, maxGas } = - request.body as typeof ZeroXExecuteQuoteRequest._type; + const { walletAddress, network, quoteId } = request.body as typeof ZeroXExecuteQuoteRequest._type; - return await executeQuote(walletAddress, network, quoteId, gasPrice, maxGas); + return await executeQuote(walletAddress, network, quoteId); } catch (e) { if (e.statusCode) throw e; logger.error('Error executing 0x quote:', e); diff --git a/src/connectors/0x/router-routes/executeSwap.ts b/src/connectors/0x/router-routes/executeSwap.ts index 8e9aa34beb..c899b9df41 100644 --- a/src/connectors/0x/router-routes/executeSwap.ts +++ b/src/connectors/0x/router-routes/executeSwap.ts @@ -17,8 +17,6 @@ async function executeSwap( amount: number, side: 'BUY' | 'SELL', slippagePct: number = ZeroXConfig.config.slippagePct, - gasPrice?: string, - maxGas?: number, ): Promise { // Step 1: Get a fresh firm quote using the quoteSwap function const quoteResult = await quoteSwap( @@ -33,7 +31,7 @@ async function executeSwap( ); // Step 2: Execute the quote immediately using executeQuote function - const executeResult = await executeQuote(walletAddress, network, quoteResult.quoteId, gasPrice, maxGas); + const executeResult = await executeQuote(walletAddress, network, quoteResult.quoteId); return executeResult; } @@ -56,7 +54,7 @@ export const executeSwapRoute: FastifyPluginAsync = async (fastify) => { }, async (request) => { try { - const { walletAddress, network, baseToken, quoteToken, amount, side, slippagePct, gasPrice, maxGas } = + const { walletAddress, network, baseToken, quoteToken, amount, side, slippagePct } = request.body as typeof ZeroXExecuteSwapRequest._type; return await executeSwap( @@ -67,8 +65,6 @@ export const executeSwapRoute: FastifyPluginAsync = async (fastify) => { amount, side as 'BUY' | 'SELL', slippagePct, - gasPrice, - maxGas, ); } catch (e) { if (e.statusCode) throw e; diff --git a/src/connectors/0x/schemas.ts b/src/connectors/0x/schemas.ts index dbd0a0301f..cae5beaf87 100644 --- a/src/connectors/0x/schemas.ts +++ b/src/connectors/0x/schemas.ts @@ -145,17 +145,6 @@ export const ZeroXExecuteQuoteRequest = Type.Object({ description: 'ID of the quote to execute', examples: ['123e4567-e89b-12d3-a456-426614174000'], }), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [1000000], - }), - ), }); // 0x-specific execute-swap request (superset of base ExecuteSwapRequest) @@ -200,15 +189,4 @@ export const ZeroXExecuteSwapRequest = Type.Object({ examples: [1], }), ), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [300000], - }), - ), }); diff --git a/src/connectors/clmm-v3-utils.ts b/src/connectors/clmm-v3-utils.ts new file mode 100644 index 0000000000..de831a1340 --- /dev/null +++ b/src/connectors/clmm-v3-utils.ts @@ -0,0 +1,138 @@ +import { Contract } from '@ethersproject/contracts'; + +import { BinLiquidity } from '../schemas/clmm-schema'; + +/** + * Per-bin liquidity distribution around the current tick for a Uniswap-V3-style + * pool. Shared by the Uniswap and PancakeSwap connectors — PancakeSwap V3 is a + * Uniswap V3 fork with identical tick semantics, so the walk is the same; each + * connector passes its own SDK's math implementations rather than depending on + * the other's. + * + * V3 has no equivalent of Orca's "fetch all positions for pool" RPC — positions + * are NFTs on the position manager. Instead we walk the pool's per-tick + * liquidity profile directly: + * + * 1. Read `liquidityNet` at every bin boundary in the window via parallel + * `pool.ticks(tick)` reads (one eth_call each, fired with Promise.all). + * Ticks that have never been initialized return zeros — that's harmless. + * 2. Start with the pool's active L = pool.liquidity() in the bin that + * contains the current tick. Propagate L outward by adding/subtracting + * `liquidityNet` at each boundary crossed (per the V3 spec). + * 3. For each bin, convert L → (amount0, amount1) via + * getAmount{0,1}Delta(sqrtA, sqrtB, L, false), splitting at the pool's + * current sqrtPriceX96 when the bin straddles the active tick. + * 4. Map to base/quote using `isBaseToken0`, scale by decimals. + * + * Output shape mirrors Meteora's `pool-info.bins[]`. + */ + +// Structural types for the V3 SDK math, in native bigint. The SDKs disagree on +// their numeric type — @uniswap/v3-sdk is JSBI-based, @pancakeswap/v3-sdk uses +// native bigint — so each connector adapts its own SDK to this interface rather +// than one connector importing the other's math. +export interface V3TickMath { + getSqrtRatioAtTick(tick: number): bigint; +} + +export interface V3SqrtPriceMath { + getAmount0Delta(sqrtRatioAX96: bigint, sqrtRatioBX96: bigint, liquidity: bigint, roundUp: boolean): bigint; + getAmount1Delta(sqrtRatioAX96: bigint, sqrtRatioBX96: bigint, liquidity: bigint, roundUp: boolean): bigint; +} + +export async function computeV3BinDistribution(args: { + poolContract: Contract; + tickSpacing: number; + currentTick: number; + currentSqrtPriceX96: bigint; + activeLiquidity: bigint; + decimals0: number; + decimals1: number; + isBaseToken0: boolean; + binCount: number; + tickMath: V3TickMath; + sqrtPriceMath: V3SqrtPriceMath; +}): Promise { + const { + poolContract, + tickSpacing, + currentTick, + currentSqrtPriceX96, + activeLiquidity, + decimals0, + decimals1, + isBaseToken0, + binCount, + tickMath, + sqrtPriceMath, + } = args; + if (binCount <= 0) return []; + + const halfBins = Math.floor(binCount / 2); + const snapped = Math.floor(currentTick / tickSpacing) * tickSpacing; + const firstBinStart = snapped - halfBins * tickSpacing; + const boundaries: number[] = []; + for (let i = 0; i <= binCount; i++) { + boundaries.push(firstBinStart + i * tickSpacing); + } + + // Parallel reads of pool.ticks(tick) at each boundary. Non-initialized + // ticks return zeros which is the correct neutral element for liquidityNet. + const tickData = await Promise.all( + boundaries.map((tick) => poolContract.ticks(tick).catch(() => ({ liquidityNet: 0 }))), + ); + + const curIdx = Math.floor((currentTick - firstBinStart) / tickSpacing); + + // Propagate L outward from the current bin (V3 spec: crossing a tick going + // UP adds liquidityNet, going DOWN subtracts it). + const binLs: bigint[] = new Array(binCount); + binLs[curIdx] = activeLiquidity; + for (let i = curIdx + 1; i < binCount; i++) { + const net = BigInt(tickData[i].liquidityNet.toString()); + binLs[i] = binLs[i - 1] + net; + } + for (let i = curIdx - 1; i >= 0; i--) { + const net = BigInt(tickData[i + 1].liquidityNet.toString()); + binLs[i] = binLs[i + 1] - net; + } + + const scale0 = Math.pow(10, decimals0); + const scale1 = Math.pow(10, decimals1); + const zero = 0n; + const bins: BinLiquidity[] = []; + for (let i = 0; i < binCount; i++) { + const tickStart = boundaries[i]; + const tickEnd = boundaries[i + 1]; + const L = binLs[i]; + let amount0: bigint = zero; + let amount1: bigint = zero; + if (L > zero) { + const sqrtA = tickMath.getSqrtRatioAtTick(tickStart); + const sqrtB = tickMath.getSqrtRatioAtTick(tickEnd); + if (currentTick >= tickEnd) { + amount1 = sqrtPriceMath.getAmount1Delta(sqrtA, sqrtB, L, false); + } else if (currentTick < tickStart) { + amount0 = sqrtPriceMath.getAmount0Delta(sqrtA, sqrtB, L, false); + } else { + amount0 = sqrtPriceMath.getAmount0Delta(currentSqrtPriceX96, sqrtB, L, false); + amount1 = sqrtPriceMath.getAmount1Delta(sqrtA, currentSqrtPriceX96, L, false); + } + } + const amt0 = parseFloat(amount0.toString()) / scale0; + const amt1 = parseFloat(amount1.toString()) / scale1; + const baseTokenAmount = isBaseToken0 ? amt0 : amt1; + const quoteTokenAmount = isBaseToken0 ? amt1 : amt0; + + // Price at tickStart in human units (quote/base regardless of token order). + const rawT1PerT0 = Math.pow(1.0001, tickStart) * Math.pow(10, decimals0 - decimals1); + const price = isBaseToken0 ? rawT1PerT0 : 1 / rawT1PerT0; + bins.push({ + binId: tickStart, + price, + baseTokenAmount, + quoteTokenAmount, + }); + } + return bins; +} diff --git a/src/connectors/dflow/router-routes/executeQuote.ts b/src/connectors/dflow/router-routes/executeQuote.ts index 8cfba7e664..71b23c6b2f 100644 --- a/src/connectors/dflow/router-routes/executeQuote.ts +++ b/src/connectors/dflow/router-routes/executeQuote.ts @@ -22,7 +22,7 @@ export async function executeQuote( const solana = await Solana.getInstance(network); const dflow = await DFlow.getInstance(network); - const { inputToken, outputToken, quoteResponse } = cached; + const { inputToken, outputToken, quoteResponse, slippagePct } = cached; // Build the swap UNSIGNED with the wallet as authority, then sign + send via the // wallet-type-aware chokepoint (local keypair / Ledger) — no per-wallet-type branching here. @@ -32,18 +32,18 @@ export async function executeQuote( const transaction = await dflow.buildSwapTransactionUnsigned(walletAddress, quoteResponse); const { signature } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress); - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Re-fetch with retry; a landed-but-failed transaction throws instead of being + // misreported as confirmed or pending. + const txData = await solana.getConfirmedTransactionData(signature); const result = await solana.handleConfirmation( signature, - txData !== null, txData, inputToken.address, outputToken.address, walletAddress, + undefined, + slippagePct, ); // Remove quote from cache only after successful execution (confirmed) diff --git a/src/connectors/dflow/schemas.ts b/src/connectors/dflow/schemas.ts index 5398472c47..dfc71e9feb 100644 --- a/src/connectors/dflow/schemas.ts +++ b/src/connectors/dflow/schemas.ts @@ -50,7 +50,7 @@ export const DFlowQuoteSwapRequest = Type.Object({ approximateIfNoExactOut: Type.Optional( Type.Boolean({ description: - 'For BUY orders: DFlow is ExactIn-only, so BUYs are approximated via a sell-leg ExactIn quote. If false, BUY requests fail with a clear error.', + 'For BUY orders when the router has no ExactOut route: approximate via a sell-leg ExactIn quote instead of failing', default: true, }), ), @@ -161,7 +161,7 @@ export const DFlowExecuteSwapRequest = Type.Object({ approximateIfNoExactOut: Type.Optional( Type.Boolean({ description: - 'For BUY orders: DFlow is ExactIn-only, so BUYs are approximated via a sell-leg ExactIn quote. If false, BUY requests fail with a clear error.', + 'For BUY orders when the router has no ExactOut route: approximate via a sell-leg ExactIn quote instead of failing', default: true, }), ), diff --git a/src/connectors/jupiter/router-routes/executeQuote.ts b/src/connectors/jupiter/router-routes/executeQuote.ts index 6c289efcd8..dcd03103fa 100644 --- a/src/connectors/jupiter/router-routes/executeQuote.ts +++ b/src/connectors/jupiter/router-routes/executeQuote.ts @@ -12,8 +12,6 @@ export async function executeQuote( walletAddress: string, network: string, quoteId: string, - priorityLevel?: string, - maxLamports?: number, ): Promise { // Retrieve cached quote const quote = quoteCache.get(quoteId); @@ -38,27 +36,22 @@ export async function executeQuote( logger.info( `Executing quote ${quoteId} for ${inputToken.symbol} -> ${outputToken.symbol}, slippageBps=${quote.slippageBps}`, ); - const transaction = await jupiter.buildSwapTransactionForHardwareWallet( - walletAddress, - quote, - maxLamports, - priorityLevel, - ); + const transaction = await jupiter.buildSwapTransactionForHardwareWallet(walletAddress, quote); const { signature } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress); - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Re-fetch with retry; a landed-but-failed transaction throws instead of being + // misreported as confirmed or pending. + const txData = await solana.getConfirmedTransactionData(signature); // Handle confirmation status const result = await solana.handleConfirmation( signature, - txData !== null, txData, inputToken.address, outputToken.address, walletAddress, + undefined, + quote.slippageBps != null ? quote.slippageBps / 100 : undefined, ); // Remove quote from cache only after successful execution (confirmed) @@ -88,10 +81,9 @@ export const executeQuoteRoute: FastifyPluginAsync = async (fastify) => { }, async (request) => { try { - const { walletAddress, network, quoteId, priorityLevel, maxLamports } = - request.body as typeof JupiterExecuteQuoteRequest._type; + const { walletAddress, network, quoteId } = request.body as typeof JupiterExecuteQuoteRequest._type; - return await executeQuote(walletAddress, network, quoteId, priorityLevel, maxLamports); + return await executeQuote(walletAddress, network, quoteId); } catch (e) { if (e.statusCode) throw e; logger.error('Error executing quote:', e); diff --git a/src/connectors/jupiter/router-routes/executeSwap.ts b/src/connectors/jupiter/router-routes/executeSwap.ts index 078170b326..df7abbef47 100644 --- a/src/connectors/jupiter/router-routes/executeSwap.ts +++ b/src/connectors/jupiter/router-routes/executeSwap.ts @@ -17,8 +17,6 @@ async function executeSwap( amount: number, side: 'BUY' | 'SELL', slippagePct: number = JupiterConfig.config.slippagePct, - priorityLevel?: string, - maxLamports?: number, approximateIfNoExactOut: boolean = true, ): Promise { // Step 1: Get a fresh quote using the quoteSwap function @@ -29,19 +27,11 @@ async function executeSwap( amount, side, slippagePct, - undefined, - undefined, approximateIfNoExactOut, ); - // Step 2: Execute the quote immediately using executeQuote function - const executeResult = await executeQuote( - walletAddress, - network, - quoteResult.quoteId, - priorityLevel ?? JupiterConfig.config.priorityLevel, - maxLamports ?? JupiterConfig.config.maxLamports, - ); + // Step 2: Execute the quote immediately (priority fees come from the connector config) + const executeResult = await executeQuote(walletAddress, network, quoteResult.quoteId); return executeResult; } @@ -64,18 +54,8 @@ export const executeSwapRoute: FastifyPluginAsync = async (fastify) => { }, async (request) => { try { - const { - walletAddress, - network, - baseToken, - quoteToken, - amount, - side, - slippagePct, - priorityLevel, - maxLamports, - approximateIfNoExactOut, - } = request.body as typeof JupiterExecuteSwapRequest._type; + const { walletAddress, network, baseToken, quoteToken, amount, side, slippagePct, approximateIfNoExactOut } = + request.body as typeof JupiterExecuteSwapRequest._type; return await executeSwap( walletAddress, @@ -85,8 +65,6 @@ export const executeSwapRoute: FastifyPluginAsync = async (fastify) => { amount, side as 'BUY' | 'SELL', slippagePct, - priorityLevel, - maxLamports, approximateIfNoExactOut, ); } catch (e) { diff --git a/src/connectors/jupiter/router-routes/quoteSwap.ts b/src/connectors/jupiter/router-routes/quoteSwap.ts index a66809bf1e..9fc10f8178 100644 --- a/src/connectors/jupiter/router-routes/quoteSwap.ts +++ b/src/connectors/jupiter/router-routes/quoteSwap.ts @@ -20,8 +20,6 @@ export async function quoteSwap( amount: number, side: 'BUY' | 'SELL', slippagePct: number = JupiterConfig.config.slippagePct, - onlyDirectRoutes?: boolean, - restrictIntermediateTokens?: boolean, approximateIfNoExactOut: boolean = true, ): Promise> { const solana = await Solana.getInstance(network); @@ -43,9 +41,10 @@ export async function quoteSwap( logger.info(`Getting quote for ${amount} ${inputToken.symbol} -> ${outputToken.symbol}`); - const effectiveOnlyDirectRoutes = onlyDirectRoutes ?? JupiterConfig.config.onlyDirectRoutes; - const effectiveRestrictIntermediateTokens = - restrictIntermediateTokens ?? JupiterConfig.config.restrictIntermediateTokens; + // Routing policy comes from the connector config (conf/connectors/jupiter.yml), + // not per-request parameters. + const effectiveOnlyDirectRoutes = JupiterConfig.config.onlyDirectRoutes; + const effectiveRestrictIntermediateTokens = JupiterConfig.config.restrictIntermediateTokens; let quoteResponse; let approximation = false; @@ -105,8 +104,7 @@ export async function quoteSwap( } else { // Pass through Jupiter's error with context const tokenPair = `${sanitizeString(baseToken)} -> ${sanitizeString(quoteToken)}`; - const swapMode = side === 'BUY' ? 'ExactOut' : 'ExactIn'; - throw httpErrors.noRouteFound(`No route found for ${tokenPair} (${swapMode}). ${errorMessage}`); + throw httpErrors.noRouteFound(`No route found for ${tokenPair} (ExactIn). ${errorMessage}`); } } @@ -188,17 +186,8 @@ export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { }, async (request) => { try { - const { - network, - baseToken, - quoteToken, - amount, - side, - slippagePct, - onlyDirectRoutes, - restrictIntermediateTokens, - approximateIfNoExactOut, - } = request.query as typeof JupiterQuoteSwapRequest._type; + const { network, baseToken, quoteToken, amount, side, slippagePct, approximateIfNoExactOut } = + request.query as typeof JupiterQuoteSwapRequest._type; return await quoteSwap( network, @@ -207,8 +196,6 @@ export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { amount, side as 'BUY' | 'SELL', slippagePct, - onlyDirectRoutes, - restrictIntermediateTokens, approximateIfNoExactOut, ); } catch (e) { diff --git a/src/connectors/jupiter/schemas.ts b/src/connectors/jupiter/schemas.ts index e454b2ece5..b486227ff4 100644 --- a/src/connectors/jupiter/schemas.ts +++ b/src/connectors/jupiter/schemas.ts @@ -47,22 +47,10 @@ export const JupiterQuoteSwapRequest = Type.Object({ default: JupiterConfig.config.slippagePct, }), ), - restrictIntermediateTokens: Type.Optional( - Type.Boolean({ - description: 'Restrict routing through highly liquid intermediate tokens only for better price and stability', - default: JupiterConfig.config.restrictIntermediateTokens, - }), - ), - onlyDirectRoutes: Type.Optional( - Type.Boolean({ - description: 'Restrict routing to only go through 1 market', - default: JupiterConfig.config.onlyDirectRoutes, - }), - ), approximateIfNoExactOut: Type.Optional( Type.Boolean({ description: - 'For BUY orders when the pair has no ExactOut route: approximate via a sell-leg ExactIn quote instead of failing', + 'For BUY orders when the router has no ExactOut route: approximate via a sell-leg ExactIn quote instead of failing', default: true, }), ), @@ -167,19 +155,6 @@ export const JupiterExecuteQuoteRequest = Type.Object({ description: 'ID of the Jupiter quote to execute', examples: ['123e4567-e89b-12d3-a456-426614174000'], }), - priorityLevel: Type.Optional( - Type.String({ - description: 'Priority level for Solana transaction processing', - enum: ['medium', 'high', 'veryHigh'], - default: JupiterConfig.config.priorityLevel, - }), - ), - maxLamports: Type.Optional( - Type.Number({ - description: 'Maximum priority fee in lamports for Solana transaction', - default: [JupiterConfig.config.maxLamports], - }), - ), }); // Jupiter-specific execute-swap request (superset of base ExecuteSwapRequest) @@ -223,36 +198,11 @@ export const JupiterExecuteSwapRequest = Type.Object({ default: JupiterConfig.config.slippagePct, }), ), - restrictIntermediateTokens: Type.Optional( - Type.Boolean({ - description: 'Restrict routing through highly liquid intermediate tokens only for better price and stability', - default: JupiterConfig.config.restrictIntermediateTokens, - }), - ), - onlyDirectRoutes: Type.Optional( - Type.Boolean({ - description: 'Restrict routing to only go through 1 market', - default: JupiterConfig.config.onlyDirectRoutes, - }), - ), approximateIfNoExactOut: Type.Optional( Type.Boolean({ description: - 'For BUY orders when the pair has no ExactOut route: approximate via a sell-leg ExactIn quote instead of failing', + 'For BUY orders when the router has no ExactOut route: approximate via a sell-leg ExactIn quote instead of failing', default: true, }), ), - priorityLevel: Type.Optional( - Type.String({ - description: 'Priority level for Solana transaction processing', - enum: ['medium', 'high', 'veryHigh'], - default: JupiterConfig.config.priorityLevel, - }), - ), - maxLamports: Type.Optional( - Type.Number({ - description: 'Maximum priority fee in lamports for Solana transaction', - default: JupiterConfig.config.maxLamports, - }), - ), }); diff --git a/src/connectors/meteora/amm-routes/addLiquidity.ts b/src/connectors/meteora/amm-routes/addLiquidity.ts index 4decdb7f4d..1b6a8c03bc 100644 --- a/src/connectors/meteora/amm-routes/addLiquidity.ts +++ b/src/connectors/meteora/amm-routes/addLiquidity.ts @@ -84,10 +84,9 @@ export async function addLiquidity( } const { signature } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress, extraSigners); - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Retrying re-fetch; throws the shared landed-but-failed error if the transaction + // landed with an error, so txData existing below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); if (txData) { const { balanceChanges } = await solana.extractBalanceChangesAndFee(signature, walletAddress, [ diff --git a/src/connectors/meteora/amm-routes/createPool.ts b/src/connectors/meteora/amm-routes/createPool.ts index abe36ca9bf..8f66a9c08f 100644 --- a/src/connectors/meteora/amm-routes/createPool.ts +++ b/src/connectors/meteora/amm-routes/createPool.ts @@ -196,10 +196,9 @@ export async function createPool( }); const { signature } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress, [positionNft]); - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Retrying re-fetch; throws the shared landed-but-failed error if the transaction + // landed with an error, so txData existing below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); if (txData) { const { balanceChanges } = await solana.extractBalanceChangesAndFee(signature, walletAddress, [ diff --git a/src/connectors/meteora/amm-routes/executeSwap.ts b/src/connectors/meteora/amm-routes/executeSwap.ts index 8e12fe4031..e4766ba9f9 100644 --- a/src/connectors/meteora/amm-routes/executeSwap.ts +++ b/src/connectors/meteora/amm-routes/executeSwap.ts @@ -61,19 +61,18 @@ export async function executeSwap( }); const { signature } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress); - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Re-fetch with retry; a landed-but-failed transaction throws instead of being + // misreported as confirmed or pending. + const txData = await solana.getConfirmedTransactionData(signature); const result = await solana.handleConfirmation( signature, - txData !== null, txData, quote.inputMint.toBase58(), quote.outputMint.toBase58(), walletAddress, side, + slippagePct, ); return result as ExecuteSwapResponseType; diff --git a/src/connectors/meteora/amm-routes/removeLiquidity.ts b/src/connectors/meteora/amm-routes/removeLiquidity.ts index 6a8f15839b..a2ffc3c75b 100644 --- a/src/connectors/meteora/amm-routes/removeLiquidity.ts +++ b/src/connectors/meteora/amm-routes/removeLiquidity.ts @@ -96,10 +96,9 @@ export async function removeLiquidity( }); const { signature } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress); - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Retrying re-fetch; throws the shared landed-but-failed error if the transaction + // landed with an error, so txData existing below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); if (txData) { const { balanceChanges } = await solana.extractBalanceChangesAndFee(signature, walletAddress, [ diff --git a/src/connectors/meteora/clmm-routes/addLiquidity.ts b/src/connectors/meteora/clmm-routes/addLiquidity.ts index dd8f1061ed..e8da979c9a 100644 --- a/src/connectors/meteora/clmm-routes/addLiquidity.ts +++ b/src/connectors/meteora/clmm-routes/addLiquidity.ts @@ -123,10 +123,9 @@ export async function addLiquidity( const { signature, fee } = await solana.sendAndConfirmTransactionForWallet(addLiquidityTx, address); // Get transaction data for confirmation - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Retrying re-fetch; throws the shared landed-but-failed error if the transaction + // landed with an error, so txData existing below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); const confirmed = txData !== null; diff --git a/src/connectors/meteora/clmm-routes/closePosition.ts b/src/connectors/meteora/clmm-routes/closePosition.ts index 8351b56844..7b4f225851 100644 --- a/src/connectors/meteora/clmm-routes/closePosition.ts +++ b/src/connectors/meteora/clmm-routes/closePosition.ts @@ -84,10 +84,9 @@ export async function closePosition( const signature = lastSignature; // Get transaction data for confirmation - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Retrying re-fetch; throws the shared landed-but-failed error if the transaction + // landed with an error, so txData existing below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); const confirmed = txData !== null; diff --git a/src/connectors/meteora/clmm-routes/collectFees.ts b/src/connectors/meteora/clmm-routes/collectFees.ts index 9f1d347161..75f87899a0 100644 --- a/src/connectors/meteora/clmm-routes/collectFees.ts +++ b/src/connectors/meteora/clmm-routes/collectFees.ts @@ -72,10 +72,9 @@ export async function collectFees( const fee = totalFee; // Get transaction data for confirmation - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Retrying re-fetch; throws the shared landed-but-failed error if the transaction + // landed with an error, so txData existing below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); const confirmed = txData !== null; diff --git a/src/connectors/meteora/clmm-routes/createPool.ts b/src/connectors/meteora/clmm-routes/createPool.ts index c301d9bc46..d1007916f7 100644 --- a/src/connectors/meteora/clmm-routes/createPool.ts +++ b/src/connectors/meteora/clmm-routes/createPool.ts @@ -9,7 +9,7 @@ import BN from 'bn.js'; import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { CreatePoolResponse, CreatePoolResponseType } from '../../../schemas/amm-schema'; +import { CreatePoolResponse, CreatePoolResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { sanitizeErrorMessage } from '../../../services/sanitize'; @@ -195,10 +195,9 @@ export async function createPool( } const { signature } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress); - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Retrying re-fetch; throws the shared landed-but-failed error if the transaction + // landed with an error, so txData existing below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); if (txData) { return { @@ -208,8 +207,6 @@ export async function createPool( price: seedPrice, data: { fee: txData.meta.fee / 1e9, - baseTokenAmountAdded: 0, - quoteTokenAmountAdded: 0, }, }; } diff --git a/src/connectors/meteora/clmm-routes/executeSwap.ts b/src/connectors/meteora/clmm-routes/executeSwap.ts index b35f741ef9..521ac013c7 100644 --- a/src/connectors/meteora/clmm-routes/executeSwap.ts +++ b/src/connectors/meteora/clmm-routes/executeSwap.ts @@ -91,11 +91,10 @@ export async function executeSwap( logger.info(`Transaction sent with signature: ${signature}`); - // Get transaction data for confirmation - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Get transaction data for confirmation. The retrying fetch throws the shared + // landed-but-failed error when the transaction landed with an error, so existence of + // txData below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); const confirmed = txData !== null; @@ -135,6 +134,7 @@ export async function executeSwap( fee: txFee, baseTokenBalanceChange, quoteTokenBalanceChange, + slippagePct, }, }; } else { diff --git a/src/connectors/meteora/clmm-routes/openPosition.ts b/src/connectors/meteora/clmm-routes/openPosition.ts index d60340c678..5c6f443b67 100644 --- a/src/connectors/meteora/clmm-routes/openPosition.ts +++ b/src/connectors/meteora/clmm-routes/openPosition.ts @@ -174,10 +174,9 @@ export async function openPosition( ]); // Get transaction data for confirmation - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Retrying re-fetch; throws the shared landed-but-failed error if the transaction + // landed with an error, so txData existing below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); const confirmed = txData !== null; diff --git a/src/connectors/meteora/clmm-routes/removeLiquidity.ts b/src/connectors/meteora/clmm-routes/removeLiquidity.ts index 38bee36e56..b468299de8 100644 --- a/src/connectors/meteora/clmm-routes/removeLiquidity.ts +++ b/src/connectors/meteora/clmm-routes/removeLiquidity.ts @@ -97,10 +97,9 @@ export async function removeLiquidity( const fee = totalFee; // Get transaction data for confirmation - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Retrying re-fetch; throws the shared landed-but-failed error if the transaction + // landed with an error, so txData existing below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); const confirmed = txData !== null; @@ -164,11 +163,11 @@ export const removeLiquidityRoute: FastifyPluginAsync = async (fastify) => { }, async (request) => { try { - const { network, walletAddress, positionAddress, liquidityPct } = request.body; + const { network, walletAddress, positionAddress, percentageToRemove } = request.body; const networkToUse = network; - return await removeLiquidity(networkToUse, walletAddress, positionAddress, liquidityPct); + return await removeLiquidity(networkToUse, walletAddress, positionAddress, percentageToRemove); } catch (e) { logger.error(e); if (e.statusCode) { diff --git a/src/connectors/meteora/schemas.ts b/src/connectors/meteora/schemas.ts index 14d0d2ebb0..349681d449 100644 --- a/src/connectors/meteora/schemas.ts +++ b/src/connectors/meteora/schemas.ts @@ -301,7 +301,7 @@ export const MeteoraClmmRemoveLiquidityRequest = Type.Object({ description: 'Position NFT address', examples: [''], }), - liquidityPct: Type.Optional( + percentageToRemove: Type.Optional( Type.Number({ minimum: 0, maximum: 100, diff --git a/src/connectors/okx/router-routes/executeQuote.ts b/src/connectors/okx/router-routes/executeQuote.ts index a99b018951..d66d14dfe0 100644 --- a/src/connectors/okx/router-routes/executeQuote.ts +++ b/src/connectors/okx/router-routes/executeQuote.ts @@ -41,18 +41,18 @@ export async function executeQuote( ); const { signature } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress); - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Re-fetch with retry; a landed-but-failed transaction throws instead of being + // misreported as confirmed or pending. + const txData = await solana.getConfirmedTransactionData(signature); const result = await solana.handleConfirmation( signature, - txData !== null, txData, inputToken.address, outputToken.address, walletAddress, + undefined, + slippagePct, ); // Remove quote from cache only after successful execution (confirmed) diff --git a/src/connectors/okx/schemas.ts b/src/connectors/okx/schemas.ts index 7347033f39..cbb70a5fb5 100644 --- a/src/connectors/okx/schemas.ts +++ b/src/connectors/okx/schemas.ts @@ -50,7 +50,7 @@ export const OkxQuoteSwapRequest = Type.Object({ approximateIfNoExactOut: Type.Optional( Type.Boolean({ description: - 'For BUY orders when OKX cannot serve an exactOut quote: approximate via a sell-leg exactIn quote instead of failing', + 'For BUY orders when the router has no ExactOut route: approximate via a sell-leg ExactIn quote instead of failing', default: true, }), ), @@ -161,7 +161,7 @@ export const OkxExecuteSwapRequest = Type.Object({ approximateIfNoExactOut: Type.Optional( Type.Boolean({ description: - 'For BUY orders when OKX cannot serve an exactOut quote: approximate via a sell-leg exactIn quote instead of failing', + 'For BUY orders when the router has no ExactOut route: approximate via a sell-leg ExactIn quote instead of failing', default: true, }), ), diff --git a/src/connectors/orca/clmm-routes/addLiquidity.ts b/src/connectors/orca/clmm-routes/addLiquidity.ts index 24b388b11e..5fb60de375 100644 --- a/src/connectors/orca/clmm-routes/addLiquidity.ts +++ b/src/connectors/orca/clmm-routes/addLiquidity.ts @@ -16,6 +16,7 @@ import { AddLiquidityResponse, AddLiquidityResponseType } from '../../../schemas import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { Orca } from '../orca'; +import { OrcaConfig } from '../orca.config'; import { getCurrentTransferFee } from '../orca.position'; import { buildOrcaTransaction, createOrcaAuthority } from '../orca.sdk'; import { OrcaClmmAddLiquidityRequest } from '../schemas'; @@ -26,7 +27,7 @@ export async function addLiquidity( positionAddress: string, baseTokenAmount: number, quoteTokenAmount: number, - slippagePct: number, + slippagePct: number = OrcaConfig.config.slippagePct ?? 1, ): Promise { if ((!baseTokenAmount || baseTokenAmount <= 0) && (!quoteTokenAmount || quoteTokenAmount <= 0)) { throw httpErrors.badRequest('At least one token amount must be provided and greater than 0'); @@ -151,14 +152,8 @@ export const addLiquidityRoute: FastifyPluginAsync = async (fastify) => { }, async (request) => { try { - const { - walletAddress, - positionAddress, - baseTokenAmount, - quoteTokenAmount, - slippagePct = 1, - network, - } = request.body; + const { walletAddress, positionAddress, baseTokenAmount, quoteTokenAmount, slippagePct, network } = + request.body; return await addLiquidity( network, walletAddress, diff --git a/src/connectors/orca/clmm-routes/closePosition.ts b/src/connectors/orca/clmm-routes/closePosition.ts index 17cb745e6e..c6b2584569 100644 --- a/src/connectors/orca/clmm-routes/closePosition.ts +++ b/src/connectors/orca/clmm-routes/closePosition.ts @@ -54,10 +54,9 @@ export async function closePosition( const transaction = buildOrcaTransaction(closeResult.instructions, walletAddress); const { signature, fee } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress); - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Retrying re-fetch; throws the shared landed-but-failed error if the transaction + // landed with an error, so txData existing below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); let positionRentRefunded = 0; if (txData) { diff --git a/src/connectors/orca/clmm-routes/createPool.ts b/src/connectors/orca/clmm-routes/createPool.ts index e2a06b0338..560bcf74b4 100644 --- a/src/connectors/orca/clmm-routes/createPool.ts +++ b/src/connectors/orca/clmm-routes/createPool.ts @@ -5,7 +5,7 @@ import { Keypair, PublicKey } from '@solana/web3.js'; import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { CreatePoolResponse, CreatePoolResponseType } from '../../../schemas/amm-schema'; +import { CreatePoolResponse, CreatePoolResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { sanitizeErrorMessage } from '../../../services/sanitize'; @@ -144,10 +144,9 @@ export async function createPool( const transaction = buildOrcaTransaction(instructions, walletAddress); const { signature } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress, extraSigners); - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Retrying re-fetch; throws the shared landed-but-failed error if the transaction + // landed with an error, so txData existing below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); if (txData) { return { @@ -157,9 +156,6 @@ export async function createPool( price: seedPrice, data: { fee: txData.meta.fee / 1e9, - // Pool created + initialized only — no liquidity/position seeded. - baseTokenAmountAdded: 0, - quoteTokenAmountAdded: 0, }, }; } diff --git a/src/connectors/orca/clmm-routes/executeSwap.ts b/src/connectors/orca/clmm-routes/executeSwap.ts index 970f2c64e1..6595f14a17 100644 --- a/src/connectors/orca/clmm-routes/executeSwap.ts +++ b/src/connectors/orca/clmm-routes/executeSwap.ts @@ -10,6 +10,7 @@ import { ExecuteSwapResponseType, ExecuteSwapResponse } from '../../../schemas/c import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { Orca } from '../orca'; +import { OrcaConfig } from '../orca.config'; import { buildOrcaTransaction, createOrcaAuthority } from '../orca.sdk'; import { OrcaClmmExecuteSwapRequest, OrcaClmmExecuteSwapRequestType } from '../schemas'; @@ -24,7 +25,7 @@ export async function executeSwap( baseTokenIdentifier: string, side: 'BUY' | 'SELL', amount: number, - slippagePct: number = 1, + slippagePct: number = OrcaConfig.config.slippagePct ?? 1, ): Promise { const solana = await Solana.getInstance(network); const orca = await Orca.getInstance(network); @@ -117,6 +118,7 @@ export async function executeSwap( fee, baseTokenBalanceChange, quoteTokenBalanceChange, + slippagePct, }, }; } diff --git a/src/connectors/orca/clmm-routes/openPosition.ts b/src/connectors/orca/clmm-routes/openPosition.ts index a55ad0aa4c..8301ab4c3f 100644 --- a/src/connectors/orca/clmm-routes/openPosition.ts +++ b/src/connectors/orca/clmm-routes/openPosition.ts @@ -27,6 +27,7 @@ import { OpenPositionResponse, OpenPositionResponseType } from '../../../schemas import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { Orca } from '../orca'; +import { OrcaConfig } from '../orca.config'; import { getCurrentTransferFee } from '../orca.position'; import { buildOrcaTransaction, createOrcaAuthority, replaceOrcaInstructionAccounts } from '../orca.sdk'; import { extractInnerTransferAmounts } from '../orca.utils'; @@ -61,7 +62,7 @@ export async function openPosition( throw httpErrors.badRequest('Calculated tick indices are invalid (lower >= upper)'); } - const slippageBps = Math.round((slippagePct || 1) * 100); + const slippageBps = Math.round((slippagePct ?? OrcaConfig.config.slippagePct ?? 1) * 100); const baseAmount = BigInt(Math.floor((baseTokenAmount || 0) * 10 ** mintA.data.decimals)); const quoteAmount = BigInt(Math.floor((quoteTokenAmount || 0) * 10 ** mintB.data.decimals)); const shouldAddLiquidity = baseAmount > 0n || quoteAmount > 0n; @@ -218,10 +219,9 @@ export async function openPosition( const { signature, fee } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress, [ positionMintKeypair, ]); - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Retrying re-fetch; throws the shared landed-but-failed error if the transaction + // landed with an error, so txData existing below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); let positionRent = 0; let baseTokenAmountAdded = liquidityQuote ? Number(liquidityQuote.tokenEstA) / 10 ** mintA.data.decimals : 0; diff --git a/src/connectors/orca/clmm-routes/quotePosition.ts b/src/connectors/orca/clmm-routes/quotePosition.ts index 98a94e45c4..b6ac7987e3 100644 --- a/src/connectors/orca/clmm-routes/quotePosition.ts +++ b/src/connectors/orca/clmm-routes/quotePosition.ts @@ -5,6 +5,7 @@ import { QuotePositionResponseType, QuotePositionResponse } from '../../../schem import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { Orca } from '../orca'; +import { OrcaConfig } from '../orca.config'; import { quotePosition as getQuotePosition } from '../orca.utils'; import { OrcaClmmQuotePositionRequest } from '../schemas'; @@ -15,7 +16,7 @@ export async function quotePosition( poolAddress: string, baseTokenAmount?: number, quoteTokenAmount?: number, - slippagePct: number = 1, + slippagePct: number = OrcaConfig.config.slippagePct ?? 1, ): Promise { const orca = await Orca.getInstance(network); diff --git a/src/connectors/orca/clmm-routes/quoteSwap.ts b/src/connectors/orca/clmm-routes/quoteSwap.ts index e2c200d041..54aa7b9a40 100644 --- a/src/connectors/orca/clmm-routes/quoteSwap.ts +++ b/src/connectors/orca/clmm-routes/quoteSwap.ts @@ -5,6 +5,7 @@ import { QuoteSwapResponseType, QuoteSwapResponse } from '../../../schemas/clmm- import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { Orca } from '../orca'; +import { OrcaConfig } from '../orca.config'; import { getOrcaSwapQuote } from '../orca.utils'; import { OrcaClmmQuoteSwapRequest, OrcaClmmQuoteSwapRequestType } from '../schemas'; @@ -15,7 +16,7 @@ export async function getRawSwapQuote( amount: number, side: 'BUY' | 'SELL', poolAddress: string, - slippagePct: number = 1, + slippagePct: number = OrcaConfig.config.slippagePct ?? 1, ) { const solana = await Solana.getInstance(network); const orca = await Orca.getInstance(network); @@ -50,7 +51,7 @@ async function formatSwapQuote( amount: number, side: 'BUY' | 'SELL', poolAddress: string, - slippagePct: number = 1, + slippagePct: number = OrcaConfig.config.slippagePct ?? 1, ): Promise { const quote = await getRawSwapQuote( network, diff --git a/src/connectors/orca/clmm-routes/removeLiquidity.ts b/src/connectors/orca/clmm-routes/removeLiquidity.ts index 623b12e0a4..83dd7e2bdc 100644 --- a/src/connectors/orca/clmm-routes/removeLiquidity.ts +++ b/src/connectors/orca/clmm-routes/removeLiquidity.ts @@ -12,6 +12,7 @@ import { RemoveLiquidityResponse, RemoveLiquidityResponseType } from '../../../s import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { Orca } from '../orca'; +import { OrcaConfig } from '../orca.config'; import { buildOrcaTransaction, createOrcaAuthority } from '../orca.sdk'; import { OrcaClmmRemoveLiquidityRequest } from '../schemas'; @@ -19,11 +20,11 @@ export async function removeLiquidity( network: string, walletAddress: string, positionAddress: string, - liquidityPct: number, - slippagePct: number, + percentageToRemove: number, + slippagePct: number = OrcaConfig.config.slippagePct ?? 1, ): Promise { - if (liquidityPct <= 0 || liquidityPct > 100) { - throw httpErrors.badRequest('liquidityPct must be between 0 and 100'); + if (percentageToRemove <= 0 || percentageToRemove > 100) { + throw httpErrors.badRequest('percentageToRemove must be between 0 and 100'); } const solana = await Solana.getInstance(network); @@ -33,7 +34,7 @@ export async function removeLiquidity( const whirlpool = await fetchWhirlpool(orca.solanaKitRpc, position.data.whirlpool); const [mintA, mintB] = await fetchAllMint(orca.solanaKitRpc, [whirlpool.data.tokenMintA, whirlpool.data.tokenMintB]); const liquidityAmount = BigInt( - new Decimal(position.data.liquidity.toString()).mul(liquidityPct).div(100).floor().toFixed(0), + new Decimal(position.data.liquidity.toString()).mul(percentageToRemove).div(100).floor().toFixed(0), ); if (liquidityAmount <= 0n || liquidityAmount > position.data.liquidity) { @@ -51,7 +52,7 @@ export async function removeLiquidity( }, ); logger.info( - `Removing ${liquidityPct}% liquidity, estimated: ` + + `Removing ${percentageToRemove}% liquidity, estimated: ` + `${(Number(result.quote.tokenEstA) / 10 ** mintA.data.decimals).toFixed(6)} tokenA, ` + `${(Number(result.quote.tokenEstB) / 10 ** mintB.data.decimals).toFixed(6)} tokenB`, ); @@ -99,8 +100,8 @@ export const removeLiquidityRoute: FastifyPluginAsync = async (fastify) => { }, async (request) => { try { - const { walletAddress, positionAddress, liquidityPct = 100, slippagePct = 1, network } = request.body; - return await removeLiquidity(network, walletAddress, positionAddress, liquidityPct, slippagePct); + const { walletAddress, positionAddress, percentageToRemove = 100, slippagePct, network } = request.body; + return await removeLiquidity(network, walletAddress, positionAddress, percentageToRemove, slippagePct); } catch (error) { logger.error(error); if (error.statusCode) throw error; diff --git a/src/connectors/orca/orca.ts b/src/connectors/orca/orca.ts index 444c94fa6d..ae7d5c354f 100644 --- a/src/connectors/orca/orca.ts +++ b/src/connectors/orca/orca.ts @@ -1,5 +1,5 @@ import { fetchPositionsForOwner, setNativeMintWrappingStrategy, type WhirlpoolDeployment } from '@orca-so/whirlpools'; -import { fetchWhirlpool, fetchPosition } from '@orca-so/whirlpools-client'; +import { fetchWhirlpool, fetchPosition, fetchMaybePosition } from '@orca-so/whirlpools-client'; import { address, createSolanaRpc, mainnet, devnet } from '@solana/kit'; import { PublicKey } from '@solana/web3.js'; @@ -369,12 +369,16 @@ export class Orca { throw httpErrors.badRequest(`Invalid position address: ${positionAddress}`); } - try { - const positionInfo = await getPositionDetails(this.solanaKitRpc, positionAddress, this.deployment); - return positionInfo; - } catch (error) { - logger.error('Error getting position info:', error); + // null means "the position account does not exist" — a definitive on-chain + // answer that callers (position-info 404, close reconciliation in + // Hummingbot's LP executor) treat as "position closed". Anything else must + // throw: swallowing a transient RPC failure here serves "position closed" + // for a network blip, and an executor acting on that abandons a live, + // funded position while reporting success. + const maybePosition = await fetchMaybePosition(this.solanaKitRpc, address(positionAddress)); + if (!maybePosition.exists) { return null; } + return await getPositionDetails(this.solanaKitRpc, positionAddress, this.deployment); } } diff --git a/src/connectors/orca/schemas.ts b/src/connectors/orca/schemas.ts index e6c69946ed..1ae92d1b91 100644 --- a/src/connectors/orca/schemas.ts +++ b/src/connectors/orca/schemas.ts @@ -343,7 +343,7 @@ export const OrcaClmmRemoveLiquidityRequest = Type.Object({ description: 'Position NFT address', examples: [''], }), - liquidityPct: Type.Optional( + percentageToRemove: Type.Optional( Type.Number({ minimum: 0, maximum: 100, @@ -357,7 +357,7 @@ export const OrcaClmmRemoveLiquidityRequest = Type.Object({ minimum: 0, maximum: 100, description: 'Maximum acceptable slippage percentage', - default: 1, + default: OrcaConfig.config.slippagePct ?? 1, examples: [1], }), ), diff --git a/src/connectors/pancakeswap-sol/clmm-routes/addLiquidity.ts b/src/connectors/pancakeswap-sol/clmm-routes/addLiquidity.ts index e9c881a774..04b64e0911 100644 --- a/src/connectors/pancakeswap-sol/clmm-routes/addLiquidity.ts +++ b/src/connectors/pancakeswap-sol/clmm-routes/addLiquidity.ts @@ -130,6 +130,10 @@ export async function addLiquidity( }; } + // A landed-but-failed transaction is terminal: fail loudly instead of returning + // PENDING (callers would poll forever). Genuinely-not-landed keeps the pending shape. + await solana.throwIfLandedWithError(signature, txData); + return { signature, status: 0, // PENDING diff --git a/src/connectors/pancakeswap-sol/clmm-routes/closePosition.ts b/src/connectors/pancakeswap-sol/clmm-routes/closePosition.ts index a945fc6bb6..9b31efa55d 100644 --- a/src/connectors/pancakeswap-sol/clmm-routes/closePosition.ts +++ b/src/connectors/pancakeswap-sol/clmm-routes/closePosition.ts @@ -129,6 +129,10 @@ export async function closePosition( }; } + // A landed-but-failed transaction is terminal: fail loudly instead of returning + // PENDING (callers would poll forever). Genuinely-not-landed keeps the pending shape. + await solana.throwIfLandedWithError(signature, txData); + return { signature, status: 0, // PENDING diff --git a/src/connectors/pancakeswap-sol/clmm-routes/collectFees.ts b/src/connectors/pancakeswap-sol/clmm-routes/collectFees.ts index b8bd781e1c..46d900fe71 100644 --- a/src/connectors/pancakeswap-sol/clmm-routes/collectFees.ts +++ b/src/connectors/pancakeswap-sol/clmm-routes/collectFees.ts @@ -1,46 +1,107 @@ import { Static } from '@sinclair/typebox'; +import { PublicKey } from '@solana/web3.js'; +import BN from 'bn.js'; import { FastifyPluginAsync } from 'fastify'; +import { Solana } from '../../../chains/solana/solana'; import { CollectFeesResponse, CollectFeesResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; +import { PancakeswapSol } from '../pancakeswap-sol'; +import { buildRemoveLiquidityTransaction } from '../pancakeswap-sol.transactions'; import { PancakeswapSolClmmCollectFeesRequest } from '../schemas'; -import { removeLiquidity } from './removeLiquidity'; - +/** + * Collect accumulated fees from a position WITHOUT touching its liquidity. + * + * The PancakeSwap Solana CLMM program (a Raydium CLMM fork) has no owner-facing + * "collect fees" instruction — like Raydium, fees (and rewards) owed to a position are + * transferred by `decrease_liquidity_v2`. Calling it with `liquidity = 0` collects the + * owed fees while leaving the position's liquidity intact. + */ async function collectFees( network: string, walletAddress: string, positionAddress: string, ): Promise { - logger.info(`Collecting fees from position ${positionAddress} by removing 1% liquidity`); + const solana = await Solana.getInstance(network); + const pancakeswapSol = await PancakeswapSol.getInstance(network); + + const positionInfo = await pancakeswapSol.getPositionInfo(positionAddress); + if (!positionInfo) { + throw httpErrors.notFound(`Position not found: ${positionAddress}`); + } + + const baseToken = await solana.getToken(positionInfo.baseTokenAddress); + const quoteToken = await solana.getToken(positionInfo.quoteTokenAddress); + if (!baseToken || !quoteToken) { + throw httpErrors.notFound('Token information not found'); + } + + logger.info(`Collecting fees from position ${positionAddress} via zero-liquidity decrease`); + + const wallet = await solana.getWallet(walletAddress); + const walletPubkey = new PublicKey(walletAddress); + const positionNftMint = new PublicKey(positionAddress); + + // Get priority fee + const priorityFeeInLamports = await solana.estimateGasPrice(); + const priorityFeePerCU = Math.floor(priorityFeeInLamports * 1e6); + + // decrease_liquidity_v2 with liquidity = 0 transfers the owed fees (and rewards) + // without removing any liquidity. + const transaction = await buildRemoveLiquidityTransaction( + solana, + positionNftMint, + walletPubkey, + new BN(0), // liquidity: collect fees only + new BN(0), // amount0Min + new BN(0), // amount1Min + 600000, // Compute units + priorityFeePerCU, + ); + + // Sign and send + transaction.sign([wallet]); + await solana.simulateWithErrorHandling(transaction); + + const { confirmed, signature, txData } = await solana.sendAndConfirmRawTransaction(transaction); + + if (confirmed && txData) { + const totalFee = txData.meta.fee; + + // No liquidity was removed, so the position tokens received are exactly the fees. + const { balanceChanges } = await solana.extractBalanceChangesAndFee(signature, walletAddress, [ + baseToken.address, + quoteToken.address, + ]); - // Use the clever Raydium approach: remove 1% of liquidity to collect fees - // This withdraws a tiny amount of liquidity + all accumulated fees - const removeLiquidityResponse = await removeLiquidity(network, walletAddress, positionAddress, 1); + const baseFeeCollected = Math.abs(balanceChanges[0]); + const quoteFeeCollected = Math.abs(balanceChanges[1]); + + logger.info( + `Fees collected from position ${positionAddress}: ${baseFeeCollected.toFixed(6)} ${baseToken.symbol}, ` + + `${quoteFeeCollected.toFixed(6)} ${quoteToken.symbol}`, + ); - if (removeLiquidityResponse.status !== 1 || !removeLiquidityResponse.data) { return { - signature: removeLiquidityResponse.signature, - status: removeLiquidityResponse.status, + signature, + status: 1, // CONFIRMED + data: { + fee: totalFee / 1e9, + baseFeeAmountCollected: baseFeeCollected, + quoteFeeAmountCollected: quoteFeeCollected, + }, }; } - // The fees are included in the amounts removed - // Since we only removed 1%, most of the tokens received are fees - const baseFeeCollected = removeLiquidityResponse.data.baseTokenAmountRemoved; - const quoteFeeCollected = removeLiquidityResponse.data.quoteTokenAmountRemoved; - - logger.info(`Fees collected. Base: ${baseFeeCollected}, Quote: ${quoteFeeCollected}`); + // A landed-but-failed transaction is terminal: fail loudly instead of returning + // PENDING (callers would poll forever). Genuinely-not-landed keeps the pending shape. + await solana.throwIfLandedWithError(signature, txData); return { - signature: removeLiquidityResponse.signature, - status: 1, // CONFIRMED - data: { - fee: removeLiquidityResponse.data.fee, - baseFeeAmountCollected: baseFeeCollected, - quoteFeeAmountCollected: quoteFeeCollected, - }, + signature, + status: 0, // PENDING }; } @@ -54,7 +115,8 @@ export const collectFeesRoute: FastifyPluginAsync = async (fastify) => { '/collect-fees', { schema: { - description: 'Collect accumulated fees from a PancakeSwap Solana CLMM position (removes 1% liquidity)', + description: + 'Collect accumulated fees from a PancakeSwap Solana CLMM position (zero-liquidity decrease; liquidity is not touched)', tags: ['/connector/pancakeswap-sol'], body: PancakeswapSolClmmCollectFeesRequest, response: { diff --git a/src/connectors/pancakeswap-sol/clmm-routes/createPool.ts b/src/connectors/pancakeswap-sol/clmm-routes/createPool.ts index 6660c7eb5f..c8578909e2 100644 --- a/src/connectors/pancakeswap-sol/clmm-routes/createPool.ts +++ b/src/connectors/pancakeswap-sol/clmm-routes/createPool.ts @@ -5,7 +5,7 @@ import BN from 'bn.js'; import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { CreatePoolResponse, CreatePoolResponseType } from '../../../schemas/amm-schema'; +import { CreatePoolResponse, CreatePoolResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { sanitizeErrorMessage } from '../../../services/sanitize'; @@ -67,12 +67,44 @@ async function fetchMarketPrice(network: string, baseToken: string, quoteToken: return quote.amountOut / quote.amountIn; // quote token per base token } +/** + * Derive the amm_config PDA for a fee-config index. The program (a Raydium CLMM + * fork) seeds it with ["amm_config", index] — index encoded big-endian, the same + * Raydium convention this connector's tick_array PDAs use. The little-endian + * (plain Anchor/borsh) form is tried as a fallback before failing. + */ +async function resolveAmmConfigByIndex(solana: Solana, ammConfigIndex: number): Promise { + if (!Number.isInteger(ammConfigIndex) || ammConfigIndex < 0 || ammConfigIndex > 0xffff) { + throw httpErrors.badRequest(`ammConfigIndex must be an integer in [0, 65535], got ${ammConfigIndex}`); + } + const candidates: PublicKey[] = []; + for (const endian of ['BE', 'LE'] as const) { + const indexBuffer = Buffer.alloc(2); + if (endian === 'BE') indexBuffer.writeUInt16BE(ammConfigIndex, 0); + else indexBuffer.writeUInt16LE(ammConfigIndex, 0); + const [pda] = PublicKey.findProgramAddressSync( + [Buffer.from('amm_config'), indexBuffer], + PANCAKESWAP_CLMM_PROGRAM_ID, + ); + candidates.push(pda); + } + for (const pda of candidates) { + const info = await solana.connection.getAccountInfo(pda); + if (info && info.owner.equals(PANCAKESWAP_CLMM_PROGRAM_ID)) { + return pda; + } + } + throw httpErrors.badRequest( + `No amm_config account found on-chain for index ${ammConfigIndex} ` + + `(tried ${candidates.map((c) => c.toBase58()).join(', ')})`, + ); +} + /** * Create and initialize (but do NOT seed a position for) a PancakeSwap Solana CLMM pool. * - * @param ammConfig Base58 address of an existing on-chain amm_config account for the desired fee tier. - * Required — there is no API to enumerate amm_config accounts, so the caller supplies - * the config for the fee tier they want (mirrors Meteora DAMM v2 configAddress). + * @param ammConfigIndex Fee-config index; resolves to the program's amm_config PDA + * (["amm_config", index]) and is validated on-chain. Default 0. */ export async function createPool( network: string, @@ -80,31 +112,13 @@ export async function createPool( baseToken: string, quoteToken: string, initialPrice?: number, - ammConfig?: string, + ammConfigIndex: number = 0, ): Promise { const solana = await Solana.getInstance(network); // Ensure the connector singleton is initialized (mirrors the other pancakeswap-sol routes). await PancakeswapSol.getInstance(network); - // Validate the required amm_config address and confirm it exists on-chain. - if (!ammConfig) { - throw httpErrors.badRequest('ammConfig is required: pass the address of an existing on-chain amm_config account'); - } - let ammConfigPubkey: PublicKey; - try { - ammConfigPubkey = new PublicKey(ammConfig); - } catch { - throw httpErrors.badRequest(sanitizeErrorMessage('Invalid ammConfig address: {}', ammConfig)); - } - const ammConfigInfo = await solana.connection.getAccountInfo(ammConfigPubkey); - if (!ammConfigInfo) { - throw httpErrors.badRequest(`amm_config account not found: ${ammConfigPubkey.toBase58()}`); - } - if (!ammConfigInfo.owner.equals(PANCAKESWAP_CLMM_PROGRAM_ID)) { - throw httpErrors.badRequest( - `amm_config ${ammConfigPubkey.toBase58()} is not owned by the PancakeSwap CLMM program`, - ); - } + const ammConfigPubkey = await resolveAmmConfigByIndex(solana, ammConfigIndex); // Resolve mints, decimals and token programs from authoritative on-chain data. const baseMint = await resolveMint(solana, baseToken); @@ -205,13 +219,14 @@ export async function createPool( price: seedPrice, data: { fee: txData.meta.fee / 1e9, - // Pool created + initialized only — no liquidity/position seeded. - baseTokenAmountAdded: 0, - quoteTokenAmountAdded: 0, }, }; } + // A landed-but-failed transaction is terminal: fail loudly instead of returning + // PENDING (callers would poll forever). Genuinely-not-landed keeps the pending shape. + await solana.throwIfLandedWithError(signature, txData); + return { signature, status: 0, poolAddress, price: seedPrice }; // PENDING } @@ -240,9 +255,9 @@ export const createPoolRoute: FastifyPluginAsync = async (fastify) => { baseToken, quoteToken, initialPrice, - ammConfig, + ammConfigIndex, } = request.body; - return await createPool(network, walletAddress!, baseToken, quoteToken, initialPrice, ammConfig); + return await createPool(network, walletAddress!, baseToken, quoteToken, initialPrice, ammConfigIndex); } catch (e: any) { logger.error('Create pool error:', e); if (e.statusCode) throw e; diff --git a/src/connectors/pancakeswap-sol/clmm-routes/executeSwap.ts b/src/connectors/pancakeswap-sol/clmm-routes/executeSwap.ts index a291385bbe..a53d609e58 100644 --- a/src/connectors/pancakeswap-sol/clmm-routes/executeSwap.ts +++ b/src/connectors/pancakeswap-sol/clmm-routes/executeSwap.ts @@ -179,10 +179,14 @@ export async function executeSwap( fee: totalFee / 1e9, baseTokenBalanceChange: baseTokenChange, quoteTokenBalanceChange: quoteTokenChange, + slippagePct: quote.slippagePct, }, }; } else { - // Transaction pending + // A landed-but-failed transaction is terminal: fail loudly instead of returning + // PENDING (callers would poll forever). Genuinely-not-landed keeps the pending shape. + await solana.throwIfLandedWithError(signature, txData); + return { signature, status: 0, // PENDING diff --git a/src/connectors/pancakeswap-sol/clmm-routes/openPosition.ts b/src/connectors/pancakeswap-sol/clmm-routes/openPosition.ts index 699a811ced..2ee868f187 100644 --- a/src/connectors/pancakeswap-sol/clmm-routes/openPosition.ts +++ b/src/connectors/pancakeswap-sol/clmm-routes/openPosition.ts @@ -173,6 +173,10 @@ export async function openPosition( }; } + // A landed-but-failed transaction is terminal: fail loudly instead of returning + // PENDING (callers would poll forever). Genuinely-not-landed keeps the pending shape. + await solana.throwIfLandedWithError(signature, txData); + return { signature, status: 0, // PENDING diff --git a/src/connectors/pancakeswap-sol/clmm-routes/poolInfo.ts b/src/connectors/pancakeswap-sol/clmm-routes/poolInfo.ts index 581fbf5a45..ead3db316e 100644 --- a/src/connectors/pancakeswap-sol/clmm-routes/poolInfo.ts +++ b/src/connectors/pancakeswap-sol/clmm-routes/poolInfo.ts @@ -4,9 +4,15 @@ import { Solana } from '../../../chains/solana/solana'; import { GetPoolInfoRequestType, PoolInfo, PoolInfoSchema } from '../../../schemas/clmm-schema'; import { logger } from '../../../services/logger'; import { PancakeswapSol } from '../pancakeswap-sol'; +import { computeBinDistribution } from '../pancakeswap-sol.bins'; import { PancakeswapSolClmmGetPoolInfoRequest } from '../schemas'; -export async function getPoolInfo(fastify: FastifyInstance, network: string, poolAddress: string): Promise { +export async function getPoolInfo( + fastify: FastifyInstance, + network: string, + poolAddress: string, + binCount: number = 0, +): Promise { const pancakeswap = await PancakeswapSol.getInstance(network); if (!poolAddress) { @@ -19,6 +25,24 @@ export async function getPoolInfo(fastify: FastifyInstance, network: string, poo throw fastify.httpErrors.notFound(`Pool not found: ${poolAddress}`); } + // Optionally include the per-bin distribution around the current tick — only + // fires the extra tick-array fetch when binCount > 0 so default latency is + // unchanged (same contract as orca/raydium). + if (binCount > 0) { + const solana = await Solana.getInstance(network); + poolInfo.bins = await computeBinDistribution({ + connection: solana.connection, + poolAddress, + tickSpacing: poolInfo.binStep as number, + currentTick: poolInfo.activeBinId as number, + currentPrice: poolInfo.price, + liquidity: (poolInfo as any)._liquidity as bigint, + decimals0: (poolInfo as any)._mintDecimals0 as number, + decimals1: (poolInfo as any)._mintDecimals1 as number, + binCount, + }); + } + return poolInfo; } @@ -40,8 +64,8 @@ export const poolInfoRoute: FastifyPluginAsync = async (fastify) => { }, async (request): Promise => { try { - const { network = 'mainnet-beta', poolAddress } = request.query; - return await getPoolInfo(fastify, network, poolAddress); + const { network = 'mainnet-beta', poolAddress, binCount = 0 } = request.query; + return await getPoolInfo(fastify, network, poolAddress, binCount); } catch (e: any) { logger.error('Pool info error:', e); // Re-throw httpErrors as-is diff --git a/src/connectors/pancakeswap-sol/clmm-routes/removeLiquidity.ts b/src/connectors/pancakeswap-sol/clmm-routes/removeLiquidity.ts index baaba8fa4e..8fc910ba7f 100644 --- a/src/connectors/pancakeswap-sol/clmm-routes/removeLiquidity.ts +++ b/src/connectors/pancakeswap-sol/clmm-routes/removeLiquidity.ts @@ -114,6 +114,10 @@ export async function removeLiquidity( }; } + // A landed-but-failed transaction is terminal: fail loudly instead of returning + // PENDING (callers would poll forever). Genuinely-not-landed keeps the pending shape. + await solana.throwIfLandedWithError(signature, txData); + return { signature, status: 0, // PENDING diff --git a/src/connectors/pancakeswap-sol/pancakeswap-sol.bins.ts b/src/connectors/pancakeswap-sol/pancakeswap-sol.bins.ts new file mode 100644 index 0000000000..d2b5a881ff --- /dev/null +++ b/src/connectors/pancakeswap-sol/pancakeswap-sol.bins.ts @@ -0,0 +1,123 @@ +import { Connection, PublicKey } from '@solana/web3.js'; +import BN from 'bn.js'; + +import { BinLiquidity } from '../../schemas/clmm-schema'; +import { logger } from '../../services/logger'; + +import { getAmountsFromLiquidity } from './pancakeswap-sol.math'; +import { getTickArrayAddress, getTickArrayStartIndexFromTick, tickToPrice } from './pancakeswap-sol.parser'; + +// TickArrayState layout (idl/clmm.json — identical to Raydium CLMM, which this +// program forks): discriminator(8) + pool_id(32) + start_tick_index(i32, 4) + +// ticks[60 x TickState] + initialized_tick_count(u8) + recent_epoch(u64) + padding. +// TickState: tick(i32, 4) + liquidity_net(i128, 16) + liquidity_gross(u128, 16) + +// fee_growth_outside_0_x64(16) + fee_growth_outside_1_x64(16) + +// reward_growths_outside_x64(3 x 16) + padding(13 x u32, 52) = 168 bytes. +const TICK_ARRAY_HEADER = 8 + 32 + 4; +const TICK_STATE_SIZE = 168; + +function readI128LE(data: Buffer, offset: number): bigint { + let value = 0n; + for (let i = 0; i < 16; i++) { + value += BigInt(data[offset + i]) << BigInt(8 * i); + } + if (value >= 1n << 127n) { + value -= 1n << 128n; + } + return value; +} + +function liquidityNetAt(arrays: Map, tickIndex: number, tickSpacing: number): bigint { + const startIndex = getTickArrayStartIndexFromTick(tickIndex, tickSpacing); + const data = arrays.get(startIndex); + if (!data) return 0n; + const slot = Math.floor((tickIndex - startIndex) / tickSpacing); + const base = TICK_ARRAY_HEADER + slot * TICK_STATE_SIZE; + if (base + TICK_STATE_SIZE > data.length) return 0n; + // A zeroed (uninitialized) slot fails the tick check; its liquidity_net would + // be 0 anyway, so either way the boundary contributes nothing. + if (data.readInt32LE(base) !== tickIndex) return 0n; + return readI128LE(data, base + 4); +} + +/** + * Per-bin liquidity distribution around the current tick, mirroring the + * raydium/orca bin walk: propagate active liquidity outward across initialized + * tick boundaries, then convert each bin's L into token amounts. + */ +export async function computeBinDistribution(args: { + connection: Connection; + poolAddress: string; + tickSpacing: number; + currentTick: number; + currentPrice: number; // human units (quote per base) + liquidity: bigint; // active liquidity at the current tick + decimals0: number; + decimals1: number; + binCount: number; +}): Promise { + const { connection, poolAddress, tickSpacing, currentTick, currentPrice, liquidity, decimals0, decimals1, binCount } = + args; + if (binCount <= 0) return []; + + // Snap the bin grid so the current tick lands inside a bin. + const halfBins = Math.floor(binCount / 2); + const snapped = Math.floor(currentTick / tickSpacing) * tickSpacing; + const firstBinStart = snapped - halfBins * tickSpacing; + const boundaries: number[] = []; + for (let i = 0; i <= binCount; i++) { + boundaries.push(firstBinStart + i * tickSpacing); + } + + // Fetch every tick array the boundaries touch in one RPC round-trip. + const poolPubkey = new PublicKey(poolAddress); + const startIndexes = [...new Set(boundaries.map((t) => getTickArrayStartIndexFromTick(t, tickSpacing)))]; + const arrays = new Map(); + try { + const accounts = await connection.getMultipleAccountsInfo( + startIndexes.map((s) => getTickArrayAddress(poolPubkey, s)), + ); + accounts.forEach((account, i) => { + if (account) arrays.set(startIndexes[i], account.data); + }); + } catch (e) { + logger.warn(`pancakeswap-sol bin fetch failed for ${poolAddress}: ${e}`); + return []; + } + + // Propagate L outward from the current bin across boundary liquidity_net. + const curIdx = Math.floor((currentTick - firstBinStart) / tickSpacing); + const binLs: bigint[] = new Array(binCount); + binLs[curIdx] = liquidity; + for (let i = curIdx + 1; i < binCount; i++) { + binLs[i] = binLs[i - 1] + liquidityNetAt(arrays, boundaries[i], tickSpacing); + } + for (let i = curIdx - 1; i >= 0; i--) { + binLs[i] = binLs[i + 1] - liquidityNetAt(arrays, boundaries[i + 1], tickSpacing); + } + + const decimalDiff = decimals0 - decimals1; + const bins: BinLiquidity[] = []; + for (let i = 0; i < binCount; i++) { + const tickStart = boundaries[i]; + const priceLower = tickToPrice(tickStart, decimalDiff); + const priceUpper = tickToPrice(boundaries[i + 1], decimalDiff); + let baseTokenAmount = 0; + let quoteTokenAmount = 0; + const L = binLs[i]; + if (L > 0n) { + const amounts = getAmountsFromLiquidity( + currentPrice, + priceLower, + priceUpper, + new BN(L.toString()), + decimals0, + decimals1, + ); + baseTokenAmount = amounts.amount0; + quoteTokenAmount = amounts.amount1; + } + bins.push({ binId: tickStart, price: priceLower, baseTokenAmount, quoteTokenAmount }); + } + return bins; +} diff --git a/src/connectors/pancakeswap-sol/pancakeswap-sol.math.ts b/src/connectors/pancakeswap-sol/pancakeswap-sol.math.ts index 3150e800fb..ef9d2cfe29 100644 --- a/src/connectors/pancakeswap-sol/pancakeswap-sol.math.ts +++ b/src/connectors/pancakeswap-sol/pancakeswap-sol.math.ts @@ -106,13 +106,14 @@ export function getAmountsFromLiquidity( let amount1Raw: number; if (currentPrice < lowerPrice) { - // Price below range - all liquidity in token1 - amount0Raw = 0; - amount1Raw = liquidityNum * (sqrtPriceUpper - sqrtPriceLower); - } else if (currentPrice >= upperPrice) { - // Price above range - all liquidity in token0 + // Price below range - all liquidity in token0: the range sits above the + // market, so the pool sells token0 as price rises through it. amount0Raw = (liquidityNum * (sqrtPriceUpper - sqrtPriceLower)) / (sqrtPriceLower * sqrtPriceUpper); amount1Raw = 0; + } else if (currentPrice >= upperPrice) { + // Price above range - all liquidity in token1 (token0 fully sold on the way up) + amount0Raw = 0; + amount1Raw = liquidityNum * (sqrtPriceUpper - sqrtPriceLower); } else { // Price in range amount0Raw = (liquidityNum * (sqrtPriceUpper - sqrtPriceCurrent)) / (sqrtPriceCurrent * sqrtPriceUpper); diff --git a/src/connectors/pancakeswap-sol/pancakeswap-sol.ts b/src/connectors/pancakeswap-sol/pancakeswap-sol.ts index acf01502dc..e233821395 100644 --- a/src/connectors/pancakeswap-sol/pancakeswap-sol.ts +++ b/src/connectors/pancakeswap-sol/pancakeswap-sol.ts @@ -145,8 +145,13 @@ export class PancakeswapSol { const tickSpacing = data.readUInt16LE(offset); offset += 2; - // Read liquidity (16 bytes, u128) - not currently used but part of data structure - // const liquidity = data.readBigUInt64LE(offset); + // Read liquidity (16 bytes, u128) - the active liquidity at the current tick, + // needed for the bin distribution walk + const liquidityBytes = data.slice(offset, offset + 16); + let liquidityValue = BigInt(0); + for (let i = 0; i < 16; i++) { + liquidityValue += BigInt(liquidityBytes[i]) << BigInt(i * 8); + } offset += 16; // Read sqrt_price_x64 (16 bytes, u128) @@ -260,6 +265,11 @@ export class PancakeswapSol { (poolInfo as any)._feeGrowthGlobal0 = feeGrowthGlobal0; (poolInfo as any)._feeGrowthGlobal1 = feeGrowthGlobal1; (poolInfo as any)._rewardGrowthGlobalX64 = rewardGrowthGlobalX64; + // Raw pool state for the bin distribution walk (poolInfo.binStep/activeBinId + // carry tickSpacing/tickCurrent already; these carry the rest) + (poolInfo as any)._liquidity = liquidityValue; + (poolInfo as any)._mintDecimals0 = mintDecimals0; + (poolInfo as any)._mintDecimals1 = mintDecimals1; return poolInfo; } catch (error) { diff --git a/src/connectors/pancakeswap-sol/schemas.ts b/src/connectors/pancakeswap-sol/schemas.ts index bcf2b1924a..84a08759f0 100644 --- a/src/connectors/pancakeswap-sol/schemas.ts +++ b/src/connectors/pancakeswap-sol/schemas.ts @@ -31,6 +31,15 @@ export const PancakeswapSolClmmGetPoolInfoRequest = Type.Object({ description: 'PancakeSwap CLMM pool address', examples: [CLMM_POOL_ADDRESS_EXAMPLE], }), + binCount: Type.Optional( + Type.Integer({ + description: + 'If > 0, include a `bins` array of per-tick liquidity around the active tick. Default 0 = skip the bin fetch.', + default: 0, + minimum: 0, + maximum: 401, + }), + ), }); export type PancakeswapSolClmmGetPoolInfoRequestType = Static; @@ -118,13 +127,16 @@ export const PancakeswapSolClmmCreatePoolRequest = Type.Object({ examples: [QUOTE_TOKEN_AMOUNT / BASE_TOKEN_AMOUNT], }), ), - ammConfig: Type.String({ - description: - 'Base58 address of an existing on-chain amm_config account for the desired fee tier. ' + - 'There is no API to enumerate amm_config accounts, so this must be supplied explicitly. ' + - 'The pool_state PDA is derived from this config plus the (canonically ordered) token mints.', - examples: ['E64NGkDLLCdQ2yFNPcavaKptrEgmiQaNykUuLC1Qgwyp'], - }), + ammConfigIndex: Type.Optional( + Type.Integer({ + description: + 'Fee-config index; resolves to the amm_config PDA (["amm_config", index]) and is validated ' + + 'on-chain. Each index is a fee tier created by the program admin. Default 0.', + default: 0, + minimum: 0, + maximum: 65535, + }), + ), }); export type PancakeswapSolClmmCreatePoolRequestType = Static; diff --git a/src/connectors/pancakeswap/amm-routes/addLiquidity.ts b/src/connectors/pancakeswap/amm-routes/addLiquidity.ts index b992bfef1a..074e086a80 100644 --- a/src/connectors/pancakeswap/amm-routes/addLiquidity.ts +++ b/src/connectors/pancakeswap/amm-routes/addLiquidity.ts @@ -7,6 +7,7 @@ import { FastifyPluginAsync } from 'fastify'; import { Ethereum } from '../../../chains/ethereum/ethereum'; import { wrapEthereum } from '../../../chains/ethereum/routes/wrap'; import { AddLiquidityResponseType, AddLiquidityResponse } from '../../../schemas/amm-schema'; +import { TransactionStatus } from '../../../schemas/chain-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { Pancakeswap } from '../pancakeswap'; @@ -30,8 +31,6 @@ async function addLiquidityInternal( baseTokenAmount: number, quoteTokenAmount: number, slippagePct: number = PancakeswapConfig.config.slippagePct, - gasPrice?: string, - maxGas?: number, ): Promise { const networkToUse = network; @@ -180,8 +179,7 @@ async function addLiquidityInternal( // Add liquidity Token + ETH // Convert gasPrice from wei to gwei if provided - const gasPriceGwei = gasPrice ? parseFloat(utils.formatUnits(gasPrice, 'gwei')) : undefined; - const gasOptions = await ethereum.prepareGasOptions(gasPriceGwei, maxGas || AMM_ADD_LIQUIDITY_GAS_LIMIT); + const gasOptions = await ethereum.prepareGasOptions(undefined, AMM_ADD_LIQUIDITY_GAS_LIMIT); gasOptions.value = quote.rawQuoteTokenAmount; tx = await router.addLiquidityETH( @@ -242,8 +240,7 @@ async function addLiquidityInternal( // Add liquidity Token + Token // Convert gasPrice from wei to gwei if provided - const gasPriceGwei = gasPrice ? parseFloat(utils.formatUnits(gasPrice, 'gwei')) : undefined; - const gasOptions = await ethereum.prepareGasOptions(gasPriceGwei, maxGas || AMM_ADD_LIQUIDITY_GAS_LIMIT); + const gasOptions = await ethereum.prepareGasOptions(undefined, AMM_ADD_LIQUIDITY_GAS_LIMIT); tx = await router.addLiquidity( quote.baseTokenObj.address, @@ -259,19 +256,17 @@ async function addLiquidityInternal( } // Wait for transaction confirmation - const receipt = await ethereum.handleTransactionExecution(tx); - - // Calculate gas fee - const gasFee = formatTokenAmount( - receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), - 18, // ETH has 18 decimals - ); + const outcome = await ethereum.handleTransactionConfirmation(tx); + if (!outcome.confirmed) { + // Still pending — the quoted amounts were computed before sending and have not moved. + return { signature: outcome.signature, status: TransactionStatus.PENDING }; + } return { - signature: receipt.transactionHash, - status: receipt.status, + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, data: { - fee: gasFee, + fee: outcome.fee, baseTokenAmountAdded: quote.baseTokenAmount, quoteTokenAmountAdded: quote.quoteTokenAmount, ...(baseWrapTxHash && { baseWrapTxHash }), @@ -291,8 +286,6 @@ export async function addLiquidity( baseTokenAmount: number, quoteTokenAmount: number, slippagePct: number = PancakeswapConfig.config.slippagePct, - gasPrice?: string, - maxGas?: number, ): Promise { const poolInfo = await getPancakeswapPoolInfo(poolAddress, network, 'amm'); if (!poolInfo) throw httpErrors.notFound(`Pool not found: ${poolAddress}`); @@ -306,8 +299,6 @@ export async function addLiquidity( baseTokenAmount, quoteTokenAmount, slippagePct, - gasPrice, - maxGas, ); } @@ -338,8 +329,6 @@ export const addLiquidityRoute: FastifyPluginAsync = async (fastify) => { quoteTokenAmount, slippagePct, walletAddress: requestedWalletAddress, - gasPrice, - maxGas, } = request.body; // Validate essential parameters @@ -366,8 +355,6 @@ export const addLiquidityRoute: FastifyPluginAsync = async (fastify) => { baseTokenAmount, quoteTokenAmount, slippagePct, - gasPrice, - maxGas, ); } catch (e) { logger.error(e); diff --git a/src/connectors/pancakeswap/amm-routes/createPool.ts b/src/connectors/pancakeswap/amm-routes/createPool.ts index 148caefae9..2bfc343a74 100644 --- a/src/connectors/pancakeswap/amm-routes/createPool.ts +++ b/src/connectors/pancakeswap/amm-routes/createPool.ts @@ -7,6 +7,7 @@ import { FastifyPluginAsync } from 'fastify'; import { Ethereum, TokenInfo } from '../../../chains/ethereum/ethereum'; import { CreatePoolResponse, CreatePoolResponseType } from '../../../schemas/amm-schema'; +import { TransactionStatus } from '../../../schemas/chain-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { PancakeswapConfig } from '../pancakeswap.config'; @@ -80,8 +81,6 @@ export async function createPool( baseTokenAmount: number, quoteTokenAmount?: number, initialPrice?: number, - gasPrice?: number, - maxGas?: number, slippagePct: number = PancakeswapConfig.config.slippagePct, ): Promise { if (baseTokenAmount <= 0) { @@ -178,10 +177,6 @@ export async function createPool( const deadline = Math.floor(Date.now() / 1000) + 60 * 20; // 20 minutes from now - // gasPrice arrives already in gwei (the unit prepareGasOptions expects). The connector's Fastify - // route accepts gasPrice as a wei string (sibling shape) and converts it to gwei before calling. - const gasPriceGwei = gasPrice; - let tx; if (baseIsEth || quoteIsEth) { // One side is ETH/WETH → addLiquidityETH. The ERC20 side needs an allowance to the router; the @@ -204,7 +199,7 @@ export async function createPool( ); } - const gasOptions = await ethereum.prepareGasOptions(gasPriceGwei, maxGas || AMM_CREATE_POOL_GAS_LIMIT); + const gasOptions = await ethereum.prepareGasOptions(undefined, AMM_CREATE_POOL_GAS_LIMIT); gasOptions.value = ethRawAmount; tx = await router.addLiquidityETH( @@ -248,7 +243,7 @@ export async function createPool( ); } - const gasOptions = await ethereum.prepareGasOptions(gasPriceGwei, maxGas || AMM_CREATE_POOL_GAS_LIMIT); + const gasOptions = await ethereum.prepareGasOptions(undefined, AMM_CREATE_POOL_GAS_LIMIT); tx = await router.addLiquidity( baseTokenInfo.address, @@ -265,32 +260,33 @@ export async function createPool( logger.info(`Creating Pancakeswap V2 pool ${baseTokenInfo.symbol}/${quoteTokenInfo.symbol} via tx ${tx.hash}`); - const receipt = await ethereum.handleTransactionExecution(tx); + // A revert throws out of here (400 TRANSACTION_FAILED) — it is never reported as PENDING. + const outcome = await ethereum.handleTransactionConfirmation(tx); // Read the (now-created) pair address from the factory — authoritative source of the pool address. const pairAddress: string = await factory.getPair(baseTokenInfo.address, quoteTokenInfo.address); - if (receipt && receipt.status === 1) { - const gasFee = formatTokenAmount(receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), 18); // ETH has 18 decimals + if (!outcome.confirmed) { + // Timed out but still broadcasting — report PENDING with the tx hash so the caller can + // reconcile it. The seed amounts below have not moved, so they are deliberately omitted. return { - signature: receipt.transactionHash, - status: 1, // CONFIRMED + signature: outcome.signature, + status: TransactionStatus.PENDING, poolAddress: pairAddress, price: seedPrice, - data: { - fee: gasFee, - baseTokenAmountAdded: baseTokenAmount, - quoteTokenAmountAdded: effectiveQuoteAmount, - }, }; } - // Timed out (still broadcasting) or reverted — report as pending with the tx hash. return { - signature: receipt ? receipt.transactionHash : tx.hash, - status: 0, // PENDING + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, poolAddress: pairAddress, price: seedPrice, + data: { + fee: outcome.fee, + baseTokenAmountAdded: baseTokenAmount, + quoteTokenAmountAdded: effectiveQuoteAmount, + }, }; } @@ -322,8 +318,6 @@ export const createPoolRoute: FastifyPluginAsync = async (fastify) => { quoteTokenAmount, initialPrice, slippagePct, - gasPrice, - maxGas, walletAddress: requestedWalletAddress, } = request.body; @@ -340,9 +334,6 @@ export const createPoolRoute: FastifyPluginAsync = async (fastify) => { logger.info(`Using first available wallet address: ${walletAddress}`); } - // Route accepts gasPrice as a wei string (matching sibling AMM requests); createPool expects gwei. - const gasPriceGwei = gasPrice ? parseFloat(utils.formatUnits(gasPrice, 'gwei')) : undefined; - return await createPool( network, walletAddress, @@ -351,8 +342,6 @@ export const createPoolRoute: FastifyPluginAsync = async (fastify) => { baseTokenAmount, quoteTokenAmount, initialPrice, - gasPriceGwei, - maxGas, slippagePct, ); } catch (e) { diff --git a/src/connectors/pancakeswap/amm-routes/executeSwap.ts b/src/connectors/pancakeswap/amm-routes/executeSwap.ts index a0a2584c0a..15bea8e51a 100644 --- a/src/connectors/pancakeswap/amm-routes/executeSwap.ts +++ b/src/connectors/pancakeswap/amm-routes/executeSwap.ts @@ -1,9 +1,10 @@ import { BigNumber, Contract, utils } from 'ethers'; import { FastifyPluginAsync } from 'fastify'; -import { Ethereum } from '../../../chains/ethereum/ethereum'; +import { Ethereum, EthereumTransactionOutcome } from '../../../chains/ethereum/ethereum'; import { EthereumLedger } from '../../../chains/ethereum/ethereum-ledger'; import { getEthereumChainConfig } from '../../../chains/ethereum/ethereum.config'; +import { TransactionStatus } from '../../../schemas/chain-schema'; import { ExecuteSwapRequestType, SwapExecuteResponseType, SwapExecuteResponse } from '../../../schemas/router-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; @@ -94,7 +95,7 @@ export async function executeAmmSwap( // Prepare transaction parameters const deadline = Math.floor(Date.now() / 1000) + 60 * 20; // 20 minutes from now - let receipt; + let outcome: EthereumTransactionOutcome; try { if (isHardwareWallet) { @@ -159,7 +160,7 @@ export async function executeAmmSwap( logger.info(`Transaction sent: ${txResponse.hash}`); // Wait for confirmation with timeout - receipt = await ethereum.handleTransactionExecution(txResponse); + outcome = await ethereum.handleTransactionConfirmation(txResponse); } else { // Regular wallet flow let wallet; @@ -216,19 +217,18 @@ export async function executeAmmSwap( logger.info(`Transaction sent: ${tx.hash}`); // Wait for transaction confirmation - receipt = await ethereum.handleTransactionExecution(tx); + outcome = await ethereum.handleTransactionConfirmation(tx); } - // Check if the transaction was successful - if (receipt.status === 0) { - logger.error(`Transaction failed on-chain. Receipt: ${JSON.stringify(receipt)}`); - throw httpErrors.internalServerError( - 'Transaction reverted on-chain. This could be due to slippage, insufficient funds, or other blockchain issues.', - ); + // A revert threw out of the confirmation helper as a 400 TRANSACTION_FAILED. What is left + // is a transaction that is still pending after the extended poll: report it as PENDING + // with its hash rather than dereferencing a null receipt and losing the hash to a 500. + if (!outcome.confirmed) { + return { signature: outcome.signature, status: TransactionStatus.PENDING }; } - logger.info(`Transaction confirmed: ${receipt.transactionHash}`); - logger.info(`Gas used: ${receipt.gasUsed.toString()}`); + logger.info(`Transaction confirmed: ${outcome.signature}`); + logger.info(`Gas used: ${outcome.receipt.gasUsed.toString()}`); // Calculate amounts using quote values const amountIn = quote.estimatedAmountIn; @@ -238,27 +238,22 @@ export async function executeAmmSwap( const baseTokenBalanceChange = side === 'BUY' ? amountOut : -amountIn; const quoteTokenBalanceChange = side === 'BUY' ? -amountIn : amountOut; - // Calculate gas fee (formatTokenAmount already returns a number) - const gasFee = formatTokenAmount( - receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), - 18, // ETH has 18 decimals - ); - // Determine token addresses for computed fields const tokenIn = quote.inputToken.address; const tokenOut = quote.outputToken.address; return { - signature: receipt.transactionHash, - status: receipt.status, + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, data: { tokenIn, tokenOut, amountIn, amountOut, - fee: gasFee, + fee: outcome.fee, baseTokenBalanceChange, quoteTokenBalanceChange, + slippagePct, }, }; } catch (error) { diff --git a/src/connectors/pancakeswap/amm-routes/removeLiquidity.ts b/src/connectors/pancakeswap/amm-routes/removeLiquidity.ts index f50da1bfd2..40fd834b47 100644 --- a/src/connectors/pancakeswap/amm-routes/removeLiquidity.ts +++ b/src/connectors/pancakeswap/amm-routes/removeLiquidity.ts @@ -6,6 +6,7 @@ import { FastifyPluginAsync } from 'fastify'; import { Ethereum } from '../../../chains/ethereum/ethereum'; import { RemoveLiquidityResponseType, RemoveLiquidityResponse } from '../../../schemas/amm-schema'; +import { TransactionStatus } from '../../../schemas/chain-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { Pancakeswap } from '../pancakeswap'; @@ -33,8 +34,6 @@ export async function removeLiquidity( poolAddress: string, percentageToRemove: number, slippagePct: number = PancakeswapConfig.config.slippagePct, - gasPrice?: string, - maxGas?: number, ): Promise { if (!poolAddress || !percentageToRemove) throw httpErrors.badRequest('Missing required parameters'); if (percentageToRemove <= 0 || percentageToRemove > 100) { @@ -89,8 +88,7 @@ export async function removeLiquidity( await checkLPAllowance(ethereum, wallet, poolAddress, routerAddress, liquidityToRemove); const deadline = Math.floor(Date.now() / 1000) + 60 * 20; // 20 minutes from now - const gasPriceGwei = gasPrice ? parseFloat(utils.formatUnits(gasPrice, 'gwei')) : undefined; - const gasOptions = await ethereum.prepareGasOptions(gasPriceGwei, maxGas || AMM_REMOVE_LIQUIDITY_GAS_LIMIT); + const gasOptions = await ethereum.prepareGasOptions(undefined, AMM_REMOVE_LIQUIDITY_GAS_LIMIT); let tx; if (baseTokenObj.symbol === 'WETH') { @@ -126,16 +124,20 @@ export async function removeLiquidity( ); } - const receipt = await ethereum.handleTransactionExecution(tx); + const outcome = await ethereum.handleTransactionConfirmation(tx); + if (!outcome.confirmed) { + // Still pending — the expected amounts were computed before sending and have not moved. + return { signature: outcome.signature, status: TransactionStatus.PENDING }; + } + const baseTokenAmountRemoved = formatTokenAmount(expectedBaseTokenAmount.toString(), baseTokenObj.decimals); const quoteTokenAmountRemoved = formatTokenAmount(expectedQuoteTokenAmount.toString(), quoteTokenObj.decimals); - const gasFee = formatTokenAmount(receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), 18); return { - signature: receipt.transactionHash, - status: receipt.status, + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, data: { - fee: gasFee, + fee: outcome.fee, baseTokenAmountRemoved, quoteTokenAmountRemoved, }, @@ -162,14 +164,7 @@ export const removeLiquidityRoute: FastifyPluginAsync = async (fastify) => { }, async (request) => { try { - const { - network, - poolAddress, - percentageToRemove, - walletAddress: requestedWalletAddress, - gasPrice, - maxGas, - } = request.body; + const { network, poolAddress, percentageToRemove, walletAddress: requestedWalletAddress } = request.body; let walletAddress = requestedWalletAddress; if (!walletAddress) { @@ -180,15 +175,7 @@ export const removeLiquidityRoute: FastifyPluginAsync = async (fastify) => { logger.info(`Using first available wallet address: ${walletAddress}`); } - return await removeLiquidity( - network, - walletAddress, - poolAddress, - percentageToRemove, - undefined, - gasPrice, - maxGas, - ); + return await removeLiquidity(network, walletAddress, poolAddress, percentageToRemove, undefined); } catch (e) { logger.error(e); if (e.statusCode) throw e; diff --git a/src/connectors/pancakeswap/clmm-routes/addLiquidity.ts b/src/connectors/pancakeswap/clmm-routes/addLiquidity.ts index 430235e0c2..0081658abd 100644 --- a/src/connectors/pancakeswap/clmm-routes/addLiquidity.ts +++ b/src/connectors/pancakeswap/clmm-routes/addLiquidity.ts @@ -6,6 +6,7 @@ import { BigNumber, utils } from 'ethers'; import { FastifyPluginAsync } from 'fastify'; import { Ethereum } from '../../../chains/ethereum/ethereum'; +import { TransactionStatus } from '../../../schemas/chain-schema'; import { AddLiquidityResponseType, AddLiquidityResponse } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; @@ -157,9 +158,12 @@ export async function addLiquidity( const txParams = await ethereum.prepareGasOptions(undefined, CLMM_ADD_LIQUIDITY_GAS_LIMIT); txParams.value = BigNumber.from(value.toString()); const tx = await positionManagerWithSigner.multicall([calldata], txParams); - const receipt = await ethereum.handleTransactionExecution(tx); + const outcome = await ethereum.handleTransactionConfirmation(tx); + if (!outcome.confirmed) { + // Still pending — the mint amounts below were computed before sending and have not moved. + return { signature: outcome.signature, status: TransactionStatus.PENDING }; + } - const gasFee = formatTokenAmount(receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), 18); const actualToken0Amount = formatTokenAmount(newPosition.mintAmounts.amount0.toString(), token0.decimals); const actualToken1Amount = formatTokenAmount(newPosition.mintAmounts.amount1.toString(), token1.decimals); @@ -167,10 +171,10 @@ export async function addLiquidity( const actualQuoteAmount = isBaseToken0 ? actualToken1Amount : actualToken0Amount; return { - signature: receipt.transactionHash, - status: receipt.status, + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, data: { - fee: gasFee, + fee: outcome.fee, baseTokenAmountAdded: actualBaseAmount, quoteTokenAmountAdded: actualQuoteAmount, }, diff --git a/src/connectors/pancakeswap/clmm-routes/closePosition.ts b/src/connectors/pancakeswap/clmm-routes/closePosition.ts index 1fcbdd567b..8be6a47a84 100644 --- a/src/connectors/pancakeswap/clmm-routes/closePosition.ts +++ b/src/connectors/pancakeswap/clmm-routes/closePosition.ts @@ -6,6 +6,7 @@ import { FastifyPluginAsync } from 'fastify'; import { Address } from 'viem'; import { Ethereum } from '../../../chains/ethereum/ethereum'; +import { TransactionStatus } from '../../../schemas/chain-schema'; import { ClosePositionRequestType, ClosePositionRequest, @@ -119,9 +120,12 @@ export async function closePosition( const txParams = await ethereum.prepareGasOptions(undefined, CLMM_CLOSE_POSITION_GAS_LIMIT); txParams.value = BigNumber.from(value.toString()); const tx = await positionManagerWithSigner.multicall([calldata], txParams); - const receipt = await ethereum.handleTransactionExecution(tx); + const outcome = await ethereum.handleTransactionConfirmation(tx); + if (!outcome.confirmed) { + // Still pending — the amounts below were computed before sending and have not moved. + return { signature: outcome.signature, status: TransactionStatus.PENDING }; + } - const gasFee = formatTokenAmount(receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), 18); const token0AmountRemoved = formatTokenAmount(totalAmount0.quotient.toString(), token0.decimals); const token1AmountRemoved = formatTokenAmount(totalAmount1.quotient.toString(), token1.decimals); @@ -137,10 +141,10 @@ export async function closePosition( const positionRentRefunded = 0; return { - signature: receipt.transactionHash, - status: receipt.status, + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, data: { - fee: gasFee, + fee: outcome.fee, positionRentRefunded, baseTokenAmountRemoved, quoteTokenAmountRemoved, diff --git a/src/connectors/pancakeswap/clmm-routes/collectFees.ts b/src/connectors/pancakeswap/clmm-routes/collectFees.ts index 7366604a0b..0515ed6d37 100644 --- a/src/connectors/pancakeswap/clmm-routes/collectFees.ts +++ b/src/connectors/pancakeswap/clmm-routes/collectFees.ts @@ -6,6 +6,7 @@ import { FastifyPluginAsync } from 'fastify'; import { Address } from 'viem'; import { Ethereum } from '../../../chains/ethereum/ethereum'; +import { TransactionStatus } from '../../../schemas/chain-schema'; import { CollectFeesRequestType, CollectFeesRequest, @@ -94,9 +95,12 @@ export async function collectFees( const txParams = await ethereum.prepareGasOptions(undefined, CLMM_COLLECT_FEES_GAS_LIMIT); txParams.value = BigNumber.from(value.toString()); const tx = await positionManagerWithSigner.multicall([calldata], txParams); - const receipt = await ethereum.handleTransactionExecution(tx); + const outcome = await ethereum.handleTransactionConfirmation(tx); + if (!outcome.confirmed) { + // Still pending — the fee amounts below were read before sending and have not moved. + return { signature: outcome.signature, status: TransactionStatus.PENDING }; + } - const gasFee = formatTokenAmount(receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), 18); const token0FeeAmount = formatTokenAmount(feeAmount0.toString(), token0.decimals); const token1FeeAmount = formatTokenAmount(feeAmount1.toString(), token1.decimals); @@ -104,10 +108,10 @@ export async function collectFees( const quoteFeeAmountCollected = isBaseToken0 ? token1FeeAmount : token0FeeAmount; return { - signature: receipt.transactionHash, - status: receipt.status, + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, data: { - fee: gasFee, + fee: outcome.fee, baseFeeAmountCollected, quoteFeeAmountCollected, }, diff --git a/src/connectors/pancakeswap/clmm-routes/createPool.ts b/src/connectors/pancakeswap/clmm-routes/createPool.ts index 85e8bda09c..377ea3e775 100644 --- a/src/connectors/pancakeswap/clmm-routes/createPool.ts +++ b/src/connectors/pancakeswap/clmm-routes/createPool.ts @@ -7,7 +7,8 @@ import { FastifyPluginAsync } from 'fastify'; import JSBI from 'jsbi'; import { Ethereum, TokenInfo } from '../../../chains/ethereum/ethereum'; -import { CreatePoolResponse, CreatePoolResponseType } from '../../../schemas/amm-schema'; +import { TransactionStatus } from '../../../schemas/chain-schema'; +import { CreatePoolResponse, CreatePoolResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { @@ -17,7 +18,6 @@ import { getPancakeswapV3FactoryAddress, getPancakeswapV3NftManagerAddress, } from '../pancakeswap.contracts'; -import { formatTokenAmount } from '../pancakeswap.utils'; import { PancakeswapClmmCreatePoolRequest } from '../schemas'; // Pancakeswap V3 supported fee tiers (hundredths of a bip). 100=0.01%, 500=0.05%, 2500=0.25%, 10000=1.00%. @@ -78,8 +78,6 @@ export async function createPool( quoteToken: string, initialPrice?: number, fee?: number, - gasPrice?: number, - maxGas?: number, ): Promise { // Validate the fee tier — V3 only accepts a fixed set of tiers, each mapped to a tick spacing. if (fee === undefined) { @@ -174,7 +172,7 @@ export async function createPool( const nftManagerAddress = getPancakeswapV3NftManagerAddress(network); const nftManager = new Contract(nftManagerAddress, INftManagerCreatePoolABI, wallet); - const gasOptions = await ethereum.prepareGasOptions(gasPrice, maxGas || CLMM_CREATE_POOL_GAS_LIMIT); + const gasOptions = await ethereum.prepareGasOptions(undefined, CLMM_CREATE_POOL_GAS_LIMIT); const tx = await nftManager.createAndInitializePoolIfNecessary( token0.address, @@ -186,32 +184,30 @@ export async function createPool( logger.info(`Creating Pancakeswap V3 pool via tx ${tx.hash}`); - const receipt = await ethereum.handleTransactionExecution(tx); + // A revert throws out of here (400 TRANSACTION_FAILED) — it is never reported as PENDING. + const outcome = await ethereum.handleTransactionConfirmation(tx); // Read the (now-created) pool address from the factory — the authoritative source. const poolAddress: string = await factory.getPool(token0.address, token1.address, fee); - if (receipt && receipt.status === 1) { - const gasFee = formatTokenAmount(receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), 18); // ETH has 18 decimals + if (!outcome.confirmed) { + // Timed out but still broadcasting — report PENDING with the tx hash so the caller can reconcile it. return { - signature: receipt.transactionHash, - status: 1, // CONFIRMED + signature: outcome.signature, + status: TransactionStatus.PENDING, poolAddress, price: seedPrice, - data: { - fee: gasFee, - baseTokenAmountAdded: 0, // create-pool only initializes price; no liquidity is seeded - quoteTokenAmountAdded: 0, - }, }; } - // Timed out (still broadcasting) or reverted — report as pending with the tx hash. return { - signature: receipt ? receipt.transactionHash : tx.hash, - status: 0, // PENDING + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, poolAddress, price: seedPrice, + data: { + fee: outcome.fee, + }, }; } @@ -241,8 +237,6 @@ export const createPoolRoute: FastifyPluginAsync = async (fastify) => { quoteToken, fee, initialPrice, - gasPrice, - maxGas, walletAddress: requestedWalletAddress, } = request.body; @@ -259,10 +253,7 @@ export const createPoolRoute: FastifyPluginAsync = async (fastify) => { logger.info(`Using first available wallet address: ${walletAddress}`); } - // Route accepts gasPrice as a wei string (matching sibling requests); createPool expects gwei. - const gasPriceGwei = gasPrice ? parseFloat(utils.formatUnits(gasPrice, 'gwei')) : undefined; - - return await createPool(network, walletAddress, baseToken, quoteToken, initialPrice, fee, gasPriceGwei, maxGas); + return await createPool(network, walletAddress, baseToken, quoteToken, initialPrice, fee); } catch (e) { logger.error(e); if (e.statusCode) { diff --git a/src/connectors/pancakeswap/clmm-routes/executeSwap.ts b/src/connectors/pancakeswap/clmm-routes/executeSwap.ts index 47fd5e91da..3ae9c9e64b 100644 --- a/src/connectors/pancakeswap/clmm-routes/executeSwap.ts +++ b/src/connectors/pancakeswap/clmm-routes/executeSwap.ts @@ -2,8 +2,9 @@ import { encodeSqrtRatioX96 } from '@uniswap/v3-sdk'; import { BigNumber, Contract, utils } from 'ethers'; import { FastifyPluginAsync } from 'fastify'; -import { Ethereum } from '../../../chains/ethereum/ethereum'; +import { Ethereum, EthereumTransactionOutcome } from '../../../chains/ethereum/ethereum'; import { EthereumLedger } from '../../../chains/ethereum/ethereum-ledger'; +import { TransactionStatus } from '../../../schemas/chain-schema'; import { ExecuteSwapRequestType, SwapExecuteResponseType, SwapExecuteResponse } from '../../../schemas/router-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; @@ -109,7 +110,7 @@ export async function executeClmmSwap( ).toString(), }; - let receipt; + let outcome: EthereumTransactionOutcome; try { if (isHardwareWallet) { @@ -186,7 +187,7 @@ export async function executeClmmSwap( logger.info(`Transaction sent: ${txResponse.hash}`); // Wait for confirmation with timeout - receipt = await ethereum.handleTransactionExecution(txResponse); + outcome = await ethereum.handleTransactionConfirmation(txResponse); } else { // Regular wallet flow let wallet; @@ -249,19 +250,18 @@ export async function executeClmmSwap( logger.info(`Transaction sent: ${tx.hash}`); // Wait for transaction confirmation - receipt = await ethereum.handleTransactionExecution(tx); + outcome = await ethereum.handleTransactionConfirmation(tx); } - // Check if the transaction was successful - if (receipt.status === 0) { - logger.error(`Transaction failed on-chain. Receipt: ${JSON.stringify(receipt)}`); - throw httpErrors.internalServerError( - 'Transaction reverted on-chain. This could be due to slippage, insufficient funds, or other blockchain issues.', - ); + // A revert threw out of the confirmation helper as a 400 TRANSACTION_FAILED. What is left + // is a transaction that is still pending after the extended poll: report it as PENDING + // with its hash rather than dereferencing a null receipt and losing the hash to a 500. + if (!outcome.confirmed) { + return { signature: outcome.signature, status: TransactionStatus.PENDING }; } - logger.info(`Transaction hash: ${receipt.transactionHash}`); - logger.info(`Gas used: ${receipt.gasUsed.toString()}`); + logger.info(`Transaction hash: ${outcome.signature}`); + logger.info(`Gas used: ${outcome.receipt.gasUsed.toString()}`); // Calculate amounts using quote values const amountIn = quote.estimatedAmountIn; @@ -271,27 +271,22 @@ export async function executeClmmSwap( const baseTokenBalanceChange = side === 'BUY' ? amountOut : -amountIn; const quoteTokenBalanceChange = side === 'BUY' ? -amountIn : amountOut; - // Calculate gas fee (formatTokenAmount already returns a number) - const gasFee = formatTokenAmount( - receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), - 18, // ETH has 18 decimals - ); - // Determine token addresses for computed fields const tokenIn = quote.inputToken.address; const tokenOut = quote.outputToken.address; return { - signature: receipt.transactionHash, - status: receipt.status, + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, data: { tokenIn, tokenOut, amountIn, amountOut, - fee: gasFee, + fee: outcome.fee, baseTokenBalanceChange, quoteTokenBalanceChange, + slippagePct, }, }; } catch (error) { diff --git a/src/connectors/pancakeswap/clmm-routes/openPosition.ts b/src/connectors/pancakeswap/clmm-routes/openPosition.ts index 2574bbe477..094587623c 100644 --- a/src/connectors/pancakeswap/clmm-routes/openPosition.ts +++ b/src/connectors/pancakeswap/clmm-routes/openPosition.ts @@ -6,6 +6,7 @@ import { FastifyPluginAsync } from 'fastify'; import { Address } from 'viem'; import { Ethereum } from '../../../chains/ethereum/ethereum'; +import { TransactionStatus } from '../../../schemas/chain-schema'; import { OpenPositionRequestType, OpenPositionRequest, @@ -239,11 +240,15 @@ export async function openPosition( } // Wait for transaction confirmation - const receipt = await ethereum.handleTransactionExecution(tx); + const outcome = await ethereum.handleTransactionConfirmation(tx); + if (!outcome.confirmed) { + // Still pending — there is no mint log to read yet, so no positionAddress and no amounts. + return { signature: outcome.signature, status: TransactionStatus.PENDING }; + } // Find the NFT ID from the transaction logs let positionId = ''; - for (const log of receipt.logs) { + for (const log of outcome.receipt.logs) { if ( log.address.toLowerCase() === positionManagerAddress.toLowerCase() && log.topics[0] === '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef' && @@ -254,8 +259,15 @@ export async function openPosition( } } - // Calculate gas fee - const gasFee = formatTokenAmount(receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), 18); + if (!positionId) { + // The transaction confirmed but no NFT-mint Transfer log was found. Returning a CONFIRMED + // response with an empty positionAddress would have the caller record a position it can + // never address — fail loudly, naming the transaction so the position stays recoverable. + throw httpErrors.internalServerError( + `Position opened in transaction ${outcome.signature} but no position NFT mint was found in its logs. ` + + `Inspect the transaction to recover the position ID.`, + ); + } // For position rent, we're using the estimated gas cost since Ethereum doesn't have rent like Solana const positionRent = 0; @@ -269,10 +281,10 @@ export async function openPosition( const quoteAmountUsed = isBaseToken0 ? actualToken1Amount : actualToken0Amount; return { - signature: receipt.transactionHash, - status: receipt.status, + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, data: { - fee: gasFee, + fee: outcome.fee, positionAddress: positionId, positionRent, baseTokenAmountAdded: baseAmountUsed, diff --git a/src/connectors/pancakeswap/clmm-routes/poolInfo.ts b/src/connectors/pancakeswap/clmm-routes/poolInfo.ts index 8efd314f11..ae0c5713c3 100644 --- a/src/connectors/pancakeswap/clmm-routes/poolInfo.ts +++ b/src/connectors/pancakeswap/clmm-routes/poolInfo.ts @@ -1,13 +1,23 @@ +import { Contract as EthersProjectContract } from '@ethersproject/contracts'; +import { abi as IPancakeV3PoolABI } from '@pancakeswap/v3-core/artifacts/contracts/interfaces/IPancakeV3Pool.sol/IPancakeV3Pool.json'; +import { SqrtPriceMath, TickMath } from '@pancakeswap/v3-sdk'; import { FastifyPluginAsync, FastifyInstance } from 'fastify'; -import { GetPoolInfoRequestType, PoolInfo, PoolInfoSchema } from '../../../schemas/clmm-schema'; +import { Ethereum } from '../../../chains/ethereum/ethereum'; +import { PoolInfo, PoolInfoSchema } from '../../../schemas/clmm-schema'; import { logger } from '../../../services/logger'; import { sanitizeErrorMessage } from '../../../services/sanitize'; +import { computeV3BinDistribution } from '../../clmm-v3-utils'; import { Pancakeswap } from '../pancakeswap'; import { formatTokenAmount, getPancakeswapPoolInfo } from '../pancakeswap.utils'; -import { PancakeswapClmmGetPoolInfoRequest } from '../schemas'; - -export async function getPoolInfo(fastify: FastifyInstance, network: string, poolAddress: string): Promise { +import { PancakeswapClmmGetPoolInfoRequest, PancakeswapClmmGetPoolInfoRequestType } from '../schemas'; + +export async function getPoolInfo( + fastify: FastifyInstance, + network: string, + poolAddress: string, + binCount: number = 0, +): Promise { const pancakeswap = await Pancakeswap.getInstance(network); if (!poolAddress) { @@ -41,9 +51,18 @@ export async function getPoolInfo(fastify: FastifyInstance, network: string, poo const price = isBaseToken0 ? parseFloat(price0) : parseFloat(price1); - const liquidity = pool.liquidity; - const token0Amount = formatTokenAmount(liquidity.toString(), token0.decimals); - const token1Amount = formatTokenAmount(liquidity.toString(), token1.decimals); + // Read the pool contract's actual ERC20 balances. V3's `pool.liquidity` is the + // active virtual liquidity in sqrt-price space, not a token amount — using it + // reported the same figure for both sides, scaled by each token's decimals. + const ethereum = await Ethereum.getInstance(network); + const token0Contract = ethereum.getContract(token0.address, ethereum.provider); + const token1Contract = ethereum.getContract(token1.address, ethereum.provider); + const [token0Balance, token1Balance] = await Promise.all([ + ethereum.getERC20BalanceByAddress(token0Contract, poolAddress, token0.decimals), + ethereum.getERC20BalanceByAddress(token1Contract, poolAddress, token1.decimals), + ]); + const token0Amount = formatTokenAmount(token0Balance.value.toString(), token0.decimals); + const token1Amount = formatTokenAmount(token1Balance.value.toString(), token1.decimals); const baseTokenAmount = isBaseToken0 ? token0Amount : token1Amount; const quoteTokenAmount = isBaseToken0 ? token1Amount : token0Amount; @@ -52,7 +71,7 @@ export async function getPoolInfo(fastify: FastifyInstance, network: string, poo const tickSpacing = pool.tickSpacing; const activeBinId = pool.tickCurrent; - return { + const result: PoolInfo = { address: poolAddress, baseTokenAddress: baseTokenObj.address, quoteTokenAddress: quoteTokenObj.address, @@ -63,11 +82,33 @@ export async function getPoolInfo(fastify: FastifyInstance, network: string, poo quoteTokenAmount: quoteTokenAmount, activeBinId: activeBinId, }; + + // Optionally include the per-bin distribution around the current tick. + // Fires N parallel pool.ticks(tick) reads — only when binCount > 0 so the + // default pool-info latency is unaffected. + if (binCount > 0) { + const poolContract = new EthersProjectContract(poolAddress, IPancakeV3PoolABI, ethereum.provider); + result.bins = await computeV3BinDistribution({ + poolContract, + tickSpacing, + currentTick: activeBinId, + currentSqrtPriceX96: BigInt(pool.sqrtRatioX96.toString()), + activeLiquidity: BigInt(pool.liquidity.toString()), + decimals0: token0.decimals, + decimals1: token1.decimals, + isBaseToken0, + binCount, + tickMath: TickMath, + sqrtPriceMath: SqrtPriceMath, + }); + } + + return result; } export const poolInfoRoute: FastifyPluginAsync = async (fastify) => { fastify.get<{ - Querystring: GetPoolInfoRequestType; + Querystring: PancakeswapClmmGetPoolInfoRequestType; Reply: Record; }>( '/pool-info', @@ -83,9 +124,9 @@ export const poolInfoRoute: FastifyPluginAsync = async (fastify) => { }, async (request): Promise => { try { - const { poolAddress } = request.query; + const { poolAddress, binCount = 0 } = request.query; const network = request.query.network; - return await getPoolInfo(fastify, network, poolAddress); + return await getPoolInfo(fastify, network, poolAddress, binCount); } catch (e) { logger.error(e); if (e.statusCode) { diff --git a/src/connectors/pancakeswap/clmm-routes/removeLiquidity.ts b/src/connectors/pancakeswap/clmm-routes/removeLiquidity.ts index 9487cb9f68..71417ff1e6 100644 --- a/src/connectors/pancakeswap/clmm-routes/removeLiquidity.ts +++ b/src/connectors/pancakeswap/clmm-routes/removeLiquidity.ts @@ -7,6 +7,7 @@ import JSBI from 'jsbi'; import { Address } from 'viem'; import { Ethereum } from '../../../chains/ethereum/ethereum'; +import { TransactionStatus } from '../../../schemas/chain-schema'; import { RemoveLiquidityRequestType, RemoveLiquidityRequest, @@ -133,9 +134,12 @@ export async function removeLiquidity( const txParams = await ethereum.prepareGasOptions(undefined, CLMM_REMOVE_LIQUIDITY_GAS_LIMIT); txParams.value = BigNumber.from(value.toString()); const tx = await positionManagerWithSigner.multicall([calldata], txParams); - const receipt = await ethereum.handleTransactionExecution(tx); + const outcome = await ethereum.handleTransactionConfirmation(tx); + if (!outcome.confirmed) { + // Still pending — the amounts below were computed before sending and have not moved. + return { signature: outcome.signature, status: TransactionStatus.PENDING }; + } - const gasFee = formatTokenAmount(receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), 18); const token0AmountRemoved = formatTokenAmount(totalAmount0.quotient.toString(), token0.decimals); const token1AmountRemoved = formatTokenAmount(totalAmount1.quotient.toString(), token1.decimals); @@ -143,10 +147,10 @@ export async function removeLiquidity( const quoteTokenAmountRemoved = isBaseToken0 ? token1AmountRemoved : token0AmountRemoved; return { - signature: receipt.transactionHash, - status: receipt.status, + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, data: { - fee: gasFee, + fee: outcome.fee, baseTokenAmountRemoved, quoteTokenAmountRemoved, }, diff --git a/src/connectors/pancakeswap/router-routes/executeQuote.ts b/src/connectors/pancakeswap/router-routes/executeQuote.ts index cbb6ae95cf..fff99c8780 100644 --- a/src/connectors/pancakeswap/router-routes/executeQuote.ts +++ b/src/connectors/pancakeswap/router-routes/executeQuote.ts @@ -18,7 +18,7 @@ async function executeQuote(walletAddress: string, network: string, quoteId: str } const { quote, request } = cached; - const { inputToken, outputToken, side, amount } = request; + const { inputToken, outputToken, side, amount, slippagePct } = request; const ethereum = await Ethereum.getInstance(network); @@ -179,6 +179,8 @@ async function executeQuote(walletAddress: string, network: string, quoteId: str expectedAmountIn, expectedAmountOut, side, + undefined, + slippagePct, ); // Handle different transaction states diff --git a/src/connectors/pancakeswap/schemas.ts b/src/connectors/pancakeswap/schemas.ts index 7d01b9a1cf..62776deb00 100644 --- a/src/connectors/pancakeswap/schemas.ts +++ b/src/connectors/pancakeswap/schemas.ts @@ -1,4 +1,4 @@ -import { Type } from '@sinclair/typebox'; +import { Type, Static } from '@sinclair/typebox'; import { getEthereumChainConfig } from '../../chains/ethereum/ethereum.config'; @@ -80,17 +80,6 @@ export const PancakeswapAmmCreatePoolRequest = Type.Object({ default: PancakeswapConfig.config.slippagePct, }), ), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [300000], - }), - ), }); // ======================================== @@ -110,7 +99,18 @@ export const PancakeswapClmmGetPoolInfoRequest = Type.Object({ description: 'Pancakeswap V3 pool address', examples: [CLMM_POOL_ADDRESS_EXAMPLE], }), + binCount: Type.Optional( + Type.Integer({ + description: + 'If > 0, include a `bins` array (per-tickSpacing token amounts around the current tick), ' + + 'mirroring Meteora pool-info.bins[]. Default 0 — pool-info skips the extra eth_calls.', + default: 0, + minimum: 0, + maximum: 401, + }), + ), }); +export type PancakeswapClmmGetPoolInfoRequestType = Static; // Pancakeswap CLMM Create Pool Request (Pancakeswap V3 — Uniswap V3 fork) export const PancakeswapClmmCreatePoolRequest = Type.Object({ @@ -149,17 +149,6 @@ export const PancakeswapClmmCreatePoolRequest = Type.Object({ 'unified swap router so the pool opens on-market and is not immediately arbitraged.', }), ), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [600000], - }), - ), }); // ======================================== @@ -298,17 +287,6 @@ export const PancakeswapAmmAddLiquidityRequest = Type.Object({ default: PancakeswapConfig.config.slippagePct, }), ), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [300000], - }), - ), }); // Pancakeswap AMM Remove Liquidity Request @@ -334,17 +312,6 @@ export const PancakeswapAmmRemoveLiquidityRequest = Type.Object({ maximum: 100, description: 'Percentage of liquidity to remove', }), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [300000], - }), - ), }); // Pancakeswap AMM Execute Swap Request @@ -483,17 +450,6 @@ export const PancakeswapClmmOpenPositionRequest = Type.Object({ default: PancakeswapConfig.config.slippagePct, }), ), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [300000], - }), - ), }); // Pancakeswap CLMM Add Liquidity Request @@ -529,17 +485,6 @@ export const PancakeswapClmmAddLiquidityRequest = Type.Object({ default: PancakeswapConfig.config.slippagePct, }), ), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [300000], - }), - ), }); // Pancakeswap CLMM Remove Liquidity Request @@ -566,17 +511,6 @@ export const PancakeswapClmmRemoveLiquidityRequest = Type.Object({ maximum: 100, description: 'Percentage of liquidity to remove', }), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [300000], - }), - ), }); // Pancakeswap CLMM Close Position Request @@ -598,17 +532,6 @@ export const PancakeswapClmmClosePositionRequest = Type.Object({ positionAddress: Type.String({ description: 'NFT token ID of the position to close', }), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [300000], - }), - ), }); // Pancakeswap CLMM Collect Fees Request @@ -630,17 +553,6 @@ export const PancakeswapClmmCollectFeesRequest = Type.Object({ positionAddress: Type.String({ description: 'NFT token ID of the position', }), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [300000], - }), - ), }); // Pancakeswap CLMM Execute Swap Request @@ -690,15 +602,4 @@ export const PancakeswapClmmExecuteSwapRequest = Type.Object({ default: PancakeswapConfig.config.slippagePct, }), ), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [300000], - }), - ), }); diff --git a/src/connectors/raydium/amm-routes/addLiquidity.ts b/src/connectors/raydium/amm-routes/addLiquidity.ts index 33ed4a8213..05e33bf3dd 100644 --- a/src/connectors/raydium/amm-routes/addLiquidity.ts +++ b/src/connectors/raydium/amm-routes/addLiquidity.ts @@ -188,10 +188,9 @@ export async function addLiquidity( // Sign + send via the wallet-type-aware chokepoint (handles local/hardware and // simulates internally). const { signature } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress); - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Retrying re-fetch; throws the shared landed-but-failed error if the transaction + // landed with an error, so txData existing below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); const confirmed = txData !== null; if (confirmed && txData) { diff --git a/src/connectors/raydium/amm-routes/createPool.ts b/src/connectors/raydium/amm-routes/createPool.ts index 39a9d54a4c..be3d07b5dc 100644 --- a/src/connectors/raydium/amm-routes/createPool.ts +++ b/src/connectors/raydium/amm-routes/createPool.ts @@ -186,10 +186,9 @@ export async function createPool( logger.info(`Creating Raydium CPMM pool ${poolAddress} (${baseToken}/${quoteToken})`); const { signature } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress); - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Retrying re-fetch; throws the shared landed-but-failed error if the transaction + // landed with an error, so txData existing below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); if (txData) { const { balanceChanges } = await solana.extractBalanceChangesAndFee(signature, walletAddress, [ diff --git a/src/connectors/raydium/amm-routes/executeSwap.ts b/src/connectors/raydium/amm-routes/executeSwap.ts index 273e56f1c4..dd4a75ee3b 100644 --- a/src/connectors/raydium/amm-routes/executeSwap.ts +++ b/src/connectors/raydium/amm-routes/executeSwap.ts @@ -156,20 +156,19 @@ export async function executeSwap( // Sign + send via the wallet-type-aware chokepoint (handles local/hardware and // simulates internally). const { signature } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress); - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Re-fetch with retry; a landed-but-failed transaction throws instead of being + // misreported as confirmed or pending. + const txData = await solana.getConfirmedTransactionData(signature); // Handle confirmation status const result = await solana.handleConfirmation( signature, - txData !== null, txData, inputToken.address, outputToken.address, walletAddress, side, + effectiveSlippage, ); if (result.status === 1) { diff --git a/src/connectors/raydium/amm-routes/removeLiquidity.ts b/src/connectors/raydium/amm-routes/removeLiquidity.ts index 806801bcc3..cba49ce3ca 100644 --- a/src/connectors/raydium/amm-routes/removeLiquidity.ts +++ b/src/connectors/raydium/amm-routes/removeLiquidity.ts @@ -189,10 +189,9 @@ export async function removeLiquidity( // Sign + send via the wallet-type-aware chokepoint (handles local/hardware and // simulates internally). const { signature } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress); - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Retrying re-fetch; throws the shared landed-but-failed error if the transaction + // landed with an error, so txData existing below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); const confirmed = txData !== null; if (confirmed && txData) { diff --git a/src/connectors/raydium/clmm-routes/addLiquidity.ts b/src/connectors/raydium/clmm-routes/addLiquidity.ts index f697aaef23..6019cf658f 100644 --- a/src/connectors/raydium/clmm-routes/addLiquidity.ts +++ b/src/connectors/raydium/clmm-routes/addLiquidity.ts @@ -81,10 +81,9 @@ export async function addLiquidity( // Sign + send via the wallet-type-aware chokepoint (handles local/hardware and // simulates internally). const { signature } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress); - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Retrying re-fetch; throws the shared landed-but-failed error if the transaction + // landed with an error, so txData existing below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); const confirmed = txData !== null; if (confirmed && txData) { diff --git a/src/connectors/raydium/clmm-routes/closePosition.ts b/src/connectors/raydium/clmm-routes/closePosition.ts index 8336fe294f..74bc1386da 100644 --- a/src/connectors/raydium/clmm-routes/closePosition.ts +++ b/src/connectors/raydium/clmm-routes/closePosition.ts @@ -102,10 +102,9 @@ export async function closePosition( // Sign + send via the wallet-type-aware chokepoint (handles local/hardware and // simulates internally). const { signature } = await solana.sendAndConfirmTransactionForWallet(result.transaction, walletAddress); - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Retrying re-fetch; throws the shared landed-but-failed error if the transaction + // landed with an error, so txData existing below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); const confirmed = txData !== null; if (!confirmed || !txData) { diff --git a/src/connectors/raydium/clmm-routes/collectFees.ts b/src/connectors/raydium/clmm-routes/collectFees.ts index fd26cbe2a3..9bace858ce 100644 --- a/src/connectors/raydium/clmm-routes/collectFees.ts +++ b/src/connectors/raydium/clmm-routes/collectFees.ts @@ -1,4 +1,6 @@ +import { TxVersion } from '@raydium-io/raydium-sdk-v2'; import { PublicKey } from '@solana/web3.js'; +import BN from 'bn.js'; import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; @@ -12,8 +14,15 @@ import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { Raydium } from '../raydium'; -import { removeLiquidity } from './removeLiquidity'; - +/** + * Collect accumulated fees from a position WITHOUT touching its liquidity. + * + * The Raydium CLMM program has no owner-facing "collect fees" instruction — fees owed + * to a position are transferred by decrease_liquidity. Calling it with liquidity = 0 + * collects what is owed and leaves the position intact. This route used to remove 1% + * of the position and report the withdrawn principal as fees, which both mutated the + * position and mis-stated the amounts. + */ export async function collectFees( network: string, walletAddress: string, @@ -23,8 +32,7 @@ export async function collectFees( const raydium = await Raydium.getInstance(network); // Set the SDK owner to the wallet's public key — works for every wallet type (local, - // hardware). The actual liquidity removal (and signing/sending) is delegated to - // removeLiquidity below. + // hardware). The tx is built unsigned; signing/sending is delegated below. await raydium.setOwner(new PublicKey(walletAddress)); const position = await raydium.getClmmPosition(positionAddress); @@ -32,58 +40,69 @@ export async function collectFees( throw httpErrors.notFound(`Position not found: ${positionAddress}`); } - const [poolInfo] = await raydium.getClmmPoolfromAPI(position.poolId.toBase58()); + const [poolInfo, poolKeys] = await raydium.getClmmPoolfromAPI(position.poolId.toBase58()); const tokenA = await solana.getToken(poolInfo.mintA.address); const tokenB = await solana.getToken(poolInfo.mintB.address); - logger.info(`Collecting fees from CLMM position ${positionAddress} by removing 1% liquidity`); + logger.info(`Collecting fees from CLMM position ${positionAddress} via zero-liquidity decrease`); - // Remove 1% of liquidity to collect fees - const removeLiquidityResponse = await removeLiquidity( - network, - walletAddress, - positionAddress, - 1, // 1% of position - false, // don't close position - ); + const COMPUTE_UNITS = 600000; + const priorityFeeInLamports = await solana.estimateGasPrice(); + const priorityFeePerCU = Math.floor(priorityFeeInLamports * 1e6); + + const { transaction } = await raydium.raydiumSDK.clmm.decreaseLiquidity({ + poolInfo, + poolKeys, + ownerPosition: position, + ownerInfo: { + useSOLBalance: true, + closePosition: false, + }, + liquidity: new BN(0), // collect fees only; principal untouched + amountMinA: new BN(0), + amountMinB: new BN(0), + txVersion: TxVersion.V0, + computeBudgetConfig: { + units: COMPUTE_UNITS, + microLamports: priorityFeePerCU, + }, + }); - if (removeLiquidityResponse.status === 1 && removeLiquidityResponse.data) { - // Use the new helper to extract balance changes including fees + const { signature } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress); + // Retrying re-fetch; throws the shared landed-but-failed error if the transaction + // landed with an error, so txData existing below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); + + if (txData) { + // No liquidity moved, so the whole balance change is fees. const { baseTokenChange, quoteTokenChange } = await solana.extractClmmBalanceChanges( - removeLiquidityResponse.signature, + signature, walletAddress, tokenA, tokenB, - removeLiquidityResponse.data.fee * 1e9, + txData.meta.fee, ); - // The total balance change includes both liquidity removal and fee collection - // Since we know the liquidity amounts from removeLiquidity response, - // we can calculate the fee amounts - const baseFeeCollected = Math.abs(baseTokenChange) - removeLiquidityResponse.data.baseTokenAmountRemoved; - const quoteFeeCollected = Math.abs(quoteTokenChange) - removeLiquidityResponse.data.quoteTokenAmountRemoved; - logger.info( - `Fees collected from position ${positionAddress}: ${Math.max(0, baseFeeCollected).toFixed(4)} ${tokenA.symbol}, ${Math.max(0, quoteFeeCollected).toFixed(4)} ${tokenB.symbol}`, + `Fees collected from position ${positionAddress}: ${Math.abs(baseTokenChange).toFixed(4)} ${tokenA.symbol}, ${Math.abs(quoteTokenChange).toFixed(4)} ${tokenB.symbol}`, ); return { - signature: removeLiquidityResponse.signature, + signature, status: 1, // CONFIRMED data: { - fee: removeLiquidityResponse.data.fee, - baseFeeAmountCollected: Math.max(0, baseFeeCollected), - quoteFeeAmountCollected: Math.max(0, quoteFeeCollected), + fee: txData.meta.fee / 1e9, + baseFeeAmountCollected: Math.abs(baseTokenChange), + quoteFeeAmountCollected: Math.abs(quoteTokenChange), }, }; - } else { - // Return pending status - return { - signature: removeLiquidityResponse.signature, - status: removeLiquidityResponse.status, - }; } + + return { + signature, + status: 0, // PENDING + }; } export const collectFeesRoute: FastifyPluginAsync = async (fastify) => { diff --git a/src/connectors/raydium/clmm-routes/createPool.ts b/src/connectors/raydium/clmm-routes/createPool.ts index c6d4ed0344..72e96892f4 100644 --- a/src/connectors/raydium/clmm-routes/createPool.ts +++ b/src/connectors/raydium/clmm-routes/createPool.ts @@ -12,7 +12,7 @@ import { Decimal } from 'decimal.js'; import { FastifyPluginAsync } from 'fastify'; import { Solana } from '../../../chains/solana/solana'; -import { CreatePoolResponse, CreatePoolResponseType } from '../../../schemas/amm-schema'; +import { CreatePoolResponse, CreatePoolResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { sanitizeErrorMessage } from '../../../services/sanitize'; @@ -184,10 +184,9 @@ export async function createPool( walletAddress, (sdkSigners as Keypair[]) ?? [], ); - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Retrying re-fetch; throws the shared landed-but-failed error if the transaction + // landed with an error, so txData existing below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); if (txData) { return { @@ -197,9 +196,6 @@ export async function createPool( price: seedPrice, data: { fee: txData.meta.fee / 1e9, - // Pool created + initialized only — no liquidity/position seeded. - baseTokenAmountAdded: 0, - quoteTokenAmountAdded: 0, }, }; } diff --git a/src/connectors/raydium/clmm-routes/executeSwap.ts b/src/connectors/raydium/clmm-routes/executeSwap.ts index beeb39a39b..2b18b2dc7d 100644 --- a/src/connectors/raydium/clmm-routes/executeSwap.ts +++ b/src/connectors/raydium/clmm-routes/executeSwap.ts @@ -154,20 +154,19 @@ export async function executeSwap( // Sign + send via the wallet-type-aware chokepoint (handles local/hardware and // simulates internally). const { signature } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress); - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Re-fetch with retry; a landed-but-failed transaction throws instead of being + // misreported as confirmed or pending. + const txData = await solana.getConfirmedTransactionData(signature); // Handle confirmation status const result = await solana.handleConfirmation( signature, - txData !== null, txData, inputToken.address, outputToken.address, walletAddress, side, + slippagePct, ); if (result.status === 1) { diff --git a/src/connectors/raydium/clmm-routes/openPosition.ts b/src/connectors/raydium/clmm-routes/openPosition.ts index 5f046e2903..b4e4e30e00 100644 --- a/src/connectors/raydium/clmm-routes/openPosition.ts +++ b/src/connectors/raydium/clmm-routes/openPosition.ts @@ -112,10 +112,9 @@ export async function openPosition( walletAddress, (sdkSigners as Keypair[]) ?? [], ); - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Retrying re-fetch; throws the shared landed-but-failed error if the transaction + // landed with an error, so txData existing below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); const confirmed = txData !== null; // Return with status diff --git a/src/connectors/raydium/clmm-routes/removeLiquidity.ts b/src/connectors/raydium/clmm-routes/removeLiquidity.ts index 668b1c09ba..12d2eb0f22 100644 --- a/src/connectors/raydium/clmm-routes/removeLiquidity.ts +++ b/src/connectors/raydium/clmm-routes/removeLiquidity.ts @@ -76,10 +76,9 @@ export async function removeLiquidity( // Sign + send via the wallet-type-aware chokepoint (handles local/hardware and // simulates internally). const { signature } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress); - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Retrying re-fetch; throws the shared landed-but-failed error if the transaction + // landed with an error, so txData existing below really means "confirmed". + const txData = await solana.getConfirmedTransactionData(signature); const confirmed = txData !== null; // Return with status diff --git a/src/connectors/titan/router-routes/executeQuote.ts b/src/connectors/titan/router-routes/executeQuote.ts index 26407e0eb2..ed65bf8fed 100644 --- a/src/connectors/titan/router-routes/executeQuote.ts +++ b/src/connectors/titan/router-routes/executeQuote.ts @@ -19,7 +19,7 @@ export async function executeQuote( throw httpErrors.badRequest('Quote not found or expired'); } - const { wallet, inputToken, outputToken, swapRoute } = cached; + const { wallet, inputToken, outputToken, swapRoute, slippagePct } = cached; // Titan instructions are built for a specific wallet; executing them from another wallet // would fail on-chain or move the wrong accounts — require a re-quote instead @@ -43,18 +43,18 @@ export async function executeQuote( ); const { signature } = await solana.sendAndConfirmTransactionForWallet(transaction, walletAddress); - const txData = await solana.connection.getTransaction(signature, { - commitment: 'confirmed', - maxSupportedTransactionVersion: 0, - }); + // Re-fetch with retry; a landed-but-failed transaction throws instead of being + // misreported as confirmed or pending. + const txData = await solana.getConfirmedTransactionData(signature); const result = await solana.handleConfirmation( signature, - txData !== null, txData, inputToken.address, outputToken.address, walletAddress, + undefined, + slippagePct, ); // Remove quote from cache only after successful execution (confirmed) diff --git a/src/connectors/titan/schemas.ts b/src/connectors/titan/schemas.ts index abbf8a2a19..6256e49a7f 100644 --- a/src/connectors/titan/schemas.ts +++ b/src/connectors/titan/schemas.ts @@ -52,7 +52,7 @@ export const TitanQuoteSwapRequest = Type.Object({ approximateIfNoExactOut: Type.Optional( Type.Boolean({ description: - 'For BUY orders: Titan DART is ExactIn-only, so BUYs are approximated via a sell-leg ExactIn quote. If false, BUY requests fail with a clear error.', + 'For BUY orders when the router has no ExactOut route: approximate via a sell-leg ExactIn quote instead of failing', default: true, }), ), @@ -170,7 +170,7 @@ export const TitanExecuteSwapRequest = Type.Object({ approximateIfNoExactOut: Type.Optional( Type.Boolean({ description: - 'For BUY orders: Titan DART is ExactIn-only, so BUYs are approximated via a sell-leg ExactIn quote. If false, BUY requests fail with a clear error.', + 'For BUY orders when the router has no ExactOut route: approximate via a sell-leg ExactIn quote instead of failing', default: true, }), ), diff --git a/src/connectors/uniswap/amm-routes/addLiquidity.ts b/src/connectors/uniswap/amm-routes/addLiquidity.ts index 62b46cdfa8..639e722e40 100644 --- a/src/connectors/uniswap/amm-routes/addLiquidity.ts +++ b/src/connectors/uniswap/amm-routes/addLiquidity.ts @@ -8,6 +8,7 @@ import { re } from 'mathjs'; import { Ethereum } from '../../../chains/ethereum/ethereum'; import { wrapEthereum } from '../../../chains/ethereum/routes/wrap'; import { AddLiquidityResponseType, AddLiquidityResponse } from '../../../schemas/amm-schema'; +import { TransactionStatus } from '../../../schemas/chain-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { UniswapAmmAddLiquidityRequest } from '../schemas'; @@ -31,8 +32,6 @@ async function addLiquidityInternal( baseTokenAmount: number, quoteTokenAmount: number, slippagePct: number = UniswapConfig.config.slippagePct, - gasPrice?: string, - maxGas?: number, ): Promise { const networkToUse = network; @@ -182,8 +181,7 @@ async function addLiquidityInternal( // Add liquidity Token + ETH // Convert gasPrice from wei to gwei if provided - const gasPriceGwei = gasPrice ? parseFloat(utils.formatUnits(gasPrice, 'gwei')) : undefined; - const gasOptions = await ethereum.prepareGasOptions(gasPriceGwei, maxGas || AMM_ADD_LIQUIDITY_GAS_LIMIT); + const gasOptions = await ethereum.prepareGasOptions(undefined, AMM_ADD_LIQUIDITY_GAS_LIMIT); gasOptions.value = quote.rawQuoteTokenAmount; tx = await router.addLiquidityETH( @@ -244,8 +242,7 @@ async function addLiquidityInternal( // Add liquidity Token + Token // Convert gasPrice from wei to gwei if provided - const gasPriceGwei = gasPrice ? parseFloat(utils.formatUnits(gasPrice, 'gwei')) : undefined; - const gasOptions = await ethereum.prepareGasOptions(gasPriceGwei, maxGas || AMM_ADD_LIQUIDITY_GAS_LIMIT); + const gasOptions = await ethereum.prepareGasOptions(undefined, AMM_ADD_LIQUIDITY_GAS_LIMIT); tx = await router.addLiquidity( quote.baseTokenObj.address, @@ -261,19 +258,17 @@ async function addLiquidityInternal( } // Wait for transaction confirmation - const receipt = await ethereum.handleTransactionExecution(tx); - - // Calculate gas fee - const gasFee = formatTokenAmount( - receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), - 18, // ETH has 18 decimals - ); + const outcome = await ethereum.handleTransactionConfirmation(tx); + if (!outcome.confirmed) { + // Still pending — the quoted amounts were computed before sending and have not moved. + return { signature: outcome.signature, status: TransactionStatus.PENDING }; + } return { - signature: receipt.transactionHash, - status: receipt.status, + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, data: { - fee: gasFee, + fee: outcome.fee, baseTokenAmountAdded: quote.baseTokenAmount, quoteTokenAmountAdded: quote.quoteTokenAmount, ...(baseWrapTxHash && { baseWrapTxHash }), @@ -293,8 +288,6 @@ export async function addLiquidity( baseTokenAmount: number, quoteTokenAmount: number, slippagePct: number = UniswapConfig.config.slippagePct, - gasPrice?: string, - maxGas?: number, ): Promise { const poolInfo = await getUniswapPoolInfo(poolAddress, network, 'amm'); if (!poolInfo) throw httpErrors.notFound(`Pool not found: ${poolAddress}`); @@ -308,8 +301,6 @@ export async function addLiquidity( baseTokenAmount, quoteTokenAmount, slippagePct, - gasPrice, - maxGas, ); } @@ -341,8 +332,6 @@ export const addLiquidityRoute: FastifyPluginAsync = async (fastify) => { quoteTokenAmount, slippagePct, walletAddress: requestedWalletAddress, - gasPrice, - maxGas, } = request.body; // Validate essential parameters @@ -369,8 +358,6 @@ export const addLiquidityRoute: FastifyPluginAsync = async (fastify) => { baseTokenAmount, quoteTokenAmount, slippagePct, - gasPrice, - maxGas, ); } catch (e) { logger.error(e); diff --git a/src/connectors/uniswap/amm-routes/createPool.ts b/src/connectors/uniswap/amm-routes/createPool.ts index 945078d9b6..8cf250598c 100644 --- a/src/connectors/uniswap/amm-routes/createPool.ts +++ b/src/connectors/uniswap/amm-routes/createPool.ts @@ -7,6 +7,7 @@ import { FastifyPluginAsync } from 'fastify'; import { Ethereum, TokenInfo } from '../../../chains/ethereum/ethereum'; import { CreatePoolResponse, CreatePoolResponseType } from '../../../schemas/amm-schema'; +import { TransactionStatus } from '../../../schemas/chain-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { UniswapAmmCreatePoolRequest } from '../schemas'; @@ -80,8 +81,6 @@ export async function createPool( baseTokenAmount: number, quoteTokenAmount?: number, initialPrice?: number, - gasPrice?: number, - maxGas?: number, slippagePct: number = UniswapConfig.config.slippagePct, ): Promise { if (baseTokenAmount <= 0) { @@ -178,10 +177,6 @@ export async function createPool( const deadline = Math.floor(Date.now() / 1000) + 60 * 20; // 20 minutes from now - // gasPrice arrives already in gwei (the unit prepareGasOptions expects). The connector's Fastify - // route accepts gasPrice as a wei string (sibling shape) and converts it to gwei before calling. - const gasPriceGwei = gasPrice; - let tx; if (baseIsEth || quoteIsEth) { // One side is ETH/WETH → addLiquidityETH. The ERC20 side needs an allowance to the router; the @@ -204,7 +199,7 @@ export async function createPool( ); } - const gasOptions = await ethereum.prepareGasOptions(gasPriceGwei, maxGas || AMM_CREATE_POOL_GAS_LIMIT); + const gasOptions = await ethereum.prepareGasOptions(undefined, AMM_CREATE_POOL_GAS_LIMIT); gasOptions.value = ethRawAmount; tx = await router.addLiquidityETH( @@ -248,7 +243,7 @@ export async function createPool( ); } - const gasOptions = await ethereum.prepareGasOptions(gasPriceGwei, maxGas || AMM_CREATE_POOL_GAS_LIMIT); + const gasOptions = await ethereum.prepareGasOptions(undefined, AMM_CREATE_POOL_GAS_LIMIT); tx = await router.addLiquidity( baseTokenInfo.address, @@ -265,32 +260,33 @@ export async function createPool( logger.info(`Creating Uniswap V2 pool ${baseTokenInfo.symbol}/${quoteTokenInfo.symbol} via tx ${tx.hash}`); - const receipt = await ethereum.handleTransactionExecution(tx); + // A revert throws out of here (400 TRANSACTION_FAILED) — it is never reported as PENDING. + const outcome = await ethereum.handleTransactionConfirmation(tx); // Read the (now-created) pair address from the factory — authoritative source of the pool address. const pairAddress: string = await factory.getPair(baseTokenInfo.address, quoteTokenInfo.address); - if (receipt && receipt.status === 1) { - const gasFee = formatTokenAmount(receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), 18); // ETH has 18 decimals + if (!outcome.confirmed) { + // Timed out but still broadcasting — report PENDING with the tx hash so the caller can + // reconcile it. The seed amounts below have not moved, so they are deliberately omitted. return { - signature: receipt.transactionHash, - status: 1, // CONFIRMED + signature: outcome.signature, + status: TransactionStatus.PENDING, poolAddress: pairAddress, price: seedPrice, - data: { - fee: gasFee, - baseTokenAmountAdded: baseTokenAmount, - quoteTokenAmountAdded: effectiveQuoteAmount, - }, }; } - // Timed out (still broadcasting) or reverted — report as pending with the tx hash. return { - signature: receipt ? receipt.transactionHash : tx.hash, - status: 0, // PENDING + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, poolAddress: pairAddress, price: seedPrice, + data: { + fee: outcome.fee, + baseTokenAmountAdded: baseTokenAmount, + quoteTokenAmountAdded: effectiveQuoteAmount, + }, }; } @@ -322,8 +318,6 @@ export const createPoolRoute: FastifyPluginAsync = async (fastify) => { quoteTokenAmount, initialPrice, slippagePct, - gasPrice, - maxGas, walletAddress: requestedWalletAddress, } = request.body; @@ -340,9 +334,6 @@ export const createPoolRoute: FastifyPluginAsync = async (fastify) => { logger.info(`Using first available wallet address: ${walletAddress}`); } - // Route accepts gasPrice as a wei string (matching sibling AMM requests); createPool expects gwei. - const gasPriceGwei = gasPrice ? parseFloat(utils.formatUnits(gasPrice, 'gwei')) : undefined; - return await createPool( network, walletAddress, @@ -351,8 +342,6 @@ export const createPoolRoute: FastifyPluginAsync = async (fastify) => { baseTokenAmount, quoteTokenAmount, initialPrice, - gasPriceGwei, - maxGas, slippagePct, ); } catch (e) { diff --git a/src/connectors/uniswap/amm-routes/executeSwap.ts b/src/connectors/uniswap/amm-routes/executeSwap.ts index d9830dd32b..5c0e150593 100644 --- a/src/connectors/uniswap/amm-routes/executeSwap.ts +++ b/src/connectors/uniswap/amm-routes/executeSwap.ts @@ -1,9 +1,10 @@ import { BigNumber, Contract, utils } from 'ethers'; import { FastifyPluginAsync } from 'fastify'; -import { Ethereum } from '../../../chains/ethereum/ethereum'; +import { Ethereum, EthereumTransactionOutcome } from '../../../chains/ethereum/ethereum'; import { EthereumLedger } from '../../../chains/ethereum/ethereum-ledger'; import { getEthereumChainConfig } from '../../../chains/ethereum/ethereum.config'; +import { TransactionStatus } from '../../../schemas/chain-schema'; import { ExecuteSwapRequestType, SwapExecuteResponseType, SwapExecuteResponse } from '../../../schemas/router-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; @@ -86,7 +87,7 @@ export async function executeAmmSwap( // Prepare transaction parameters const deadline = Math.floor(Date.now() / 1000) + 60 * 20; // 20 minutes from now - let receipt; + let outcome: EthereumTransactionOutcome; try { if (isHardwareWallet) { @@ -151,7 +152,7 @@ export async function executeAmmSwap( logger.info(`Transaction sent: ${txResponse.hash}`); // Wait for confirmation with timeout - receipt = await ethereum.handleTransactionExecution(txResponse); + outcome = await ethereum.handleTransactionConfirmation(txResponse); } else { // Regular wallet flow let wallet; @@ -208,19 +209,18 @@ export async function executeAmmSwap( logger.info(`Transaction sent: ${tx.hash}`); // Wait for transaction confirmation - receipt = await ethereum.handleTransactionExecution(tx); + outcome = await ethereum.handleTransactionConfirmation(tx); } - // Check if the transaction was successful - if (receipt.status === 0) { - logger.error(`Transaction failed on-chain. Receipt: ${JSON.stringify(receipt)}`); - throw httpErrors.internalServerError( - 'Transaction reverted on-chain. This could be due to slippage, insufficient funds, or other blockchain issues.', - ); + // A revert threw out of the confirmation helper as a 400 TRANSACTION_FAILED. What is left + // is a transaction that is still pending after the extended poll: report it as PENDING + // with its hash rather than dereferencing a null receipt and losing the hash to a 500. + if (!outcome.confirmed) { + return { signature: outcome.signature, status: TransactionStatus.PENDING }; } - logger.info(`Transaction confirmed: ${receipt.transactionHash}`); - logger.info(`Gas used: ${receipt.gasUsed.toString()}`); + logger.info(`Transaction confirmed: ${outcome.signature}`); + logger.info(`Gas used: ${outcome.receipt.gasUsed.toString()}`); // Calculate amounts using quote values const amountIn = quote.estimatedAmountIn; @@ -230,27 +230,22 @@ export async function executeAmmSwap( const baseTokenBalanceChange = side === 'BUY' ? amountOut : -amountIn; const quoteTokenBalanceChange = side === 'BUY' ? -amountIn : amountOut; - // Calculate gas fee (formatTokenAmount already returns a number) - const gasFee = formatTokenAmount( - receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), - 18, // ETH has 18 decimals - ); - // Determine token addresses for computed fields const tokenIn = quote.inputToken.address; const tokenOut = quote.outputToken.address; return { - signature: receipt.transactionHash, - status: receipt.status, + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, data: { tokenIn, tokenOut, amountIn, amountOut, - fee: gasFee, + fee: outcome.fee, baseTokenBalanceChange, quoteTokenBalanceChange, + slippagePct, }, }; } catch (error) { diff --git a/src/connectors/uniswap/amm-routes/removeLiquidity.ts b/src/connectors/uniswap/amm-routes/removeLiquidity.ts index cfe1f9d645..b5927a044e 100644 --- a/src/connectors/uniswap/amm-routes/removeLiquidity.ts +++ b/src/connectors/uniswap/amm-routes/removeLiquidity.ts @@ -6,6 +6,7 @@ import { FastifyPluginAsync } from 'fastify'; import { Ethereum } from '../../../chains/ethereum/ethereum'; import { RemoveLiquidityResponseType, RemoveLiquidityResponse } from '../../../schemas/amm-schema'; +import { TransactionStatus } from '../../../schemas/chain-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { UniswapAmmRemoveLiquidityRequest } from '../schemas'; @@ -29,8 +30,6 @@ export async function removeLiquidity( poolAddress: string, percentageToRemove: number, slippagePct: number = UniswapConfig.config.slippagePct, - gasPrice?: string, - maxGas?: number, ): Promise { if (!poolAddress || !percentageToRemove) throw httpErrors.badRequest('Missing required parameters'); if (percentageToRemove <= 0 || percentageToRemove > 100) { @@ -85,8 +84,7 @@ export async function removeLiquidity( await checkLPAllowance(ethereum, wallet, poolAddress, routerAddress, liquidityToRemove); const deadline = Math.floor(Date.now() / 1000) + 60 * 20; // 20 minutes from now - const gasPriceGwei = gasPrice ? parseFloat(utils.formatUnits(gasPrice, 'gwei')) : undefined; - const gasOptions = await ethereum.prepareGasOptions(gasPriceGwei, maxGas || AMM_REMOVE_LIQUIDITY_GAS_LIMIT); + const gasOptions = await ethereum.prepareGasOptions(undefined, AMM_REMOVE_LIQUIDITY_GAS_LIMIT); let tx; if (baseTokenObj.symbol === 'WETH') { @@ -122,16 +120,20 @@ export async function removeLiquidity( ); } - const receipt = await ethereum.handleTransactionExecution(tx); + const outcome = await ethereum.handleTransactionConfirmation(tx); + if (!outcome.confirmed) { + // Still pending — the expected amounts were computed before sending and have not moved. + return { signature: outcome.signature, status: TransactionStatus.PENDING }; + } + const baseTokenAmountRemoved = formatTokenAmount(expectedBaseTokenAmount.toString(), baseTokenObj.decimals); const quoteTokenAmountRemoved = formatTokenAmount(expectedQuoteTokenAmount.toString(), quoteTokenObj.decimals); - const gasFee = formatTokenAmount(receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), 18); return { - signature: receipt.transactionHash, - status: receipt.status, + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, data: { - fee: gasFee, + fee: outcome.fee, baseTokenAmountRemoved, quoteTokenAmountRemoved, }, @@ -159,14 +161,7 @@ export const removeLiquidityRoute: FastifyPluginAsync = async (fastify) => { }, async (request) => { try { - const { - network, - poolAddress, - percentageToRemove, - walletAddress: requestedWalletAddress, - gasPrice, - maxGas, - } = request.body; + const { network, poolAddress, percentageToRemove, walletAddress: requestedWalletAddress } = request.body; let walletAddress = requestedWalletAddress; if (!walletAddress) { @@ -177,15 +172,7 @@ export const removeLiquidityRoute: FastifyPluginAsync = async (fastify) => { logger.info(`Using first available wallet address: ${walletAddress}`); } - return await removeLiquidity( - network, - walletAddress, - poolAddress, - percentageToRemove, - undefined, - gasPrice, - maxGas, - ); + return await removeLiquidity(network, walletAddress, poolAddress, percentageToRemove, undefined); } catch (e) { logger.error(e); if (e.statusCode) throw e; diff --git a/src/connectors/uniswap/clmm-routes/addLiquidity.ts b/src/connectors/uniswap/clmm-routes/addLiquidity.ts index 9594bbcd46..2dea1ad872 100644 --- a/src/connectors/uniswap/clmm-routes/addLiquidity.ts +++ b/src/connectors/uniswap/clmm-routes/addLiquidity.ts @@ -7,6 +7,7 @@ import { FastifyPluginAsync } from 'fastify'; import JSBI from 'jsbi'; import { Ethereum } from '../../../chains/ethereum/ethereum'; +import { TransactionStatus } from '../../../schemas/chain-schema'; import { AddLiquidityResponseType, AddLiquidityResponse } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; @@ -152,9 +153,12 @@ export async function addLiquidity( const txParams = await ethereum.prepareGasOptions(undefined, CLMM_ADD_LIQUIDITY_GAS_LIMIT); txParams.value = BigNumber.from(value.toString()); const tx = await positionManagerWithSigner.multicall([calldata], txParams); - const receipt = await ethereum.handleTransactionExecution(tx); + const outcome = await ethereum.handleTransactionConfirmation(tx); + if (!outcome.confirmed) { + // Still pending — the mint amounts below were computed before sending and have not moved. + return { signature: outcome.signature, status: TransactionStatus.PENDING }; + } - const gasFee = formatTokenAmount(receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), 18); const actualToken0Amount = formatTokenAmount(newPosition.mintAmounts.amount0.toString(), token0.decimals); const actualToken1Amount = formatTokenAmount(newPosition.mintAmounts.amount1.toString(), token1.decimals); @@ -162,10 +166,10 @@ export async function addLiquidity( const actualQuoteAmount = isBaseToken0 ? actualToken1Amount : actualToken0Amount; return { - signature: receipt.transactionHash, - status: receipt.status, + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, data: { - fee: gasFee, + fee: outcome.fee, baseTokenAmountAdded: actualBaseAmount, quoteTokenAmountAdded: actualQuoteAmount, }, diff --git a/src/connectors/uniswap/clmm-routes/closePosition.ts b/src/connectors/uniswap/clmm-routes/closePosition.ts index 972dabf7f1..b9ea4feacd 100644 --- a/src/connectors/uniswap/clmm-routes/closePosition.ts +++ b/src/connectors/uniswap/clmm-routes/closePosition.ts @@ -6,6 +6,7 @@ import { FastifyPluginAsync } from 'fastify'; import JSBI from 'jsbi'; import { Ethereum } from '../../../chains/ethereum/ethereum'; +import { TransactionStatus } from '../../../schemas/chain-schema'; import { ClosePositionRequestType, ClosePositionRequest, @@ -153,10 +154,11 @@ export async function closePosition( const tx = await positionManagerWithSigner.multicall([calldata], txParams); // Wait for transaction confirmation - const receipt = await ethereum.handleTransactionExecution(tx); - - // Calculate gas fee - const gasFee = formatTokenAmount(receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), 18); + const outcome = await ethereum.handleTransactionConfirmation(tx); + if (!outcome.confirmed) { + // Still pending — the amounts below were computed before sending and have not moved. + return { signature: outcome.signature, status: TransactionStatus.PENDING }; + } // Calculate token amounts removed including fees const token0AmountRemoved = formatTokenAmount(totalAmount0.quotient.toString(), token0.decimals); @@ -177,10 +179,10 @@ export async function closePosition( const positionRentRefunded = 0; return { - signature: receipt.transactionHash, - status: receipt.status, + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, data: { - fee: gasFee, + fee: outcome.fee, positionRentRefunded, baseTokenAmountRemoved, quoteTokenAmountRemoved, diff --git a/src/connectors/uniswap/clmm-routes/collectFees.ts b/src/connectors/uniswap/clmm-routes/collectFees.ts index 718d9bb8e8..58918b30ad 100644 --- a/src/connectors/uniswap/clmm-routes/collectFees.ts +++ b/src/connectors/uniswap/clmm-routes/collectFees.ts @@ -5,6 +5,7 @@ import { BigNumber } from 'ethers'; import { FastifyPluginAsync } from 'fastify'; import { Ethereum } from '../../../chains/ethereum/ethereum'; +import { TransactionStatus } from '../../../schemas/chain-schema'; import { CollectFeesRequestType, CollectFeesRequest, @@ -114,10 +115,11 @@ export async function collectFees( const tx = await positionManagerWithSigner.multicall([calldata], txParams); // Wait for transaction confirmation - const receipt = await ethereum.handleTransactionExecution(tx); - - // Calculate gas fee - const gasFee = formatTokenAmount(receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), 18); + const outcome = await ethereum.handleTransactionConfirmation(tx); + if (!outcome.confirmed) { + // Still pending — the fee amounts below were read before sending and have not moved. + return { signature: outcome.signature, status: TransactionStatus.PENDING }; + } // Calculate fee amounts collected const token0FeeAmount = formatTokenAmount(feeAmount0.toString(), token0.decimals); @@ -128,10 +130,10 @@ export async function collectFees( const quoteFeeAmountCollected = isBaseToken0 ? token1FeeAmount : token0FeeAmount; return { - signature: receipt.transactionHash, - status: receipt.status, + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, data: { - fee: gasFee, + fee: outcome.fee, baseFeeAmountCollected, quoteFeeAmountCollected, }, diff --git a/src/connectors/uniswap/clmm-routes/createPool.ts b/src/connectors/uniswap/clmm-routes/createPool.ts index 7ef1320b2d..5c11303419 100644 --- a/src/connectors/uniswap/clmm-routes/createPool.ts +++ b/src/connectors/uniswap/clmm-routes/createPool.ts @@ -7,7 +7,8 @@ import { FastifyPluginAsync } from 'fastify'; import JSBI from 'jsbi'; import { Ethereum, TokenInfo } from '../../../chains/ethereum/ethereum'; -import { CreatePoolResponse, CreatePoolResponseType } from '../../../schemas/amm-schema'; +import { TransactionStatus } from '../../../schemas/chain-schema'; +import { CreatePoolResponse, CreatePoolResponseType } from '../../../schemas/clmm-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; import { UniswapClmmCreatePoolRequest } from '../schemas'; @@ -18,7 +19,6 @@ import { getUniswapV3FactoryAddress, getUniswapV3NftManagerAddress, } from '../uniswap.contracts'; -import { formatTokenAmount } from '../uniswap.utils'; // Uniswap V3 supported fee tiers (hundredths of a bip). 100=0.01%, 500=0.05%, 3000=0.30%, 10000=1.00%. const VALID_FEE_TIERS = [100, 500, 3000, 10000]; @@ -77,8 +77,6 @@ export async function createPool( quoteToken: string, initialPrice?: number, fee?: number, - gasPrice?: number, - maxGas?: number, ): Promise { // Validate the fee tier — V3 only accepts a fixed set of tiers, each mapped to a tick spacing. if (fee === undefined) { @@ -176,7 +174,7 @@ export async function createPool( const nftManagerAddress = getUniswapV3NftManagerAddress(network); const nftManager = new Contract(nftManagerAddress, INftManagerCreatePoolABI, wallet); - const gasOptions = await ethereum.prepareGasOptions(gasPrice, maxGas || CLMM_CREATE_POOL_GAS_LIMIT); + const gasOptions = await ethereum.prepareGasOptions(undefined, CLMM_CREATE_POOL_GAS_LIMIT); const tx = await nftManager.createAndInitializePoolIfNecessary( token0.address, @@ -188,32 +186,30 @@ export async function createPool( logger.info(`Creating Uniswap V3 pool via tx ${tx.hash}`); - const receipt = await ethereum.handleTransactionExecution(tx); + // A revert throws out of here (400 TRANSACTION_FAILED) — it is never reported as PENDING. + const outcome = await ethereum.handleTransactionConfirmation(tx); // Read the (now-created) pool address from the factory — the authoritative source. const poolAddress: string = await factory.getPool(token0.address, token1.address, fee); - if (receipt && receipt.status === 1) { - const gasFee = formatTokenAmount(receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), 18); // ETH has 18 decimals + if (!outcome.confirmed) { + // Timed out but still broadcasting — report PENDING with the tx hash so the caller can reconcile it. return { - signature: receipt.transactionHash, - status: 1, // CONFIRMED + signature: outcome.signature, + status: TransactionStatus.PENDING, poolAddress, price: seedPrice, - data: { - fee: gasFee, - baseTokenAmountAdded: 0, // create-pool only initializes price; no liquidity is seeded - quoteTokenAmountAdded: 0, - }, }; } - // Timed out (still broadcasting) or reverted — report as pending with the tx hash. return { - signature: receipt ? receipt.transactionHash : tx.hash, - status: 0, // PENDING + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, poolAddress, price: seedPrice, + data: { + fee: outcome.fee, + }, }; } @@ -243,8 +239,6 @@ export const createPoolRoute: FastifyPluginAsync = async (fastify) => { quoteToken, fee, initialPrice, - gasPrice, - maxGas, walletAddress: requestedWalletAddress, } = request.body; @@ -261,10 +255,7 @@ export const createPoolRoute: FastifyPluginAsync = async (fastify) => { logger.info(`Using first available wallet address: ${walletAddress}`); } - // Route accepts gasPrice as a wei string (matching sibling requests); createPool expects gwei. - const gasPriceGwei = gasPrice ? parseFloat(utils.formatUnits(gasPrice, 'gwei')) : undefined; - - return await createPool(network, walletAddress, baseToken, quoteToken, initialPrice, fee, gasPriceGwei, maxGas); + return await createPool(network, walletAddress, baseToken, quoteToken, initialPrice, fee); } catch (e) { logger.error(e); if (e.statusCode) { diff --git a/src/connectors/uniswap/clmm-routes/executeSwap.ts b/src/connectors/uniswap/clmm-routes/executeSwap.ts index 152d02e0d7..33b404a69c 100644 --- a/src/connectors/uniswap/clmm-routes/executeSwap.ts +++ b/src/connectors/uniswap/clmm-routes/executeSwap.ts @@ -1,8 +1,9 @@ import { BigNumber, Contract, utils } from 'ethers'; import { FastifyPluginAsync } from 'fastify'; -import { Ethereum } from '../../../chains/ethereum/ethereum'; +import { Ethereum, EthereumTransactionOutcome } from '../../../chains/ethereum/ethereum'; import { EthereumLedger } from '../../../chains/ethereum/ethereum-ledger'; +import { TransactionStatus } from '../../../schemas/chain-schema'; import { ExecuteSwapRequestType, SwapExecuteResponseType, SwapExecuteResponse } from '../../../schemas/router-schema'; import { httpErrors } from '../../../services/error-handler'; import { logger } from '../../../services/logger'; @@ -103,7 +104,7 @@ export async function executeClmmSwap( sqrtPriceLimitX96: '0', }; - let receipt; + let outcome: EthereumTransactionOutcome; try { if (isHardwareWallet) { @@ -180,7 +181,7 @@ export async function executeClmmSwap( logger.info(`Transaction sent: ${txResponse.hash}`); // Wait for confirmation with timeout - receipt = await ethereum.handleTransactionExecution(txResponse); + outcome = await ethereum.handleTransactionConfirmation(txResponse); } else { // Regular wallet flow let wallet; @@ -242,19 +243,18 @@ export async function executeClmmSwap( logger.info(`Transaction sent: ${tx.hash}`); // Wait for transaction confirmation - receipt = await ethereum.handleTransactionExecution(tx); + outcome = await ethereum.handleTransactionConfirmation(tx); } - // Check if the transaction was successful - if (receipt.status === 0) { - logger.error(`Transaction failed on-chain. Receipt: ${JSON.stringify(receipt)}`); - throw httpErrors.internalServerError( - 'Transaction reverted on-chain. This could be due to slippage, insufficient funds, or other blockchain issues.', - ); + // A revert threw out of the confirmation helper as a 400 TRANSACTION_FAILED. What is left + // is a transaction that is still pending after the extended poll: report it as PENDING + // with its hash rather than dereferencing a null receipt and losing the hash to a 500. + if (!outcome.confirmed) { + return { signature: outcome.signature, status: TransactionStatus.PENDING }; } - logger.info(`Transaction confirmed: ${receipt.transactionHash}`); - logger.info(`Gas used: ${receipt.gasUsed.toString()}`); + logger.info(`Transaction confirmed: ${outcome.signature}`); + logger.info(`Gas used: ${outcome.receipt.gasUsed.toString()}`); // Calculate amounts using quote values const amountIn = quote.estimatedAmountIn; @@ -264,27 +264,22 @@ export async function executeClmmSwap( const baseTokenBalanceChange = side === 'BUY' ? amountOut : -amountIn; const quoteTokenBalanceChange = side === 'BUY' ? -amountIn : amountOut; - // Calculate gas fee (formatTokenAmount already returns a number) - const gasFee = formatTokenAmount( - receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), - 18, // ETH has 18 decimals - ); - // Determine token addresses for computed fields const tokenIn = quote.inputToken.address; const tokenOut = quote.outputToken.address; return { - signature: receipt.transactionHash, - status: receipt.status, + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, data: { tokenIn, tokenOut, amountIn, amountOut, - fee: gasFee, + fee: outcome.fee, baseTokenBalanceChange, quoteTokenBalanceChange, + slippagePct, }, }; } catch (error) { diff --git a/src/connectors/uniswap/clmm-routes/openPosition.ts b/src/connectors/uniswap/clmm-routes/openPosition.ts index 108987b81f..d98166b4ce 100644 --- a/src/connectors/uniswap/clmm-routes/openPosition.ts +++ b/src/connectors/uniswap/clmm-routes/openPosition.ts @@ -9,6 +9,7 @@ import JSBI from 'jsbi'; const CLMM_OPEN_POSITION_GAS_LIMIT = 600000; import { Ethereum } from '../../../chains/ethereum/ethereum'; +import { TransactionStatus } from '../../../schemas/chain-schema'; import { OpenPositionRequestType, OpenPositionRequest, @@ -235,11 +236,15 @@ export async function openPosition( } // Wait for transaction confirmation - const receipt = await ethereum.handleTransactionExecution(tx); + const outcome = await ethereum.handleTransactionConfirmation(tx); + if (!outcome.confirmed) { + // Still pending — there is no mint log to read yet, so no positionAddress and no amounts. + return { signature: outcome.signature, status: TransactionStatus.PENDING }; + } // Find the NFT ID from the transaction logs let positionId = ''; - for (const log of receipt.logs) { + for (const log of outcome.receipt.logs) { if ( log.address.toLowerCase() === positionManagerAddress.toLowerCase() && log.topics[0] === '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef' && @@ -250,8 +255,15 @@ export async function openPosition( } } - // Calculate gas fee - const gasFee = formatTokenAmount(receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), 18); + if (!positionId) { + // The transaction confirmed but no NFT-mint Transfer log was found. Returning a CONFIRMED + // response with an empty positionAddress would have the caller record a position it can + // never address — fail loudly, naming the transaction so the position stays recoverable. + throw httpErrors.internalServerError( + `Position opened in transaction ${outcome.signature} but no position NFT mint was found in its logs. ` + + `Inspect the transaction to recover the position ID.`, + ); + } // For position rent, we're using the estimated gas cost since Ethereum doesn't have rent like Solana const positionRent = 0; @@ -265,10 +277,10 @@ export async function openPosition( const quoteAmountUsed = isBaseToken0 ? actualToken1Amount : actualToken0Amount; return { - signature: receipt.transactionHash, - status: receipt.status, + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, data: { - fee: gasFee, + fee: outcome.fee, positionAddress: positionId, positionRent, baseTokenAmountAdded: baseAmountUsed, @@ -364,8 +376,9 @@ export const openPositionRoute: FastifyPluginAsync = async (fastify) => { throw httpErrors.badRequest('Insufficient funds to complete the transaction'); } - // Generic error - throw httpErrors.internalServerError('Failed to open position'); + // Generic error — keep the underlying message. Dropping it hid every real cause + // (including a lost transaction hash) behind an unactionable 'Failed to open position'. + throw httpErrors.internalServerError(`Failed to open position: ${e.message ?? String(e)}`); } }, ); diff --git a/src/connectors/uniswap/clmm-routes/removeLiquidity.ts b/src/connectors/uniswap/clmm-routes/removeLiquidity.ts index 0198ea2d42..80313396e5 100644 --- a/src/connectors/uniswap/clmm-routes/removeLiquidity.ts +++ b/src/connectors/uniswap/clmm-routes/removeLiquidity.ts @@ -6,6 +6,7 @@ import { FastifyPluginAsync } from 'fastify'; import JSBI from 'jsbi'; import { Ethereum } from '../../../chains/ethereum/ethereum'; +import { TransactionStatus } from '../../../schemas/chain-schema'; import { RemoveLiquidityRequestType, RemoveLiquidityRequest, @@ -164,10 +165,11 @@ export async function removeLiquidity( const tx = await positionManagerWithSigner.multicall([calldata], txParams); // Wait for transaction confirmation - const receipt = await ethereum.handleTransactionExecution(tx); - - // Calculate gas fee - const gasFee = formatTokenAmount(receipt.gasUsed.mul(receipt.effectiveGasPrice).toString(), 18); + const outcome = await ethereum.handleTransactionConfirmation(tx); + if (!outcome.confirmed) { + // Still pending — the amounts below were computed before sending and have not moved. + return { signature: outcome.signature, status: TransactionStatus.PENDING }; + } // Calculate token amounts removed including fees const token0AmountRemoved = formatTokenAmount(totalAmount0.quotient.toString(), token0.decimals); @@ -178,10 +180,10 @@ export async function removeLiquidity( const quoteTokenAmountRemoved = isBaseToken0 ? token1AmountRemoved : token0AmountRemoved; return { - signature: receipt.transactionHash, - status: receipt.status, + signature: outcome.signature, + status: TransactionStatus.CONFIRMED, data: { - fee: gasFee, + fee: outcome.fee, baseTokenAmountRemoved, quoteTokenAmountRemoved, }, diff --git a/src/connectors/uniswap/router-routes/executeQuote.ts b/src/connectors/uniswap/router-routes/executeQuote.ts index 7f6e4f8739..ce43d08b42 100644 --- a/src/connectors/uniswap/router-routes/executeQuote.ts +++ b/src/connectors/uniswap/router-routes/executeQuote.ts @@ -22,7 +22,7 @@ async function executeQuote(walletAddress: string, network: string, quoteId: str } const { quote, request } = cached; - const { inputToken, outputToken, side, amount } = request; + const { inputToken, outputToken, side, amount, slippagePct } = request; const ethereum = await Ethereum.getInstance(network); @@ -277,6 +277,7 @@ async function executeQuote(walletAddress: string, network: string, quoteId: str expectedAmountOut, side, txHash, + slippagePct, ); // Handle different transaction states diff --git a/src/connectors/uniswap/schemas.ts b/src/connectors/uniswap/schemas.ts index e17603a95e..535174c571 100644 --- a/src/connectors/uniswap/schemas.ts +++ b/src/connectors/uniswap/schemas.ts @@ -197,17 +197,6 @@ export const UniswapAmmAddLiquidityRequest = Type.Object({ default: UniswapConfig.config.slippagePct, }), ), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [300000], - }), - ), }); // Uniswap AMM Create Pool Request @@ -258,17 +247,6 @@ export const UniswapAmmCreatePoolRequest = Type.Object({ default: UniswapConfig.config.slippagePct, }), ), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [300000], - }), - ), }); // Uniswap CLMM Create Pool Request (Uniswap V3) @@ -306,17 +284,6 @@ export const UniswapClmmCreatePoolRequest = Type.Object({ 'unified swap router so the pool opens on-market and is not immediately arbitraged.', }), ), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [600000], - }), - ), }); // Uniswap AMM Remove Liquidity Request @@ -342,17 +309,6 @@ export const UniswapAmmRemoveLiquidityRequest = Type.Object({ maximum: 100, description: 'Percentage of liquidity to remove', }), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [300000], - }), - ), }); // Uniswap AMM Execute Swap Request @@ -490,17 +446,6 @@ export const UniswapClmmOpenPositionRequest = Type.Object({ default: UniswapConfig.config.slippagePct, }), ), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [300000], - }), - ), }); // Uniswap CLMM Add Liquidity Request @@ -535,17 +480,6 @@ export const UniswapClmmAddLiquidityRequest = Type.Object({ default: UniswapConfig.config.slippagePct, }), ), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [300000], - }), - ), }); // Uniswap CLMM Remove Liquidity Request @@ -571,17 +505,6 @@ export const UniswapClmmRemoveLiquidityRequest = Type.Object({ maximum: 100, description: 'Percentage of liquidity to remove', }), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [300000], - }), - ), }); // Uniswap CLMM Close Position Request @@ -602,17 +525,6 @@ export const UniswapClmmClosePositionRequest = Type.Object({ positionAddress: Type.String({ description: 'NFT token ID of the position to close', }), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [300000], - }), - ), }); // Uniswap CLMM Collect Fees Request @@ -633,17 +545,6 @@ export const UniswapClmmCollectFeesRequest = Type.Object({ positionAddress: Type.String({ description: 'NFT token ID of the position', }), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [300000], - }), - ), }); // Uniswap CLMM Execute Swap Request @@ -692,15 +593,4 @@ export const UniswapClmmExecuteSwapRequest = Type.Object({ default: UniswapConfig.config.slippagePct, }), ), - gasPrice: Type.Optional( - Type.String({ - description: 'Gas price in wei for the transaction', - }), - ), - maxGas: Type.Optional( - Type.Number({ - description: 'Maximum gas limit for the transaction', - examples: [300000], - }), - ), }); diff --git a/src/connectors/uniswap/uniswap.utils.ts b/src/connectors/uniswap/uniswap.utils.ts index eb24b37819..787f8cadf7 100644 --- a/src/connectors/uniswap/uniswap.utils.ts +++ b/src/connectors/uniswap/uniswap.utils.ts @@ -7,7 +7,9 @@ import { FastifyInstance } from 'fastify'; import JSBI from 'jsbi'; import { TokenInfo, Ethereum } from '../../chains/ethereum/ethereum'; +import { BinLiquidity } from '../../schemas/clmm-schema'; import { logger } from '../../services/logger'; +import { computeV3BinDistribution, V3SqrtPriceMath, V3TickMath } from '../clmm-v3-utils'; import { Uniswap } from './uniswap'; import { UniswapConfig } from './uniswap.config'; @@ -304,32 +306,35 @@ export async function getUniswapPoolInfo( /** * Per-bin liquidity distribution around the current tick for a Uniswap V3 pool. - * - * Uniswap V3 has no equivalent of Orca's "fetch all positions for pool" RPC — - * positions are NFTs on the NonfungiblePositionManager. Instead we walk the - * pool's per-tick liquidity profile directly: - * - * 1. Read `liquidityNet` at every bin boundary in the window via parallel - * `pool.ticks(tick)` reads (one eth_call each, fired with Promise.all). - * Ticks that have never been initialized return zeros — that's harmless. - * 2. Start with the pool's active L = pool.liquidity() in the bin that - * contains the current tick. Propagate L outward by adding/subtracting - * `liquidityNet` at each boundary crossed (per the V3 spec). - * 3. For each bin, convert L → (amount0, amount1) via - * SqrtPriceMath.getAmount{0,1}Delta(sqrtA, sqrtB, L, false), splitting - * at the pool's current sqrtPriceX96 when the bin straddles the - * active tick. - * 4. Map to base/quote using `isBaseToken0`, scale by decimals. - * - * Output shape mirrors Meteora's `pool-info.bins[]`: - * { binId, price, baseTokenAmount, quoteTokenAmount } + * The tick walk lives in the shared V3 helper (`clmm-v3-utils`); Uniswap's SDK is + * JSBI-based while the helper works in native bigint, so the math is adapted here. */ -export interface UniswapBinDistributionEntry { - binId: number; - price: number; - baseTokenAmount: number; - quoteTokenAmount: number; -} +export type UniswapBinDistributionEntry = BinLiquidity; + +const uniswapTickMath: V3TickMath = { + getSqrtRatioAtTick: (tick) => BigInt(TickMath.getSqrtRatioAtTick(tick).toString()), +}; + +const uniswapSqrtPriceMath: V3SqrtPriceMath = { + getAmount0Delta: (a, b, l, roundUp) => + BigInt( + SqrtPriceMath.getAmount0Delta( + JSBI.BigInt(a.toString()), + JSBI.BigInt(b.toString()), + JSBI.BigInt(l.toString()), + roundUp, + ).toString(), + ), + getAmount1Delta: (a, b, l, roundUp) => + BigInt( + SqrtPriceMath.getAmount1Delta( + JSBI.BigInt(a.toString()), + JSBI.BigInt(b.toString()), + JSBI.BigInt(l.toString()), + roundUp, + ).toString(), + ), +}; export async function computeUniswapBinDistribution(args: { poolContract: Contract; @@ -342,84 +347,11 @@ export async function computeUniswapBinDistribution(args: { isBaseToken0: boolean; binCount: number; }): Promise { - const { - poolContract, - tickSpacing, - currentTick, - currentSqrtPriceX96, - activeLiquidity, - decimals0, - decimals1, - isBaseToken0, - binCount, - } = args; - if (binCount <= 0) return []; - - const halfBins = Math.floor(binCount / 2); - const snapped = Math.floor(currentTick / tickSpacing) * tickSpacing; - const firstBinStart = snapped - halfBins * tickSpacing; - const boundaries: number[] = []; - for (let i = 0; i <= binCount; i++) { - boundaries.push(firstBinStart + i * tickSpacing); - } - - // Parallel reads of pool.ticks(tick) at each boundary. Non-initialized - // ticks return zeros which is the correct neutral element for liquidityNet. - const tickData = await Promise.all( - boundaries.map((tick) => poolContract.ticks(tick).catch(() => ({ liquidityNet: 0 }))), - ); - - const curIdx = Math.floor((currentTick - firstBinStart) / tickSpacing); - - // Propagate L outward from the current bin (V3 spec: crossing a tick going - // UP adds liquidityNet, going DOWN subtracts it). - const binLs: JSBI[] = new Array(binCount); - binLs[curIdx] = activeLiquidity; - for (let i = curIdx + 1; i < binCount; i++) { - const net = JSBI.BigInt(tickData[i].liquidityNet.toString()); - binLs[i] = JSBI.add(binLs[i - 1], net); - } - for (let i = curIdx - 1; i >= 0; i--) { - const net = JSBI.BigInt(tickData[i + 1].liquidityNet.toString()); - binLs[i] = JSBI.subtract(binLs[i + 1], net); - } - - const scale0 = Math.pow(10, decimals0); - const scale1 = Math.pow(10, decimals1); - const zero = JSBI.BigInt(0); - const bins: UniswapBinDistributionEntry[] = []; - for (let i = 0; i < binCount; i++) { - const tickStart = boundaries[i]; - const tickEnd = boundaries[i + 1]; - const L = binLs[i]; - let amount0: JSBI = zero; - let amount1: JSBI = zero; - if (JSBI.greaterThan(L, zero)) { - const sqrtA = TickMath.getSqrtRatioAtTick(tickStart); - const sqrtB = TickMath.getSqrtRatioAtTick(tickEnd); - if (currentTick >= tickEnd) { - amount1 = SqrtPriceMath.getAmount1Delta(sqrtA, sqrtB, L, false); - } else if (currentTick < tickStart) { - amount0 = SqrtPriceMath.getAmount0Delta(sqrtA, sqrtB, L, false); - } else { - amount0 = SqrtPriceMath.getAmount0Delta(currentSqrtPriceX96, sqrtB, L, false); - amount1 = SqrtPriceMath.getAmount1Delta(sqrtA, currentSqrtPriceX96, L, false); - } - } - const amt0 = parseFloat(amount0.toString()) / scale0; - const amt1 = parseFloat(amount1.toString()) / scale1; - const baseTokenAmount = isBaseToken0 ? amt0 : amt1; - const quoteTokenAmount = isBaseToken0 ? amt1 : amt0; - - // Price at tickStart in human units (quote/base regardless of token order). - const rawT1PerT0 = Math.pow(1.0001, tickStart) * Math.pow(10, decimals0 - decimals1); - const price = isBaseToken0 ? rawT1PerT0 : 1 / rawT1PerT0; - bins.push({ - binId: tickStart, - price, - baseTokenAmount, - quoteTokenAmount, - }); - } - return bins; + return computeV3BinDistribution({ + ...args, + currentSqrtPriceX96: BigInt(args.currentSqrtPriceX96.toString()), + activeLiquidity: BigInt(args.activeLiquidity.toString()), + tickMath: uniswapTickMath, + sqrtPriceMath: uniswapSqrtPriceMath, + }); } diff --git a/src/schemas/amm-schema.ts b/src/schemas/amm-schema.ts index a9b678ff90..79a0062540 100644 --- a/src/schemas/amm-schema.ts +++ b/src/schemas/amm-schema.ts @@ -283,6 +283,9 @@ export const ExecuteSwapResponse = Type.Object( fee: Type.Number(), baseTokenBalanceChange: Type.Number(), quoteTokenBalanceChange: Type.Number(), + slippagePct: Type.Optional( + Type.Number({ description: 'Slippage tolerance percentage actually applied to the swap' }), + ), }), ), }, diff --git a/src/schemas/chain-schema.ts b/src/schemas/chain-schema.ts index 6e7f49d896..400cd4e878 100644 --- a/src/schemas/chain-schema.ts +++ b/src/schemas/chain-schema.ts @@ -94,12 +94,23 @@ export const PollRequestSchema = Type.Object( ); export type PollRequestType = Static; +// Values reported in PollResponse.txStatus, shared by all chains. +export enum TransactionStatusCode { + NOT_FOUND = -2, // unknown to the chain: never received or dropped + FAILED = -1, // landed with an error / reverted + PENDING = 0, // seen by the chain, awaiting confirmation + CONFIRMED = 1, // landed without error +} + export const PollResponseSchema = Type.Object( { currentBlock: Type.Number(), signature: Type.String(), txBlock: Type.Union([Type.Number(), Type.Null()]), - txStatus: Type.Number({ description: 'Transaction status: 1 = confirmed, 0 = pending, -1 = failed' }), + txStatus: Type.Number({ + 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: Type.Union([Type.Number(), Type.Null()]), error: Type.Union([Type.String({ description: 'Error info if failed: "TYPE (code): message"' }), Type.Null()]), txData: Type.Union([Type.Record(Type.String(), Type.Any()), Type.Null()]), @@ -211,6 +222,11 @@ export const ChainExecuteSwapResponseSchema = Type.Object( quoteTokenBalanceChange: Type.Number({ description: 'Change in quote token balance (negative for decrease)', }), + slippagePct: Type.Optional( + Type.Number({ + description: 'Slippage tolerance percentage actually applied to the swap', + }), + ), }), ), }, diff --git a/src/schemas/clmm-schema.ts b/src/schemas/clmm-schema.ts index ce1fc94f30..30c8d56999 100644 --- a/src/schemas/clmm-schema.ts +++ b/src/schemas/clmm-schema.ts @@ -153,8 +153,6 @@ export const PositionInfoSchema = Type.Object( lowerPrice: Type.Number(), upperPrice: Type.Number(), price: Type.Number(), - rewardTokenAddress: Type.Optional(Type.String()), - rewardAmount: Type.Optional(Type.Number()), }, { $id: 'PositionInfo' }, ); @@ -324,6 +322,72 @@ export const ClosePositionResponse = Type.Object( ); export type ClosePositionResponseType = Static; +// ======================================== +// CLMM Create Pool Types +// ======================================== + +// One fee-tier vocabulary across connectors: binStep is the bin/tick granularity +// (Meteora DLMM bin step, Orca tick spacing), feeBps the base fee in basis points +// (Meteora DLMM base fee; Uniswap/PancakeSwap V3 tier — 1, 5, 30 or 100 bps, +// PancakeSwap also 25), ammConfigIndex the Raydium-family fee-config index +// (Raydium API config list; pancakeswap-sol amm_config PDA index). +export const CreatePoolRequest = Type.Object( + { + network: Type.Optional(Type.String()), + walletAddress: Type.Optional(Type.String()), + baseToken: Type.String(), + quoteToken: Type.String(), + initialPrice: Type.Optional( + Type.Number({ + 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.', + }), + ), + binStep: Type.Optional( + Type.Number({ + description: 'Bin/tick granularity: Meteora DLMM bin step (bps); Orca Whirlpool tick spacing.', + }), + ), + feeBps: Type.Optional( + Type.Number({ + description: + 'Base fee in basis points: Meteora DLMM base fee; Uniswap/PancakeSwap V3 fee tier ' + + '(1, 5, 30 or 100 bps; PancakeSwap also 25).', + }), + ), + ammConfigIndex: Type.Optional( + Type.Number({ + description: + 'Fee-config index for the Raydium CLMM family: Raydium API config list index; ' + + 'pancakeswap-sol amm_config PDA index. Default 0.', + }), + ), + }, + { $id: 'ClmmCreatePoolRequest' }, +); +export type CreatePoolRequestType = Static; + +// CLMM create-pool initializes an EMPTY pool — liquidity arrives later via +// open-position — so unlike the AMM response there are no seeded amounts. +export const CreatePoolResponse = Type.Object( + { + signature: Type.String(), + status: Type.Number({ description: 'TransactionStatus enum value' }), + poolAddress: Type.String({ description: 'Address of the newly created pool' }), + price: Type.Optional(Type.Number({ description: 'Initial price the pool was initialized at (quote per base)' })), + + // Only included when status = CONFIRMED + data: Type.Optional( + Type.Object({ + fee: Type.Number(), + }), + ), + }, + { $id: 'ClmmCreatePoolResponse' }, +); +export type CreatePoolResponseType = Static; + export const QuotePositionRequest = Type.Omit(OpenPositionRequest, ['walletAddress'], { $id: 'QuotePositionRequest' }); export type QuotePositionRequestType = Static; @@ -428,6 +492,9 @@ export const ExecuteSwapResponse = Type.Object( fee: Type.Number(), baseTokenBalanceChange: Type.Number(), quoteTokenBalanceChange: Type.Number(), + slippagePct: Type.Optional( + Type.Number({ description: 'Slippage tolerance percentage actually applied to the swap' }), + ), }), ), }, diff --git a/src/schemas/router-schema.ts b/src/schemas/router-schema.ts index 9ee6cc952f..9fb382d98e 100644 --- a/src/schemas/router-schema.ts +++ b/src/schemas/router-schema.ts @@ -183,6 +183,11 @@ export const SwapExecuteResponse = Type.Object( quoteTokenBalanceChange: Type.Number({ description: 'Change in quote token balance (negative for decrease)', }), + slippagePct: Type.Optional( + Type.Number({ + description: 'Slippage tolerance percentage actually applied to the swap', + }), + ), }), ), }, diff --git a/src/services/error-handler.ts b/src/services/error-handler.ts index dfb4911592..49d4baf5b1 100644 --- a/src/services/error-handler.ts +++ b/src/services/error-handler.ts @@ -5,6 +5,7 @@ export const ErrorCode = { TRANSACTION_TIMEOUT: 'TRANSACTION_TIMEOUT', // Retryable - tx may have succeeded SIMULATION_FAILED: 'SIMULATION_FAILED', // Non-retryable - tx would fail on-chain + TRANSACTION_FAILED: 'TRANSACTION_FAILED', // Non-retryable - tx landed on-chain but failed (fees were paid) INSUFFICIENT_BALANCE: 'INSUFFICIENT_BALANCE', // Non-retryable - not enough funds INVALID_PARAMS: 'INVALID_PARAMS', // Non-retryable - bad request params SLIPPAGE_EXCEEDED: 'SLIPPAGE_EXCEEDED', // Non-retryable - price moved too much @@ -88,6 +89,10 @@ export function simulationFailed(message: string): HttpError { return new HttpError(400, message, ErrorCode.SIMULATION_FAILED); } +export function transactionFailed(message: string): HttpError { + return new HttpError(400, message, ErrorCode.TRANSACTION_FAILED); +} + export function insufficientBalance(message: string): HttpError { return new HttpError(400, message, ErrorCode.INSUFFICIENT_BALANCE); } @@ -115,6 +120,7 @@ export const httpErrors = { forbidden, transactionTimeout, simulationFailed, + transactionFailed, insufficientBalance, slippageExceeded, noRouteFound, diff --git a/src/services/quote-cache.ts b/src/services/quote-cache.ts index c9ea946a11..05bf1ba8cd 100644 --- a/src/services/quote-cache.ts +++ b/src/services/quote-cache.ts @@ -41,6 +41,20 @@ class QuoteCache { return cached.quote; } + /** + * Get the original request data stored alongside a quote + * @param quoteId The unique quote identifier + * @returns The cached request data or null if not found + */ + public getRequest(quoteId: string): any | null { + const cached = this.cache.get(quoteId); + if (!cached) { + return null; + } + + return cached.request; + } + /** * Store a quote in cache * @param quoteId The unique quote identifier diff --git a/src/templates/chains/ethereum/bsc.yml b/src/templates/chains/ethereum/bsc.yml index 567f5157b9..be5392b1c6 100644 --- a/src/templates/chains/ethereum/bsc.yml +++ b/src/templates/chains/ethereum/bsc.yml @@ -1,6 +1,6 @@ chainID: 56 geckoId: bsc -nodeURL: https://binance.llamarpc.com +nodeURL: https://bsc-dataseed.bnbchain.org nativeCurrencySymbol: BNB transactionExecutionTimeoutMs: 10000 # Timeout for waiting for transaction execution (in milliseconds) swapProvider: pancakeswap/router diff --git a/src/templates/chains/ethereum/mainnet.yml b/src/templates/chains/ethereum/mainnet.yml index 1e0677a034..86b848df4c 100644 --- a/src/templates/chains/ethereum/mainnet.yml +++ b/src/templates/chains/ethereum/mainnet.yml @@ -1,5 +1,5 @@ chainID: 1 -nodeURL: https://eth.llamarpc.com +nodeURL: https://eth-mainnet.g.alchemy.com/public nativeCurrencySymbol: ETH geckoId: eth transactionExecutionTimeoutMs: 10000 # Timeout for waiting for transaction execution (in milliseconds) diff --git a/src/trading/clmm/pools.ts b/src/trading/clmm/pools.ts index bd35c68926..d8eaea3353 100644 --- a/src/trading/clmm/pools.ts +++ b/src/trading/clmm/pools.ts @@ -11,6 +11,7 @@ import { getPoolInfo as raydiumGetPoolInfo } from '../../connectors/raydium/clmm import { getPoolInfo as uniswapGetPoolInfo } from '../../connectors/uniswap/clmm-routes/poolInfo'; import { PoolInfo, PoolInfoSchema } from '../../schemas/clmm-schema'; import { logger } from '../../services/logger'; +import { chainNetworkField, CLMM_CONNECTORS, connectorField, parseChainNetwork, rethrowRouteError } from '../common'; // Constants for examples (using Meteora CLMM values) const CLMM_POOL_ADDRESS_EXAMPLE = '2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3'; @@ -19,43 +20,27 @@ const CLMM_POOL_ADDRESS_EXAMPLE = '2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3' * Unified pool info request schema */ const UnifiedPoolInfoRequestSchema = Type.Object({ - connector: Type.String({ - description: 'CLMM connector (raydium, meteora, pancakeswap-sol, uniswap, pancakeswap, orca)', - enum: ['raydium', 'meteora', 'pancakeswap-sol', 'uniswap', 'pancakeswap', 'orca'], - default: 'meteora', - examples: ['meteora'], - }), - chainNetwork: Type.String({ - description: 'Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', - default: 'solana-mainnet-beta', - examples: ['solana-mainnet-beta'], - }), + connector: connectorField(CLMM_CONNECTORS, 'CLMM connector'), + chainNetwork: chainNetworkField(), poolAddress: Type.String({ description: 'Pool contract address', examples: [CLMM_POOL_ADDRESS_EXAMPLE], }), + binCount: Type.Optional( + Type.Integer({ + 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: 401, + }), + ), }); type UnifiedPoolInfoRequest = Static; -/** - * Parse chain-network parameter into chain and network - */ -function parseChainNetwork(chainNetwork: string): { chain: string; network: string } { - const parts = chainNetwork.split('-'); - - if (parts.length < 2) { - throw new Error( - `Invalid chain-network format: ${chainNetwork}. Expected format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)`, - ); - } - - const chain = parts[0]; - const network = parts.slice(1).join('-'); - - return { chain, network }; -} - /** * Get pool info from Solana connectors */ @@ -64,18 +49,20 @@ async function getSolanaPoolInfo( connector: string, network: string, poolAddress: string, + binCount: number, ): Promise { logger.info(`[CLMM] Getting pool info from ${connector} on solana/${network}`); switch (connector) { case 'raydium': - return await raydiumGetPoolInfo(fastify, network, poolAddress); + return await raydiumGetPoolInfo(fastify, network, poolAddress, binCount); case 'meteora': + // Meteora always returns its bins; it has no binCount parameter. return await meteoraGetPoolInfo(fastify, network, poolAddress); case 'pancakeswap-sol': - return await pancakeswapSolGetPoolInfo(fastify, network, poolAddress); + return await pancakeswapSolGetPoolInfo(fastify, network, poolAddress, binCount); case 'orca': - return await orcaGetPoolInfo(fastify, network, poolAddress); + return await orcaGetPoolInfo(fastify, network, poolAddress, binCount); default: throw fastify.httpErrors.badRequest(`Unsupported Solana CLMM connector: ${connector}`); } @@ -89,14 +76,15 @@ async function getEthereumPoolInfo( connector: string, network: string, poolAddress: string, + binCount: number, ): Promise { logger.info(`[CLMM] Getting pool info from ${connector} on ethereum/${network}`); switch (connector) { case 'uniswap': - return await uniswapGetPoolInfo(fastify, network, poolAddress); + return await uniswapGetPoolInfo(fastify, network, poolAddress, binCount); case 'pancakeswap': - return await pancakeswapGetPoolInfo(fastify, network, poolAddress); + return await pancakeswapGetPoolInfo(fastify, network, poolAddress, binCount); default: throw fastify.httpErrors.badRequest(`Unsupported Ethereum CLMM connector: ${connector}`); } @@ -110,6 +98,7 @@ export async function getUnifiedPoolInfo( connector: string, chainNetwork: string, poolAddress: string, + binCount: number = 0, ): Promise { const { chain, network } = parseChainNetwork(chainNetwork); @@ -117,10 +106,10 @@ export async function getUnifiedPoolInfo( switch (chain.toLowerCase()) { case 'ethereum': - return getEthereumPoolInfo(fastify, connector, network, poolAddress); + return getEthereumPoolInfo(fastify, connector, network, poolAddress, binCount); case 'solana': - return getSolanaPoolInfo(fastify, connector, network, poolAddress); + return getSolanaPoolInfo(fastify, connector, network, poolAddress, binCount); default: throw fastify.httpErrors.badRequest(`Unsupported chain: ${chain}`); @@ -148,14 +137,13 @@ export const poolsRoute: FastifyPluginAsync = async (fastify) => { }, }, async (request, reply) => { - const { connector, chainNetwork, poolAddress } = request.query; + const { connector, chainNetwork, poolAddress, binCount = 0 } = request.query; try { - const result = await getUnifiedPoolInfo(fastify, connector, chainNetwork, poolAddress); + const result = await getUnifiedPoolInfo(fastify, connector, chainNetwork, poolAddress, binCount); return reply.code(200).send(result); } catch (error: any) { - logger.error(`[UnifiedCLMM] Pool info error: ${error.message}`); - throw error; + rethrowRouteError(error, 'Failed to get CLMM pool info'); } }, ); diff --git a/src/trading/clmm/positions-owned.ts b/src/trading/clmm/positions-owned.ts index af6756aa1e..1c8d47199c 100644 --- a/src/trading/clmm/positions-owned.ts +++ b/src/trading/clmm/positions-owned.ts @@ -1,8 +1,6 @@ import { Type, Static } from '@sinclair/typebox'; import { FastifyPluginAsync, FastifyInstance } from 'fastify'; -import { getEthereumChainConfig } from '../../chains/ethereum/ethereum.config'; -import { getSolanaChainConfig } from '../../chains/solana/solana.config'; import { getPositionsOwned as meteoraGetPositionsOwned } from '../../connectors/meteora/clmm-routes/positionsOwned'; import { getPositionsOwned as orcaGetPositionsOwned } from '../../connectors/orca/clmm-routes/positionsOwned'; import { getPositionsOwned as pancakeswapGetPositionsOwned } from '../../connectors/pancakeswap/clmm-routes/positionsOwned'; @@ -11,60 +9,29 @@ import { getPositionsOwned as raydiumGetPositionsOwned } from '../../connectors/ import { getPositionsOwned as uniswapGetPositionsOwned } from '../../connectors/uniswap/clmm-routes/positionsOwned'; import { PositionInfo, PositionInfoSchema } from '../../schemas/clmm-schema'; import { logger } from '../../services/logger'; - -// Get default wallet from Solana config, fallback to Ethereum if Solana doesn't exist -let defaultWallet: string; -try { - const solanaChainConfig = getSolanaChainConfig(); - defaultWallet = solanaChainConfig.defaultWallet; -} catch { - const ethereumChainConfig = getEthereumChainConfig(); - defaultWallet = ethereumChainConfig.defaultWallet; -} +import { + chainNetworkField, + CLMM_CONNECTORS, + connectorField, + defaultWallet, + parseChainNetwork, + rethrowRouteError, +} from '../common'; /** * Unified positions owned request schema */ const UnifiedPositionsOwnedRequestSchema = Type.Object({ - connector: Type.String({ - description: 'CLMM connector (raydium, meteora, pancakeswap-sol, uniswap, pancakeswap, orca)', - enum: ['raydium', 'meteora', 'pancakeswap-sol', 'uniswap', 'pancakeswap', 'orca'], - default: 'meteora', - examples: ['meteora'], - }), - chainNetwork: Type.String({ - description: 'Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', - default: 'solana-mainnet-beta', - examples: ['solana-mainnet-beta'], + connector: connectorField(CLMM_CONNECTORS, 'CLMM connector'), + chainNetwork: chainNetworkField(), + walletAddress: Type.String({ + description: 'Wallet address', + default: defaultWallet, }), - walletAddress: Type.Optional( - Type.String({ - description: 'Wallet address (optional, uses default wallet if not provided)', - default: defaultWallet, - }), - ), }); type UnifiedPositionsOwnedRequest = Static; -/** - * Parse chain-network parameter into chain and network - */ -function parseChainNetwork(chainNetwork: string): { chain: string; network: string } { - const parts = chainNetwork.split('-'); - - if (parts.length < 2) { - throw new Error( - `Invalid chain-network format: ${chainNetwork}. Expected format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)`, - ); - } - - const chain = parts[0]; - const network = parts.slice(1).join('-'); - - return { chain, network }; -} - /** * Get positions owned from Solana connectors */ @@ -163,8 +130,7 @@ export const positionsOwnedRoute: FastifyPluginAsync = async (fastify) => { const result = await getUnifiedPositionsOwned(fastify, connector, chainNetwork, walletAddress); return reply.code(200).send(result); } catch (error: any) { - logger.error(`[UnifiedCLMM] Positions owned error: ${error.message}`); - throw error; + rethrowRouteError(error, 'Failed to list CLMM positions owned'); } }, ); diff --git a/src/trading/clmm/positions.ts b/src/trading/clmm/positions.ts index 26582eea1d..dcee78ba1c 100644 --- a/src/trading/clmm/positions.ts +++ b/src/trading/clmm/positions.ts @@ -9,22 +9,14 @@ import { getPositionInfo as raydiumGetPositionInfo } from '../../connectors/rayd import { getPositionInfo as uniswapGetPositionInfo } from '../../connectors/uniswap/clmm-routes/positionInfo'; import { PositionInfo, PositionInfoSchema } from '../../schemas/clmm-schema'; import { logger } from '../../services/logger'; +import { chainNetworkField, CLMM_CONNECTORS, connectorField, parseChainNetwork, rethrowRouteError } from '../common'; /** * Unified position info request schema */ const UnifiedPositionInfoRequestSchema = Type.Object({ - connector: Type.String({ - description: 'CLMM connector (raydium, meteora, pancakeswap-sol, uniswap, pancakeswap, orca)', - enum: ['raydium', 'meteora', 'pancakeswap-sol', 'uniswap', 'pancakeswap', 'orca'], - default: 'meteora', - examples: ['meteora'], - }), - chainNetwork: Type.String({ - description: 'Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', - default: 'solana-mainnet-beta', - examples: ['solana-mainnet-beta'], - }), + connector: connectorField(CLMM_CONNECTORS, 'CLMM connector'), + chainNetwork: chainNetworkField(), positionAddress: Type.String({ description: 'Position address or NFT token ID', examples: [''], @@ -33,24 +25,6 @@ const UnifiedPositionInfoRequestSchema = Type.Object({ type UnifiedPositionInfoRequest = Static; -/** - * Parse chain-network parameter into chain and network - */ -function parseChainNetwork(chainNetwork: string): { chain: string; network: string } { - const parts = chainNetwork.split('-'); - - if (parts.length < 2) { - throw new Error( - `Invalid chain-network format: ${chainNetwork}. Expected format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)`, - ); - } - - const chain = parts[0]; - const network = parts.slice(1).join('-'); - - return { chain, network }; -} - /** * Get position info from Solana connectors */ @@ -149,8 +123,7 @@ export const positionsRoute: FastifyPluginAsync = async (fastify) => { const result = await getUnifiedPositionInfo(fastify, connector, chainNetwork, positionAddress); return reply.code(200).send(result); } catch (error: any) { - logger.error(`[UnifiedCLMM] Position info error: ${error.message}`); - throw error; + rethrowRouteError(error, 'Failed to get CLMM position info'); } }, ); diff --git a/src/trading/clmm/quote-position.ts b/src/trading/clmm/quote-position.ts index d1a44ec22f..7e66ffdd1d 100644 --- a/src/trading/clmm/quote-position.ts +++ b/src/trading/clmm/quote-position.ts @@ -1,8 +1,6 @@ import { Type, Static } from '@sinclair/typebox'; import { FastifyPluginAsync } from 'fastify'; -import { getEthereumChainConfig } from '../../chains/ethereum/ethereum.config'; -import { getSolanaChainConfig } from '../../chains/solana/solana.config'; import { quotePosition as meteoraQuotePosition } from '../../connectors/meteora/clmm-routes/quotePosition'; import { quotePosition as orcaQuotePosition } from '../../connectors/orca/clmm-routes/quotePosition'; import { quotePosition as pancakeswapQuotePosition } from '../../connectors/pancakeswap/clmm-routes/quotePosition'; @@ -12,6 +10,14 @@ import { quotePosition as uniswapQuotePosition } from '../../connectors/uniswap/ import { QuotePositionResponseType, QuotePositionResponse } from '../../schemas/clmm-schema'; import { httpErrors } from '../../services/error-handler'; import { logger } from '../../services/logger'; +import { + chainNetworkField, + CLMM_CONNECTORS, + connectorField, + parseChainNetwork, + rethrowRouteError, + slippagePctField, +} from '../common'; // Constants for examples (using Meteora CLMM values) const BASE_TOKEN_AMOUNT = 0.01; @@ -24,17 +30,8 @@ const CLMM_POOL_ADDRESS_EXAMPLE = '2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3' * Unified quote position request schema */ const UnifiedQuotePositionRequestSchema = Type.Object({ - connector: Type.String({ - description: 'CLMM connector (raydium, meteora, pancakeswap-sol, uniswap, pancakeswap, orca)', - enum: ['raydium', 'meteora', 'pancakeswap-sol', 'uniswap', 'pancakeswap', 'orca'], - default: 'meteora', - examples: ['meteora'], - }), - chainNetwork: Type.String({ - description: 'Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', - default: 'solana-mainnet-beta', - examples: ['solana-mainnet-beta'], - }), + connector: connectorField(CLMM_CONNECTORS, 'CLMM connector'), + chainNetwork: chainNetworkField(), lowerPrice: Type.Number({ description: 'Lower price bound for the position', examples: [LOWER_PRICE_BOUND], @@ -59,37 +56,11 @@ const UnifiedQuotePositionRequestSchema = Type.Object({ examples: [QUOTE_TOKEN_AMOUNT], }), ), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: 1, - examples: [1], - }), - ), + slippagePct: slippagePctField(), }); type UnifiedQuotePositionRequest = Static; -/** - * Parse chain-network parameter into chain and network - */ -function parseChainNetwork(chainNetwork: string): { chain: string; network: string } { - const parts = chainNetwork.split('-'); - - if (parts.length < 2) { - throw new Error( - `Invalid chain-network format: ${chainNetwork}. Expected format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)`, - ); - } - - const chain = parts[0]; - const network = parts.slice(1).join('-'); - - return { chain, network }; -} - /** * Quote position from Solana connectors */ @@ -134,6 +105,7 @@ async function getSolanaQuotePosition( poolAddress, baseTokenAmount, quoteTokenAmount, + slippagePct, ); case 'orca': return await orcaQuotePosition( @@ -283,8 +255,7 @@ export const quotePositionRoute: FastifyPluginAsync = async (fastify) => { ); return reply.code(200).send(result); } catch (error: any) { - logger.error(`[UnifiedCLMM] Quote position error: ${error.message}`); - throw error; + rethrowRouteError(error, 'Failed to quote CLMM position'); } }, ); diff --git a/src/trading/common.ts b/src/trading/common.ts new file mode 100644 index 0000000000..18ecb2ea72 --- /dev/null +++ b/src/trading/common.ts @@ -0,0 +1,76 @@ +import { Type } from '@sinclair/typebox'; + +import { getEthereumChainConfig } from '../chains/ethereum/ethereum.config'; +import { getSolanaChainConfig } from '../chains/solana/solana.config'; +import { httpErrors } from '../services/error-handler'; +import { logger } from '../services/logger'; + +/** CLMM connectors that back the unified /trading/clmm routes. */ +export const CLMM_CONNECTORS = ['meteora', 'raydium', 'pancakeswap-sol', 'orca', 'uniswap', 'pancakeswap']; + +/** AMM connectors that back the unified /trading/amm routes. */ +export const AMM_CONNECTORS = ['meteora', 'raydium', 'uniswap', 'pancakeswap']; + +/** Connector selector: enum-constrained so unknown connectors are rejected at the schema. */ +export const connectorField = (connectors: string[], label: string) => + Type.String({ description: label, enum: connectors, default: connectors[0], examples: [connectors[0]] }); + +/** Chain-network selector shared by every unified trading route. */ +export const chainNetworkField = () => + Type.String({ + description: 'Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', + default: 'solana-mainnet-beta', + examples: ['solana-mainnet-beta'], + }); + +/** + * Optional slippage override shared by the unified trading routes. Deliberately + * has NO schema default: Fastify injects schema defaults before the handler + * runs, so a default here would shadow the connector-level defaults. When + * omitted, each connector applies its own configured slippagePct (falling back + * to 1 where the config has none). + */ +export const slippagePctField = (description?: string) => + Type.Optional( + Type.Number({ + minimum: 0, + maximum: 100, + description: + description ?? "Maximum acceptable slippage percentage. Defaults to the connector's configured slippagePct.", + examples: [1], + }), + ); + +/** + * Standard catch handler for the unified trading routes: errors that already + * carry an HTTP status code (connector badRequest/notFound, chain errors) pass + * through untouched; anything else becomes a 500 that keeps the underlying + * message so callers see the real cause instead of a generic label. + */ +export function rethrowRouteError(e: any, context: string): never { + logger.error(`${context}: ${e?.message ?? e}`); + if (e?.statusCode) { + throw e; + } + throw httpErrors.internalServerError(`${context}: ${e?.message ?? e}`); +} + +/** Parse a chain-network string (e.g. "solana-mainnet-beta") into its chain and network parts. */ +export function parseChainNetwork(chainNetwork: string): { chain: string; network: string } { + const parts = chainNetwork.split('-'); + if (parts.length < 2) { + throw httpErrors.badRequest( + `Invalid chain-network format: ${chainNetwork}. Expected format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)`, + ); + } + return { chain: parts[0], network: parts.slice(1).join('-') }; +} + +// Default wallet from Solana config, falling back to Ethereum when Solana is unavailable. +let dw: string; +try { + dw = getSolanaChainConfig().defaultWallet; +} catch { + dw = getEthereumChainConfig().defaultWallet; +} +export const defaultWallet = dw; diff --git a/src/trading/swap/execute.ts b/src/trading/swap/execute.ts index ea46dbe360..d38490f5e7 100644 --- a/src/trading/swap/execute.ts +++ b/src/trading/swap/execute.ts @@ -2,11 +2,12 @@ import { Type, Static } from '@sinclair/typebox'; import { FastifyPluginAsync } from 'fastify'; // Solana connector imports -import { getEthereumChainConfig, getEthereumNetworkConfig } from '../../chains/ethereum/ethereum.config'; -import { getSolanaChainConfig, getSolanaNetworkConfig } from '../../chains/solana/solana.config'; +import { getEthereumNetworkConfig } from '../../chains/ethereum/ethereum.config'; +import { getSolanaNetworkConfig } from '../../chains/solana/solana.config'; import { executeSwap as zeroXRouterExecuteSwap } from '../../connectors/0x/router-routes/executeSwap'; import { executeSwap as dflowRouterExecuteSwap } from '../../connectors/dflow/router-routes/executeSwap'; import { executeSwap as jupiterRouterExecuteSwap } from '../../connectors/jupiter/router-routes/executeSwap'; +import { executeSwap as meteoraAmmExecuteSwap } from '../../connectors/meteora/amm-routes/executeSwap'; import { executeSwap as meteoraClmmExecuteSwap } from '../../connectors/meteora/clmm-routes/executeSwap'; import { executeSwap as okxRouterExecuteSwap } from '../../connectors/okx/router-routes/executeSwap'; import { executeSwap as orcaClmmExecuteSwap } from '../../connectors/orca/clmm-routes/executeSwap'; @@ -28,16 +29,7 @@ import { ChainExecuteSwapResponseSchema } from '../../schemas/chain-schema'; import { httpErrors } from '../../services/error-handler'; import { logger } from '../../services/logger'; import { PoolService } from '../../services/pool-service'; - -// Get default wallet from Solana config, fallback to Ethereum if Solana doesn't exist -let defaultWallet: string; -try { - const solanaChainConfig = getSolanaChainConfig(); - defaultWallet = solanaChainConfig.defaultWallet; -} catch { - const ethereumChainConfig = getEthereumChainConfig(); - defaultWallet = ethereumChainConfig.defaultWallet; -} +import { chainNetworkField, defaultWallet, parseChainNetwork, rethrowRouteError, slippagePctField } from '../common'; /** * Unified swap execute request schema @@ -48,11 +40,7 @@ const UnifiedExecuteSwapRequestSchema = Type.Object({ description: 'Wallet address to execute swap from', default: defaultWallet, }), - chainNetwork: Type.String({ - description: - 'Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet, ethereum-polygon)', - default: 'solana-mainnet-beta', - }), + chainNetwork: chainNetworkField(), connector: Type.Optional( Type.String({ description: @@ -77,40 +65,27 @@ const UnifiedExecuteSwapRequestSchema = Type.Object({ enum: ['BUY', 'SELL'], default: 'SELL', }), - slippagePct: Type.Optional( - Type.Number({ - description: 'Slippage tolerance percentage (optional)', - default: 1, + poolAddress: Type.Optional( + Type.String({ + description: + 'Pin the swap to a specific pool. Only meaningful for amm/clmm providers, which trade against one ' + + 'pool; router providers choose their own route and reject it. Omit to resolve the pool from ' + + "Gateway's configured pool list by token pair — which a pool that is not in that list (a freshly " + + 'created one, an unlisted token) cannot be, so pass its address here.', + }), + ), + slippagePct: slippagePctField(), + approximateIfNoExactOut: Type.Optional( + Type.Boolean({ + description: + 'For BUY orders when the router has no ExactOut route: approximate via a sell-leg ExactIn quote instead of failing. Solana routers only.', + default: true, }), ), }); type UnifiedExecuteSwapRequest = Static; -/** - * Parse chain-network parameter into chain and network - * Examples: "solana-mainnet-beta" -> {chain: "solana", network: "mainnet-beta"} - * "ethereum-mainnet" -> {chain: "ethereum", network: "mainnet"} - * "ethereum-polygon" -> {chain: "ethereum", network: "polygon"} - */ -function parseChainNetwork(chainNetwork: string): { chain: string; network: string } { - const parts = chainNetwork.split('-'); - - if (parts.length < 2) { - throw new Error( - `Invalid chain-network format: ${chainNetwork}. Expected format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)`, - ); - } - - // First part is always the chain - const chain = parts[0]; - - // Rest is the network (e.g., "mainnet-beta" from ["solana", "mainnet", "beta"]) - const network = parts.slice(1).join('-'); - - return { chain, network }; -} - /** * Execute a Solana swap */ @@ -123,6 +98,8 @@ async function executeSolanaSwap( side: 'BUY' | 'SELL', slippagePct?: number, connector?: string, + approximateIfNoExactOut?: boolean, + requestedPoolAddress?: string, ): Promise { try { const networkConfig = getSolanaNetworkConfig(network); @@ -135,20 +112,28 @@ async function executeSolanaSwap( `Using swap provider: ${swapProvider} for network: ${network}${connector ? ' (explicit)' : ' (from config)'}`, ); - // For AMM and CLMM, look up the pool address using PoolService - let poolAddress: string | undefined; + // An explicit pin wins; otherwise resolve the pool from the configured list. + let poolAddress: string | undefined = requestedPoolAddress; if (connectorType === 'amm' || connectorType === 'clmm') { - const poolService = PoolService.getInstance(); - const pool = await poolService.getPool('solana', network, connectorType, baseToken, quoteToken, connectorName); + if (!poolAddress) { + const poolService = PoolService.getInstance(); + const pool = await poolService.getPool('solana', network, connectorType, baseToken, quoteToken, connectorName); + + if (!pool) { + throw httpErrors.notFound( + `No ${connectorType.toUpperCase()} pool found for ${baseToken}-${quoteToken} on ${connectorName}/${network}. ` + + 'Pass poolAddress to trade against a specific pool.', + ); + } - if (!pool) { - throw httpErrors.notFound( - `No ${connectorType.toUpperCase()} pool found for ${baseToken}-${quoteToken} on ${connectorName}/${network}`, - ); + poolAddress = pool.address; + logger.info(`Found pool: ${poolAddress} for ${baseToken}-${quoteToken}`); } - - poolAddress = pool.address; - logger.info(`Found pool: ${poolAddress} for ${baseToken}-${quoteToken}`); + } else if (requestedPoolAddress) { + throw httpErrors.badRequest( + `poolAddress is not supported for router provider ${swapProvider}: a router chooses its own route ` + + 'across pools. Use an amm or clmm provider to pin a pool.', + ); } // Route to the appropriate connector based on swapProvider @@ -163,15 +148,43 @@ async function executeSolanaSwap( amount, side, slippagePct, - undefined, // priorityLevel - undefined, // maxLamports + approximateIfNoExactOut, ); } else if (providerKey === 'dflow/router') { - return await dflowRouterExecuteSwap(walletAddress, network, baseToken, quoteToken, amount, side, slippagePct); + return await dflowRouterExecuteSwap( + walletAddress, + network, + baseToken, + quoteToken, + amount, + side, + slippagePct, + approximateIfNoExactOut, + ); } else if (providerKey === 'okx/router') { - return await okxRouterExecuteSwap(walletAddress, network, baseToken, quoteToken, amount, side, slippagePct); + return await okxRouterExecuteSwap( + walletAddress, + network, + baseToken, + quoteToken, + amount, + side, + slippagePct, + approximateIfNoExactOut, + ); } else if (providerKey === 'titan/router') { - return await titanRouterExecuteSwap(walletAddress, network, baseToken, quoteToken, amount, side, slippagePct); + return await titanRouterExecuteSwap( + walletAddress, + network, + baseToken, + quoteToken, + amount, + side, + slippagePct, + approximateIfNoExactOut, + ); + } else if (providerKey === 'meteora/amm') { + return await meteoraAmmExecuteSwap(network, walletAddress, poolAddress!, baseToken, side, amount, slippagePct); } else if (providerKey === 'raydium/amm') { return await raydiumAmmExecuteSwap(network, walletAddress, poolAddress!, baseToken, side, amount, slippagePct); } else if (providerKey === 'raydium/clmm') { @@ -214,6 +227,7 @@ async function executeEthereumSwap( side: 'BUY' | 'SELL', slippagePct?: number, connector?: string, + requestedPoolAddress?: string, ): Promise { try { const networkConfig = getEthereumNetworkConfig(network); @@ -226,20 +240,35 @@ async function executeEthereumSwap( `Using swap provider: ${swapProvider} for network: ${network}${connector ? ' (explicit)' : ' (from config)'}`, ); - // For AMM and CLMM, look up the pool address using PoolService - let poolAddress: string | undefined; + // An explicit pin wins; otherwise resolve the pool from the configured list. + let poolAddress: string | undefined = requestedPoolAddress; if (connectorType === 'amm' || connectorType === 'clmm') { - const poolService = PoolService.getInstance(); - const pool = await poolService.getPool('ethereum', network, connectorType, baseToken, quoteToken, connectorName); - - if (!pool) { - throw httpErrors.notFound( - `No ${connectorType.toUpperCase()} pool found for ${baseToken}-${quoteToken} on ${connectorName}/${network}`, + if (!poolAddress) { + const poolService = PoolService.getInstance(); + const pool = await poolService.getPool( + 'ethereum', + network, + connectorType, + baseToken, + quoteToken, + connectorName, ); - } - poolAddress = pool.address; - logger.info(`Found pool: ${poolAddress} for ${baseToken}-${quoteToken}`); + if (!pool) { + throw httpErrors.notFound( + `No ${connectorType.toUpperCase()} pool found for ${baseToken}-${quoteToken} on ${connectorName}/${network}. ` + + 'Pass poolAddress to trade against a specific pool.', + ); + } + + poolAddress = pool.address; + logger.info(`Found pool: ${poolAddress} for ${baseToken}-${quoteToken}`); + } + } else if (requestedPoolAddress) { + throw httpErrors.badRequest( + `poolAddress is not supported for router provider ${swapProvider}: a router chooses its own route ` + + 'across pools. Use an amm or clmm provider to pin a pool.', + ); } // Route to the appropriate connector based on swapProvider @@ -307,6 +336,8 @@ export async function executeUnifiedSwap( side: 'BUY' | 'SELL', slippagePct?: number, connector?: string, + approximateIfNoExactOut?: boolean, + requestedPoolAddress?: string, ): Promise { const { chain, network } = parseChainNetwork(chainNetwork); @@ -316,10 +347,31 @@ export async function executeUnifiedSwap( switch (chain.toLowerCase()) { case 'ethereum': - return executeEthereumSwap(network, walletAddress, baseToken, quoteToken, amount, side, slippagePct, connector); + return executeEthereumSwap( + network, + walletAddress, + baseToken, + quoteToken, + amount, + side, + slippagePct, + connector, + requestedPoolAddress, + ); case 'solana': - return executeSolanaSwap(network, walletAddress, baseToken, quoteToken, amount, side, slippagePct, connector); + return executeSolanaSwap( + network, + walletAddress, + baseToken, + quoteToken, + amount, + side, + slippagePct, + connector, + approximateIfNoExactOut, + requestedPoolAddress, + ); default: throw httpErrors.badRequest(`Unsupported chain: ${chain}`); @@ -344,8 +396,18 @@ export const executeSwapRoute: FastifyPluginAsync = async (fastify) => { }, }, async (request, reply) => { - const { chainNetwork, walletAddress, baseToken, quoteToken, amount, side, slippagePct, connector } = - request.body as UnifiedExecuteSwapRequest; + const { + chainNetwork, + walletAddress, + baseToken, + quoteToken, + amount, + side, + slippagePct, + approximateIfNoExactOut, + connector, + poolAddress, + } = request.body as UnifiedExecuteSwapRequest; try { const result = await executeUnifiedSwap( @@ -357,14 +419,12 @@ export const executeSwapRoute: FastifyPluginAsync = async (fastify) => { side as 'BUY' | 'SELL', slippagePct, connector, + approximateIfNoExactOut, + poolAddress, ); return reply.code(200).send(result); } catch (error: any) { - logger.error(`[UnifiedSwap] Execute error: ${error.message}`); - if (error.statusCode) { - throw error; - } - throw fastify.httpErrors.internalServerError(error.message || 'Failed to execute swap'); + rethrowRouteError(error, 'Failed to execute swap'); } }, ); diff --git a/src/trading/swap/quote.ts b/src/trading/swap/quote.ts index db4953c5a5..092896d3cf 100644 --- a/src/trading/swap/quote.ts +++ b/src/trading/swap/quote.ts @@ -7,6 +7,7 @@ import { getSolanaNetworkConfig } from '../../chains/solana/solana.config'; import { quoteSwap as zeroXRouterQuoteSwap } from '../../connectors/0x/router-routes/quoteSwap'; import { quoteSwap as dflowRouterQuoteSwap } from '../../connectors/dflow/router-routes/quoteSwap'; import { quoteSwap as jupiterRouterQuoteSwap } from '../../connectors/jupiter/router-routes/quoteSwap'; +import { quoteSwap as meteoraAmmQuoteSwap } from '../../connectors/meteora/amm-routes/quoteSwap'; import { quoteSwap as meteoraClmmQuoteSwap } from '../../connectors/meteora/clmm-routes/quoteSwap'; import { quoteSwap as okxRouterQuoteSwap } from '../../connectors/okx/router-routes/quoteSwap'; import { quoteSwap as orcaClmmQuoteSwap } from '../../connectors/orca/clmm-routes/quoteSwap'; @@ -28,17 +29,14 @@ import { ChainQuoteSwapResponseSchema } from '../../schemas/chain-schema'; import { httpErrors } from '../../services/error-handler'; import { logger } from '../../services/logger'; import { PoolService } from '../../services/pool-service'; +import { chainNetworkField, parseChainNetwork, rethrowRouteError, slippagePctField } from '../common'; /** * Unified swap quote request schema * Accepts chain-network parameter like "solana-mainnet-beta", "ethereum-mainnet", or "ethereum-polygon" */ const UnifiedQuoteSwapRequestSchema = Type.Object({ - chainNetwork: Type.String({ - description: - 'Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet, ethereum-polygon)', - default: 'solana-mainnet-beta', - }), + chainNetwork: chainNetworkField(), connector: Type.Optional( Type.String({ description: @@ -63,40 +61,27 @@ const UnifiedQuoteSwapRequestSchema = Type.Object({ enum: ['BUY', 'SELL'], default: 'SELL', }), - slippagePct: Type.Optional( - Type.Number({ - description: 'Slippage tolerance percentage (optional)', - default: 1, + poolAddress: Type.Optional( + Type.String({ + description: + 'Pin the swap to a specific pool. Only meaningful for amm/clmm providers, which trade against one ' + + 'pool; router providers choose their own route and reject it. Omit to resolve the pool from ' + + "Gateway's configured pool list by token pair — which a pool that is not in that list (a freshly " + + 'created one, an unlisted token) cannot be, so pass its address here.', + }), + ), + slippagePct: slippagePctField(), + approximateIfNoExactOut: Type.Optional( + Type.Boolean({ + description: + 'For BUY orders when the router has no ExactOut route: approximate via a sell-leg ExactIn quote instead of failing. Solana routers only.', + default: true, }), ), }); type UnifiedQuoteSwapRequest = Static; -/** - * Parse chain-network parameter into chain and network - * Examples: "solana-mainnet-beta" -> {chain: "solana", network: "mainnet-beta"} - * "ethereum-mainnet" -> {chain: "ethereum", network: "mainnet"} - * "ethereum-polygon" -> {chain: "ethereum", network: "polygon"} - */ -function parseChainNetwork(chainNetwork: string): { chain: string; network: string } { - const parts = chainNetwork.split('-'); - - if (parts.length < 2) { - throw httpErrors.badRequest( - `Invalid chain-network format: ${chainNetwork}. Expected format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)`, - ); - } - - // First part is always the chain - const chain = parts[0]; - - // Rest is the network (e.g., "mainnet-beta" from ["solana", "mainnet", "beta"]) - const network = parts.slice(1).join('-'); - - return { chain, network }; -} - /** * Get a Solana swap quote */ @@ -108,6 +93,8 @@ async function getSolanaQuoteSwap( side: 'BUY' | 'SELL', slippagePct?: number, connector?: string, + approximateIfNoExactOut?: boolean, + requestedPoolAddress?: string, ): Promise { try { const networkConfig = getSolanaNetworkConfig(network); @@ -120,20 +107,28 @@ async function getSolanaQuoteSwap( `Using swap provider: ${swapProvider} for network: ${network}${connector ? ' (explicit)' : ' (from config)'}`, ); - // For AMM and CLMM, look up the pool address using PoolService - let poolAddress: string | undefined; + // An explicit pin wins; otherwise resolve the pool from the configured list. + let poolAddress: string | undefined = requestedPoolAddress; if (connectorType === 'amm' || connectorType === 'clmm') { - const poolService = PoolService.getInstance(); - const pool = await poolService.getPool('solana', network, connectorType, baseToken, quoteToken, connectorName); + if (!poolAddress) { + const poolService = PoolService.getInstance(); + const pool = await poolService.getPool('solana', network, connectorType, baseToken, quoteToken, connectorName); + + if (!pool) { + throw httpErrors.notFound( + `No ${connectorType.toUpperCase()} pool found for ${baseToken}-${quoteToken} on ${connectorName}/${network}. ` + + 'Pass poolAddress to trade against a specific pool.', + ); + } - if (!pool) { - throw httpErrors.notFound( - `No ${connectorType.toUpperCase()} pool found for ${baseToken}-${quoteToken} on ${connectorName}/${network}`, - ); + poolAddress = pool.address; + logger.info(`Found pool: ${poolAddress} for ${baseToken}-${quoteToken}`); } - - poolAddress = pool.address; - logger.info(`Found pool: ${poolAddress} for ${baseToken}-${quoteToken}`); + } else if (requestedPoolAddress) { + throw httpErrors.badRequest( + `poolAddress is not supported for router provider ${swapProvider}: a router chooses its own route ` + + 'across pools. Use an amm or clmm provider to pin a pool.', + ); } // Route to the appropriate connector based on swapProvider @@ -147,15 +142,40 @@ async function getSolanaQuoteSwap( amount, side, slippagePct, - undefined, // onlyDirectRoutes - undefined, // restrictIntermediateTokens + approximateIfNoExactOut, ); } else if (providerKey === 'dflow/router') { - return await dflowRouterQuoteSwap(network, baseToken, quoteToken, amount, side, slippagePct); + return await dflowRouterQuoteSwap( + network, + baseToken, + quoteToken, + amount, + side, + slippagePct, + approximateIfNoExactOut, + ); } else if (providerKey === 'okx/router') { - return await okxRouterQuoteSwap(network, baseToken, quoteToken, amount, side, slippagePct); + return await okxRouterQuoteSwap( + network, + baseToken, + quoteToken, + amount, + side, + slippagePct, + approximateIfNoExactOut, + ); } else if (providerKey === 'titan/router') { - return await titanRouterQuoteSwap(network, baseToken, quoteToken, amount, side, slippagePct); + return await titanRouterQuoteSwap( + network, + baseToken, + quoteToken, + amount, + side, + slippagePct, + approximateIfNoExactOut, + ); + } else if (providerKey === 'meteora/amm') { + return await meteoraAmmQuoteSwap(network, poolAddress!, baseToken, side, amount, slippagePct); } else if (providerKey === 'raydium/amm') { return await raydiumAmmQuoteSwap(network, poolAddress!, baseToken, side, amount, slippagePct); } else if (providerKey === 'raydium/clmm') { @@ -189,6 +209,7 @@ async function getEthereumQuoteSwap( side: 'BUY' | 'SELL', slippagePct?: number, connector?: string, + requestedPoolAddress?: string, ): Promise { try { const networkConfig = getEthereumNetworkConfig(network); @@ -201,27 +222,42 @@ async function getEthereumQuoteSwap( `Using swap provider: ${swapProvider} for network: ${network}${connector ? ' (explicit)' : ' (from config)'}`, ); - // For AMM and CLMM, look up the pool address using PoolService - let poolAddress: string | undefined; + // An explicit pin wins; otherwise resolve the pool from the configured list. + let poolAddress: string | undefined = requestedPoolAddress; if (connectorType === 'amm' || connectorType === 'clmm') { - const poolService = PoolService.getInstance(); - const pool = await poolService.getPool('ethereum', network, connectorType, baseToken, quoteToken, connectorName); - - if (!pool) { - throw httpErrors.notFound( - `No ${connectorType.toUpperCase()} pool found for ${baseToken}-${quoteToken} on ${connectorName}/${network}`, + if (!poolAddress) { + const poolService = PoolService.getInstance(); + const pool = await poolService.getPool( + 'ethereum', + network, + connectorType, + baseToken, + quoteToken, + connectorName, ); - } - poolAddress = pool.address; - logger.info(`Found pool: ${poolAddress} for ${baseToken}-${quoteToken}`); + if (!pool) { + throw httpErrors.notFound( + `No ${connectorType.toUpperCase()} pool found for ${baseToken}-${quoteToken} on ${connectorName}/${network}. ` + + 'Pass poolAddress to trade against a specific pool.', + ); + } + + poolAddress = pool.address; + logger.info(`Found pool: ${poolAddress} for ${baseToken}-${quoteToken}`); + } + } else if (requestedPoolAddress) { + throw httpErrors.badRequest( + `poolAddress is not supported for router provider ${swapProvider}: a router chooses its own route ` + + 'across pools. Use an amm or clmm provider to pin a pool.', + ); } // Route to the appropriate connector based on swapProvider const providerKey = swapProvider; if (providerKey === 'uniswap/router') { - return await uniswapRouterQuoteSwap(network, undefined, baseToken, quoteToken, amount, side, slippagePct || 1); + return await uniswapRouterQuoteSwap(network, undefined, baseToken, quoteToken, amount, side, slippagePct); } else if (providerKey === 'uniswap/amm') { return await uniswapAmmQuoteSwap(network, poolAddress!, baseToken, side, amount, slippagePct); } else if (providerKey === 'uniswap/clmm') { @@ -233,7 +269,7 @@ async function getEthereumQuoteSwap( } else if (providerKey === 'pancakeswap/clmm') { return await pancakeswapClmmQuoteSwap(network, poolAddress!, baseToken, side, amount, slippagePct); } else if (providerKey === '0x/router') { - return await zeroXRouterQuoteSwap(network, baseToken, quoteToken, amount, side, slippagePct || 1); + return await zeroXRouterQuoteSwap(network, baseToken, quoteToken, amount, side, slippagePct); } throw httpErrors.badRequest(`Unsupported swap provider: ${swapProvider}`); @@ -257,6 +293,8 @@ export async function getUnifiedQuoteSwap( side: 'BUY' | 'SELL', slippagePct?: number, connector?: string, + approximateIfNoExactOut?: boolean, + requestedPoolAddress?: string, ): Promise { const { chain, network } = parseChainNetwork(chainNetwork); @@ -266,10 +304,29 @@ export async function getUnifiedQuoteSwap( switch (chain.toLowerCase()) { case 'ethereum': - return getEthereumQuoteSwap(network, baseToken, quoteToken, amount, side, slippagePct, connector); + return getEthereumQuoteSwap( + network, + baseToken, + quoteToken, + amount, + side, + slippagePct, + connector, + requestedPoolAddress, + ); case 'solana': - return getSolanaQuoteSwap(network, baseToken, quoteToken, amount, side, slippagePct, connector); + return getSolanaQuoteSwap( + network, + baseToken, + quoteToken, + amount, + side, + slippagePct, + connector, + approximateIfNoExactOut, + requestedPoolAddress, + ); default: throw httpErrors.badRequest(`Unsupported chain: ${chain}`); @@ -294,8 +351,17 @@ export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { }, }, async (request, reply) => { - const { chainNetwork, baseToken, quoteToken, amount, side, slippagePct, connector } = - request.query as UnifiedQuoteSwapRequest; + const { + chainNetwork, + baseToken, + quoteToken, + amount, + side, + slippagePct, + approximateIfNoExactOut, + connector, + poolAddress, + } = request.query as UnifiedQuoteSwapRequest; try { const result = await getUnifiedQuoteSwap( @@ -306,14 +372,12 @@ export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { side as 'BUY' | 'SELL', slippagePct, connector, + approximateIfNoExactOut, + poolAddress, ); return reply.code(200).send(result); } catch (error: any) { - logger.error(`[UnifiedSwap] Quote error: ${error.message}`); - if (error.statusCode) { - throw error; - } - throw fastify.httpErrors.internalServerError(error.message || 'Failed to get swap quote'); + rethrowRouteError(error, 'Failed to get swap quote'); } }, ); diff --git a/src/trading/trading-amm-routes/add-liquidity.ts b/src/trading/trading-amm-routes/add-liquidity.ts index 6b02ee97d6..523e8b0b53 100644 --- a/src/trading/trading-amm-routes/add-liquidity.ts +++ b/src/trading/trading-amm-routes/add-liquidity.ts @@ -7,16 +7,19 @@ import { addLiquidity as raydiumAddLiquidity } from '../../connectors/raydium/am import { addLiquidity as uniswapAddLiquidity } from '../../connectors/uniswap/amm-routes/addLiquidity'; import { AddLiquidityResponse, AddLiquidityResponseType } from '../../schemas/amm-schema'; import { httpErrors } from '../../services/error-handler'; -import { logger } from '../../services/logger'; - -import { AMM_CONNECTORS, parseChainNetwork, defaultWallet } from './common'; +import { + AMM_CONNECTORS, + chainNetworkField, + connectorField, + defaultWallet, + parseChainNetwork, + rethrowRouteError, + slippagePctField, +} from '../common'; const UnifiedAmmAddLiquidityRequest = Type.Object({ - connector: Type.String({ description: 'AMM connector (meteora, raydium, uniswap)', default: 'meteora' }), - chainNetwork: Type.String({ - description: 'Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', - default: 'solana-mainnet-beta', - }), + connector: connectorField(AMM_CONNECTORS, 'AMM connector'), + chainNetwork: chainNetworkField(), walletAddress: Type.String({ description: 'Wallet address', default: defaultWallet }), poolAddress: Type.String({ description: 'Pool contract address' }), baseTokenAmount: Type.Number({ description: 'Amount of base token to add' }), @@ -28,7 +31,7 @@ const UnifiedAmmAddLiquidityRequest = Type.Object({ 'position. Ignored by fungible-LP AMMs.', }), ), - slippagePct: Type.Optional(Type.Number({ minimum: 0, maximum: 100 })), + slippagePct: slippagePctField(), }); export const addLiquidityRoute: FastifyPluginAsync = async (fastify) => { @@ -102,9 +105,7 @@ export const addLiquidityRoute: FastifyPluginAsync = async (fastify) => { ); } } catch (e: any) { - logger.error('Failed to add AMM liquidity:', e); - if (e.statusCode) throw e; - throw httpErrors.internalServerError('Failed to add liquidity'); + rethrowRouteError(e, 'Failed to add AMM liquidity'); } }, ); diff --git a/src/trading/trading-amm-routes/common.ts b/src/trading/trading-amm-routes/common.ts deleted file mode 100644 index a0e702f621..0000000000 --- a/src/trading/trading-amm-routes/common.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { getEthereumChainConfig } from '../../chains/ethereum/ethereum.config'; -import { getSolanaChainConfig } from '../../chains/solana/solana.config'; - -/** AMM connectors that back the unified /trading/amm routes. */ -export const AMM_CONNECTORS = ['meteora', 'raydium', 'uniswap', 'pancakeswap']; - -/** Parse a chain-network string (e.g. "solana-mainnet-beta") into its chain and network parts. */ -export function parseChainNetwork(chainNetwork: string): { chain: string; network: string } { - const parts = chainNetwork.split('-'); - if (parts.length < 2) { - throw new Error( - `Invalid chain-network format: ${chainNetwork}. Expected format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)`, - ); - } - return { chain: parts[0], network: parts.slice(1).join('-') }; -} - -// Default wallet from Solana config, falling back to Ethereum when Solana is unavailable. -let dw: string; -try { - dw = getSolanaChainConfig().defaultWallet; -} catch { - dw = getEthereumChainConfig().defaultWallet; -} -export const defaultWallet = dw; diff --git a/src/trading/trading-amm-routes/create-pool.ts b/src/trading/trading-amm-routes/create-pool.ts index a72e8c4b24..c8aee96b76 100644 --- a/src/trading/trading-amm-routes/create-pool.ts +++ b/src/trading/trading-amm-routes/create-pool.ts @@ -1,88 +1,54 @@ import { Static, Type } from '@sinclair/typebox'; import { FastifyPluginAsync } from 'fastify'; -import { getEthereumChainConfig } from '../../chains/ethereum/ethereum.config'; -import { getSolanaChainConfig } from '../../chains/solana/solana.config'; import { createPool as meteoraCreatePool } from '../../connectors/meteora/amm-routes/createPool'; import { createPool as pancakeswapCreatePool } from '../../connectors/pancakeswap/amm-routes/createPool'; import { createPool as raydiumCreatePool } from '../../connectors/raydium/amm-routes/createPool'; import { createPool as uniswapCreatePool } from '../../connectors/uniswap/amm-routes/createPool'; -import { CreatePoolResponse, CreatePoolResponseType } from '../../schemas/amm-schema'; +import { CreatePoolRequest, CreatePoolResponse, CreatePoolResponseType } from '../../schemas/amm-schema'; import { httpErrors } from '../../services/error-handler'; -import { logger } from '../../services/logger'; +import { + AMM_CONNECTORS, + chainNetworkField, + connectorField, + defaultWallet, + parseChainNetwork, + rethrowRouteError, + slippagePctField, +} from '../common'; -// Get default wallet from Solana config, fallback to Ethereum if Solana doesn't exist -let defaultWallet: string; -try { - const solanaChainConfig = getSolanaChainConfig(); - defaultWallet = solanaChainConfig.defaultWallet; -} catch { - const ethereumChainConfig = getEthereumChainConfig(); - defaultWallet = ethereumChainConfig.defaultWallet; -} - -/** - * Parse chain-network parameter into chain and network. - */ -function parseChainNetwork(chainNetwork: string): { chain: string; network: string } { - const parts = chainNetwork.split('-'); - if (parts.length < 2) { - throw new Error( - `Invalid chain-network format: ${chainNetwork}. Expected format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)`, - ); - } - return { chain: parts[0], network: parts.slice(1).join('-') }; -} - -// Unified schema with a connector field. Per-connector create-pool extras are optional -// and only consumed by their owning connector (configAddress → meteora, feeConfigIndex → -// raydium, gasPrice/maxGas/slippagePct → uniswap). See docs/connectors/meteora-damm-v2.md. -const UnifiedCreatePoolRequest = Type.Object({ - connector: Type.String({ - description: 'AMM connector name (meteora, raydium, uniswap)', - default: 'meteora', - examples: ['meteora'], - }), - chainNetwork: Type.String({ - description: 'Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', - default: 'solana-mainnet-beta', - examples: ['solana-mainnet-beta'], +// Composed from the canonical CreatePoolRequest (schemas/amm-schema.ts): the +// unified route swaps per-connector `network` for connector + chainNetwork, +// defaults the wallet, and adds the per-protocol fee-config selectors. +const UnifiedCreatePoolRequest = Type.Composite([ + Type.Object({ + connector: connectorField(AMM_CONNECTORS, 'AMM connector'), + chainNetwork: chainNetworkField(), + walletAddress: Type.String({ + description: 'Wallet address (pool creator + payer)', + default: defaultWallet, + }), }), - walletAddress: Type.String({ - description: 'Wallet address (pool creator + payer)', - default: defaultWallet, + Type.Omit(CreatePoolRequest, ['network', 'walletAddress'], {}), + // Optional per-protocol fee-config selectors, last so required fields lead the schema: + Type.Object({ + configAddress: Type.Optional( + Type.String({ + 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).', + }), + ), + ammConfigIndex: Type.Optional( + Type.Number({ + description: 'Raydium CPMM fee-config index (optional; defaults to the first available config).', + }), + ), + slippagePct: slippagePctField( + "Uniswap/PancakeSwap seeding slippage percentage. Defaults to the connector's configured slippagePct.", + ), }), - baseToken: Type.String({ description: 'Base token symbol or address (becomes the pool base)' }), - quoteToken: Type.String({ description: 'Quote token symbol or address (becomes the pool quote)' }), - baseTokenAmount: Type.Number({ description: 'Amount of base token to seed the pool with' }), - quoteTokenAmount: Type.Optional( - Type.Number({ - 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.', - }), - ), - initialPrice: Type.Optional( - Type.Number({ - 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.', - }), - ), - // Connector-specific create-pool params (optional; ignored by connectors that do not use them): - configAddress: Type.Optional( - Type.String({ description: 'Meteora DAMM v2 config account address (required for the meteora connector)' }), - ), - feeConfigIndex: Type.Optional( - Type.Number({ description: 'Raydium CPMM fee config index (optional; defaults to the first available config)' }), - ), - openTime: Type.Optional(Type.Number({ description: 'Raydium CPMM pool open time (unix seconds; optional)' })), - gasPrice: Type.Optional(Type.Number({ description: 'Uniswap (EVM) gas price in gwei (optional)' })), - maxGas: Type.Optional(Type.Number({ description: 'Uniswap (EVM) max gas limit (optional)' })), - slippagePct: Type.Optional( - Type.Number({ minimum: 0, maximum: 100, description: 'Uniswap seeding slippage percentage (optional)' }), - ), -}); +]); export const createPoolRoute: FastifyPluginAsync = async (fastify) => { fastify.post<{ @@ -93,7 +59,8 @@ export const createPoolRoute: FastifyPluginAsync = async (fastify) => { { schema: { description: - 'Create and seed a new AMM pool across supported connectors (Meteora DAMM v2, Raydium CPMM, Uniswap V2)', + 'Create and seed a new AMM pool across supported connectors (Meteora DAMM v2, Raydium CPMM, ' + + 'Uniswap V2, PancakeSwap V2)', tags: ['/trading/amm'], body: UnifiedCreatePoolRequest, response: { @@ -113,10 +80,7 @@ export const createPoolRoute: FastifyPluginAsync = async (fastify) => { quoteTokenAmount, initialPrice, configAddress, - feeConfigIndex, - openTime, - gasPrice, - maxGas, + ammConfigIndex, slippagePct, } = request.body; @@ -144,8 +108,7 @@ export const createPoolRoute: FastifyPluginAsync = async (fastify) => { baseTokenAmount, quoteTokenAmount, initialPrice, - feeConfigIndex, - openTime, + ammConfigIndex, ); case 'uniswap': @@ -157,8 +120,6 @@ export const createPoolRoute: FastifyPluginAsync = async (fastify) => { baseTokenAmount, quoteTokenAmount, initialPrice, - gasPrice, - maxGas, slippagePct, ); @@ -171,8 +132,6 @@ export const createPoolRoute: FastifyPluginAsync = async (fastify) => { baseTokenAmount, quoteTokenAmount, initialPrice, - gasPrice, - maxGas, slippagePct, ); @@ -180,9 +139,7 @@ export const createPoolRoute: FastifyPluginAsync = async (fastify) => { throw httpErrors.badRequest(`Unsupported AMM connector: ${connector}`); } } catch (e: any) { - logger.error('Failed to create pool:', e); - if (e.statusCode) throw e; - throw httpErrors.internalServerError('Failed to create pool'); + rethrowRouteError(e, 'Failed to create pool'); } }, ); diff --git a/src/trading/trading-amm-routes/execute-swap.ts b/src/trading/trading-amm-routes/execute-swap.ts deleted file mode 100644 index bbdceb1912..0000000000 --- a/src/trading/trading-amm-routes/execute-swap.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { Type, Static } from '@sinclair/typebox'; -import { FastifyPluginAsync } from 'fastify'; - -import { executeSwap as meteoraExecuteSwap } from '../../connectors/meteora/amm-routes/executeSwap'; -import { executeSwap as pancakeswapExecuteSwap } from '../../connectors/pancakeswap/amm-routes/executeSwap'; -import { executeSwap as raydiumExecuteSwap } from '../../connectors/raydium/amm-routes/executeSwap'; -import { executeSwap as uniswapExecuteSwap } from '../../connectors/uniswap/amm-routes/executeSwap'; -import { ExecuteSwapResponse, ExecuteSwapResponseType } from '../../schemas/amm-schema'; -import { httpErrors } from '../../services/error-handler'; -import { logger } from '../../services/logger'; - -import { AMM_CONNECTORS, parseChainNetwork, defaultWallet } from './common'; - -const UnifiedAmmExecuteSwapRequest = Type.Object({ - connector: Type.String({ description: 'AMM connector (meteora, raydium, uniswap)', default: 'meteora' }), - chainNetwork: Type.String({ - description: 'Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', - default: 'solana-mainnet-beta', - }), - walletAddress: Type.String({ description: 'Wallet address', default: defaultWallet }), - poolAddress: Type.String({ description: 'Pool contract address' }), - baseToken: Type.String({ description: 'Base token symbol or address (determines swap direction)' }), - amount: Type.Number({ description: 'Amount denominated in the base token' }), - side: Type.String({ description: 'Trade direction', enum: ['BUY', 'SELL'] }), - slippagePct: Type.Optional(Type.Number({ minimum: 0, maximum: 100 })), -}); - -export const executeSwapRoute: FastifyPluginAsync = async (fastify) => { - fastify.post<{ - Body: Static; - Reply: ExecuteSwapResponseType; - }>( - '/execute-swap', - { - schema: { - description: 'Execute a swap against a specific AMM pool from any supported connector', - tags: ['/trading/amm'], - body: UnifiedAmmExecuteSwapRequest, - response: { 200: ExecuteSwapResponse }, - }, - }, - async (request) => { - try { - const { connector, chainNetwork, walletAddress, poolAddress, baseToken, amount, side, slippagePct } = - request.body; - const { network } = parseChainNetwork(chainNetwork); - const s = side as 'BUY' | 'SELL'; - switch (connector) { - case 'meteora': - return await meteoraExecuteSwap(network, walletAddress, poolAddress, baseToken, s, amount, slippagePct); - case 'raydium': - return await raydiumExecuteSwap(network, walletAddress, poolAddress, baseToken, s, amount, slippagePct); - case 'uniswap': - return await uniswapExecuteSwap(network, walletAddress, poolAddress, baseToken, s, amount, slippagePct); - case 'pancakeswap': - return await pancakeswapExecuteSwap(network, walletAddress, poolAddress, baseToken, s, amount, slippagePct); - default: - throw httpErrors.badRequest( - `Unsupported AMM connector: ${connector}. Supported: ${AMM_CONNECTORS.join(', ')}`, - ); - } - } catch (e: any) { - logger.error('Failed to execute AMM swap:', e); - if (e.statusCode) throw e; - throw httpErrors.internalServerError('Failed to execute swap'); - } - }, - ); -}; - -export default executeSwapRoute; diff --git a/src/trading/trading-amm-routes/index.ts b/src/trading/trading-amm-routes/index.ts index 9b621f1548..46b40d7bec 100644 --- a/src/trading/trading-amm-routes/index.ts +++ b/src/trading/trading-amm-routes/index.ts @@ -2,8 +2,6 @@ export { createPoolRoute } from './create-pool'; export { poolInfoRoute } from './pool-info'; export { positionInfoRoute } from './position-info'; export { positionsOwnedRoute } from './positions-owned'; -export { quoteSwapRoute } from './quote-swap'; -export { executeSwapRoute } from './execute-swap'; export { quoteLiquidityRoute } from './quote-liquidity'; export { addLiquidityRoute } from './add-liquidity'; export { removeLiquidityRoute } from './remove-liquidity'; diff --git a/src/trading/trading-amm-routes/pool-info.ts b/src/trading/trading-amm-routes/pool-info.ts index 2f553eb89b..de8b90f6c0 100644 --- a/src/trading/trading-amm-routes/pool-info.ts +++ b/src/trading/trading-amm-routes/pool-info.ts @@ -7,16 +7,11 @@ import { getPoolInfo as raydiumGetPoolInfo } from '../../connectors/raydium/amm- import { getPoolInfo as uniswapGetPoolInfo } from '../../connectors/uniswap/amm-routes/poolInfo'; import { PoolInfo, PoolInfoSchema } from '../../schemas/amm-schema'; import { httpErrors } from '../../services/error-handler'; -import { logger } from '../../services/logger'; - -import { AMM_CONNECTORS, parseChainNetwork } from './common'; +import { AMM_CONNECTORS, chainNetworkField, connectorField, parseChainNetwork, rethrowRouteError } from '../common'; const UnifiedAmmPoolInfoRequest = Type.Object({ - connector: Type.String({ description: 'AMM connector (meteora, raydium, uniswap)', default: 'meteora' }), - chainNetwork: Type.String({ - description: 'Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', - default: 'solana-mainnet-beta', - }), + connector: connectorField(AMM_CONNECTORS, 'AMM connector'), + chainNetwork: chainNetworkField(), poolAddress: Type.String({ description: 'Pool contract address' }), }); @@ -53,9 +48,7 @@ export const poolInfoRoute: FastifyPluginAsync = async (fastify) => { ); } } catch (e: any) { - logger.error('Failed to get AMM pool info:', e); - if (e.statusCode) throw e; - throw httpErrors.internalServerError('Failed to get pool info'); + rethrowRouteError(e, 'Failed to get AMM pool info'); } }, ); diff --git a/src/trading/trading-amm-routes/position-info.ts b/src/trading/trading-amm-routes/position-info.ts index 8cde9b4c73..fdd0c84b1e 100644 --- a/src/trading/trading-amm-routes/position-info.ts +++ b/src/trading/trading-amm-routes/position-info.ts @@ -7,16 +7,18 @@ import { getPositionInfo as raydiumGetPositionInfo } from '../../connectors/rayd import { getPositionInfo as uniswapGetPositionInfo } from '../../connectors/uniswap/amm-routes/positionInfo'; import { PositionInfo, PositionInfoSchema } from '../../schemas/amm-schema'; import { httpErrors } from '../../services/error-handler'; -import { logger } from '../../services/logger'; - -import { AMM_CONNECTORS, parseChainNetwork, defaultWallet } from './common'; +import { + AMM_CONNECTORS, + chainNetworkField, + connectorField, + defaultWallet, + parseChainNetwork, + rethrowRouteError, +} from '../common'; const UnifiedAmmPositionInfoRequest = Type.Object({ - connector: Type.String({ description: 'AMM connector (meteora, raydium, uniswap)', default: 'meteora' }), - chainNetwork: Type.String({ - description: 'Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', - default: 'solana-mainnet-beta', - }), + connector: connectorField(AMM_CONNECTORS, 'AMM connector'), + chainNetwork: chainNetworkField(), poolAddress: Type.String({ description: 'Pool contract address' }), walletAddress: Type.String({ description: 'Wallet address', default: defaultWallet }), }); @@ -54,9 +56,7 @@ export const positionInfoRoute: FastifyPluginAsync = async (fastify) => { ); } } catch (e: any) { - logger.error('Failed to get AMM position info:', e); - if (e.statusCode) throw e; - throw httpErrors.internalServerError('Failed to get position info'); + rethrowRouteError(e, 'Failed to get AMM position info'); } }, ); diff --git a/src/trading/trading-amm-routes/positions-owned.ts b/src/trading/trading-amm-routes/positions-owned.ts index 43aec08d84..6b0ceb0033 100644 --- a/src/trading/trading-amm-routes/positions-owned.ts +++ b/src/trading/trading-amm-routes/positions-owned.ts @@ -4,16 +4,18 @@ import { FastifyPluginAsync } from 'fastify'; import { getPositionsOwned as meteoraGetPositionsOwned } from '../../connectors/meteora/amm-routes/positionsOwned'; import { PositionInfo, PositionInfoSchema } from '../../schemas/amm-schema'; import { httpErrors } from '../../services/error-handler'; -import { logger } from '../../services/logger'; - -import { AMM_CONNECTORS, parseChainNetwork, defaultWallet } from './common'; +import { + AMM_CONNECTORS, + chainNetworkField, + connectorField, + defaultWallet, + parseChainNetwork, + rethrowRouteError, +} from '../common'; const UnifiedAmmPositionsOwnedRequest = Type.Object({ - connector: Type.String({ description: 'AMM connector (meteora)', default: 'meteora' }), - chainNetwork: Type.String({ - description: 'Chain and network in format: chain-network (e.g., solana-mainnet-beta)', - default: 'solana-mainnet-beta', - }), + connector: connectorField(AMM_CONNECTORS, 'AMM connector (only non-fungible-LP AMMs supported: meteora)'), + chainNetwork: chainNetworkField(), walletAddress: Type.String({ description: 'Wallet address to list positions for', default: defaultWallet }), }); @@ -54,9 +56,7 @@ export const positionsOwnedRoute: FastifyPluginAsync = async (fastify) => { ); } } catch (e: any) { - logger.error('Failed to list AMM positions owned:', e); - if (e.statusCode) throw e; - throw httpErrors.internalServerError('Failed to list positions owned'); + rethrowRouteError(e, 'Failed to list AMM positions owned'); } }, ); diff --git a/src/trading/trading-amm-routes/quote-liquidity.ts b/src/trading/trading-amm-routes/quote-liquidity.ts index 49d073ad28..4d4211fd4c 100644 --- a/src/trading/trading-amm-routes/quote-liquidity.ts +++ b/src/trading/trading-amm-routes/quote-liquidity.ts @@ -7,20 +7,22 @@ import { quoteLiquidity as raydiumQuoteLiquidity } from '../../connectors/raydiu import { quoteLiquidity as uniswapQuoteLiquidity } from '../../connectors/uniswap/amm-routes/quoteLiquidity'; import { QuoteLiquidityResponse, QuoteLiquidityResponseType } from '../../schemas/amm-schema'; import { httpErrors } from '../../services/error-handler'; -import { logger } from '../../services/logger'; - -import { AMM_CONNECTORS, parseChainNetwork } from './common'; +import { + AMM_CONNECTORS, + chainNetworkField, + connectorField, + parseChainNetwork, + rethrowRouteError, + slippagePctField, +} from '../common'; const UnifiedAmmQuoteLiquidityRequest = Type.Object({ - connector: Type.String({ description: 'AMM connector (meteora, raydium, uniswap)', default: 'meteora' }), - chainNetwork: Type.String({ - description: 'Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', - default: 'solana-mainnet-beta', - }), + connector: connectorField(AMM_CONNECTORS, 'AMM connector'), + chainNetwork: chainNetworkField(), poolAddress: Type.String({ description: 'Pool contract address' }), baseTokenAmount: Type.Number({ description: 'Amount of base token to deposit' }), quoteTokenAmount: Type.Number({ description: 'Amount of quote token to deposit' }), - slippagePct: Type.Optional(Type.Number({ minimum: 0, maximum: 100 })), + slippagePct: slippagePctField(), }); export const quoteLiquidityRoute: FastifyPluginAsync = async (fastify) => { @@ -62,9 +64,7 @@ export const quoteLiquidityRoute: FastifyPluginAsync = async (fastify) => { ); } } catch (e: any) { - logger.error('Failed to quote AMM liquidity:', e); - if (e.statusCode) throw e; - throw httpErrors.internalServerError('Failed to quote liquidity'); + rethrowRouteError(e, 'Failed to quote AMM liquidity'); } }, ); diff --git a/src/trading/trading-amm-routes/quote-swap.ts b/src/trading/trading-amm-routes/quote-swap.ts deleted file mode 100644 index 72e467a6aa..0000000000 --- a/src/trading/trading-amm-routes/quote-swap.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { Type, Static } from '@sinclair/typebox'; -import { FastifyPluginAsync } from 'fastify'; - -import { quoteSwap as meteoraQuoteSwap } from '../../connectors/meteora/amm-routes/quoteSwap'; -import { quoteSwap as pancakeswapQuoteSwap } from '../../connectors/pancakeswap/amm-routes/quoteSwap'; -import { quoteSwap as raydiumQuoteSwap } from '../../connectors/raydium/amm-routes/quoteSwap'; -import { quoteSwap as uniswapQuoteSwap } from '../../connectors/uniswap/amm-routes/quoteSwap'; -import { QuoteSwapResponse, QuoteSwapResponseType } from '../../schemas/amm-schema'; -import { httpErrors } from '../../services/error-handler'; -import { logger } from '../../services/logger'; - -import { AMM_CONNECTORS, parseChainNetwork } from './common'; - -const UnifiedAmmQuoteSwapRequest = Type.Object({ - connector: Type.String({ description: 'AMM connector (meteora, raydium, uniswap)', default: 'meteora' }), - chainNetwork: Type.String({ - description: 'Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', - default: 'solana-mainnet-beta', - }), - poolAddress: Type.String({ description: 'Pool contract address' }), - baseToken: Type.String({ description: 'Base token symbol or address (determines swap direction)' }), - amount: Type.Number({ description: 'Amount denominated in the base token' }), - side: Type.String({ description: 'Trade direction', enum: ['BUY', 'SELL'] }), - slippagePct: Type.Optional(Type.Number({ minimum: 0, maximum: 100 })), -}); - -export const quoteSwapRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: Static; - Reply: QuoteSwapResponseType; - }>( - '/quote-swap', - { - schema: { - description: 'Get a swap quote against a specific AMM pool from any supported connector', - tags: ['/trading/amm'], - querystring: UnifiedAmmQuoteSwapRequest, - response: { 200: QuoteSwapResponse }, - }, - }, - async (request) => { - try { - const { connector, chainNetwork, poolAddress, baseToken, amount, side, slippagePct } = request.query; - const { network } = parseChainNetwork(chainNetwork); - const s = side as 'BUY' | 'SELL'; - switch (connector) { - case 'meteora': - return await meteoraQuoteSwap(network, poolAddress, baseToken, s, amount, slippagePct); - case 'raydium': - return await raydiumQuoteSwap(network, poolAddress, baseToken, s, amount, slippagePct); - case 'uniswap': - return await uniswapQuoteSwap(network, poolAddress, baseToken, s, amount, slippagePct); - case 'pancakeswap': - return await pancakeswapQuoteSwap(network, poolAddress, baseToken, s, amount, slippagePct); - default: - throw httpErrors.badRequest( - `Unsupported AMM connector: ${connector}. Supported: ${AMM_CONNECTORS.join(', ')}`, - ); - } - } catch (e: any) { - logger.error('Failed to get AMM swap quote:', e); - if (e.statusCode) throw e; - throw httpErrors.internalServerError('Failed to get swap quote'); - } - }, - ); -}; - -export default quoteSwapRoute; diff --git a/src/trading/trading-amm-routes/remove-liquidity.ts b/src/trading/trading-amm-routes/remove-liquidity.ts index f1c9aff3d5..049cf0c77b 100644 --- a/src/trading/trading-amm-routes/remove-liquidity.ts +++ b/src/trading/trading-amm-routes/remove-liquidity.ts @@ -7,16 +7,19 @@ import { removeLiquidity as raydiumRemoveLiquidity } from '../../connectors/rayd import { removeLiquidity as uniswapRemoveLiquidity } from '../../connectors/uniswap/amm-routes/removeLiquidity'; import { RemoveLiquidityResponse, RemoveLiquidityResponseType } from '../../schemas/amm-schema'; import { httpErrors } from '../../services/error-handler'; -import { logger } from '../../services/logger'; - -import { AMM_CONNECTORS, parseChainNetwork, defaultWallet } from './common'; +import { + AMM_CONNECTORS, + chainNetworkField, + connectorField, + defaultWallet, + parseChainNetwork, + rethrowRouteError, + slippagePctField, +} from '../common'; const UnifiedAmmRemoveLiquidityRequest = Type.Object({ - connector: Type.String({ description: 'AMM connector (meteora, raydium, uniswap)', default: 'meteora' }), - chainNetwork: Type.String({ - description: 'Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', - default: 'solana-mainnet-beta', - }), + connector: connectorField(AMM_CONNECTORS, 'AMM connector'), + chainNetwork: chainNetworkField(), walletAddress: Type.String({ description: 'Wallet address', default: defaultWallet }), poolAddress: Type.String({ description: 'Pool contract address' }), positionAddress: Type.Optional( @@ -26,8 +29,14 @@ const UnifiedAmmRemoveLiquidityRequest = Type.Object({ 'List positions with position-info or positions-owned. Ignored by fungible-LP AMMs.', }), ), - percentageToRemove: Type.Number({ minimum: 0, maximum: 100, description: 'Percentage of liquidity to remove' }), - slippagePct: Type.Optional(Type.Number({ minimum: 0, maximum: 100 })), + percentageToRemove: Type.Number({ + minimum: 0, + maximum: 100, + description: 'Percentage of liquidity to remove', + default: 100, + examples: [100], + }), + slippagePct: slippagePctField(), }); export const removeLiquidityRoute: FastifyPluginAsync = async (fastify) => { @@ -90,9 +99,7 @@ export const removeLiquidityRoute: FastifyPluginAsync = async (fastify) => { ); } } catch (e: any) { - logger.error('Failed to remove AMM liquidity:', e); - if (e.statusCode) throw e; - throw httpErrors.internalServerError('Failed to remove liquidity'); + rethrowRouteError(e, 'Failed to remove AMM liquidity'); } }, ); diff --git a/src/trading/trading-clmm-routes/add.ts b/src/trading/trading-clmm-routes/add.ts index 7eddf422a0..7f4c64bc17 100644 --- a/src/trading/trading-clmm-routes/add.ts +++ b/src/trading/trading-clmm-routes/add.ts @@ -1,8 +1,6 @@ import { Static, Type } from '@sinclair/typebox'; import { FastifyPluginAsync } from 'fastify'; -import { getEthereumChainConfig } from '../../chains/ethereum/ethereum.config'; -import { getSolanaChainConfig } from '../../chains/solana/solana.config'; import { addLiquidity as meteoraAddLiquidity } from '../../connectors/meteora/clmm-routes/addLiquidity'; import { addLiquidity as orcaAddLiquidity } from '../../connectors/orca/clmm-routes/addLiquidity'; import { addLiquidity as pancakeswapAddLiquidity } from '../../connectors/pancakeswap/clmm-routes/addLiquidity'; @@ -11,52 +9,24 @@ import { addLiquidity as raydiumAddLiquidity } from '../../connectors/raydium/cl import { addLiquidity as uniswapAddLiquidity } from '../../connectors/uniswap/clmm-routes/addLiquidity'; import { AddLiquidityResponseType, AddLiquidityResponse } from '../../schemas/clmm-schema'; import { httpErrors } from '../../services/error-handler'; -import { logger } from '../../services/logger'; - -// Get default wallet from Solana config, fallback to Ethereum if Solana doesn't exist -let defaultWallet: string; -try { - const solanaChainConfig = getSolanaChainConfig(); - defaultWallet = solanaChainConfig.defaultWallet; -} catch { - const ethereumChainConfig = getEthereumChainConfig(); - defaultWallet = ethereumChainConfig.defaultWallet; -} +import { + chainNetworkField, + CLMM_CONNECTORS, + connectorField, + defaultWallet, + parseChainNetwork, + rethrowRouteError, + slippagePctField, +} from '../common'; // Constants for examples (using Meteora CLMM values) const BASE_TOKEN_AMOUNT = 0.01; const QUOTE_TOKEN_AMOUNT = 2; -/** - * Parse chain-network parameter into chain and network - */ -function parseChainNetwork(chainNetwork: string): { chain: string; network: string } { - const parts = chainNetwork.split('-'); - - if (parts.length < 2) { - throw new Error( - `Invalid chain-network format: ${chainNetwork}. Expected format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)`, - ); - } - - const chain = parts[0]; - const network = parts.slice(1).join('-'); - - return { chain, network }; -} - // Unified schema with connector field const UnifiedAddLiquidityRequest = Type.Object({ - connector: Type.String({ - description: 'Connector name (uniswap, pancakeswap, raydium, meteora, pancakeswap-sol, orca)', - default: 'meteora', - examples: ['meteora'], - }), - chainNetwork: Type.String({ - description: 'Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', - default: 'solana-mainnet-beta', - examples: ['solana-mainnet-beta'], - }), + connector: connectorField(CLMM_CONNECTORS, 'CLMM connector'), + chainNetwork: chainNetworkField(), walletAddress: Type.String({ description: 'Wallet address', default: defaultWallet, @@ -77,13 +47,14 @@ const UnifiedAddLiquidityRequest = Type.Object({ examples: [QUOTE_TOKEN_AMOUNT], }), ), - slippagePct: Type.Optional( + slippagePct: slippagePctField(), + // Meteora-specific parameter (optional, ignored by other connectors). Without it an + // add falls back to the connector-config default shape, which can silently differ + // from the shape the position was opened with. + strategyType: Type.Optional( Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: 1, - examples: [1], + description: 'Strategy type for Meteora positions (0=Spot, 1=Curve). Only applies to Meteora connector.', + examples: [0], }), ), }); @@ -116,6 +87,7 @@ export const addLiquidityRoute: FastifyPluginAsync = async (fastify) => { baseTokenAmount, quoteTokenAmount, slippagePct, + strategyType, } = request.body; // Parse chain and network from chainNetwork parameter @@ -168,6 +140,7 @@ export const addLiquidityRoute: FastifyPluginAsync = async (fastify) => { baseAmount, quoteAmount, slippagePct, + strategyType, ); case 'pancakeswap-sol': @@ -194,11 +167,7 @@ export const addLiquidityRoute: FastifyPluginAsync = async (fastify) => { throw httpErrors.badRequest(`Unsupported connector: ${connector}`); } } catch (e: any) { - logger.error('Failed to add liquidity:', e); - if (e.statusCode) { - throw e; - } - throw httpErrors.internalServerError('Failed to add liquidity'); + rethrowRouteError(e, 'Failed to add liquidity'); } }, ); diff --git a/src/trading/trading-clmm-routes/close.ts b/src/trading/trading-clmm-routes/close.ts index 45b3469f73..3a5904ad38 100644 --- a/src/trading/trading-clmm-routes/close.ts +++ b/src/trading/trading-clmm-routes/close.ts @@ -1,8 +1,6 @@ import { Static, Type } from '@sinclair/typebox'; import { FastifyPluginAsync } from 'fastify'; -import { getEthereumChainConfig } from '../../chains/ethereum/ethereum.config'; -import { getSolanaChainConfig } from '../../chains/solana/solana.config'; import { closePosition as meteoraClosePosition } from '../../connectors/meteora/clmm-routes/closePosition'; import { closePosition as orcaClosePosition } from '../../connectors/orca/clmm-routes/closePosition'; import { closePosition as pancakeswapClosePosition } from '../../connectors/pancakeswap/clmm-routes/closePosition'; @@ -11,48 +9,19 @@ import { closePosition as raydiumClosePosition } from '../../connectors/raydium/ import { closePosition as uniswapClosePosition } from '../../connectors/uniswap/clmm-routes/closePosition'; import { ClosePositionResponseType, ClosePositionResponse } from '../../schemas/clmm-schema'; import { httpErrors } from '../../services/error-handler'; -import { logger } from '../../services/logger'; - -// Get default wallet from Solana config, fallback to Ethereum if Solana doesn't exist -let defaultWallet: string; -try { - const solanaChainConfig = getSolanaChainConfig(); - defaultWallet = solanaChainConfig.defaultWallet; -} catch { - const ethereumChainConfig = getEthereumChainConfig(); - defaultWallet = ethereumChainConfig.defaultWallet; -} - -/** - * Parse chain-network parameter into chain and network - */ -function parseChainNetwork(chainNetwork: string): { chain: string; network: string } { - const parts = chainNetwork.split('-'); - - if (parts.length < 2) { - throw new Error( - `Invalid chain-network format: ${chainNetwork}. Expected format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)`, - ); - } - - const chain = parts[0]; - const network = parts.slice(1).join('-'); - - return { chain, network }; -} +import { + chainNetworkField, + CLMM_CONNECTORS, + connectorField, + defaultWallet, + parseChainNetwork, + rethrowRouteError, +} from '../common'; // Unified schema with connector field const UnifiedClosePositionRequest = Type.Object({ - connector: Type.String({ - description: 'Connector name (uniswap, pancakeswap, raydium, meteora, pancakeswap-sol, orca)', - default: 'meteora', - examples: ['meteora'], - }), - chainNetwork: Type.String({ - description: 'Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', - default: 'solana-mainnet-beta', - examples: ['solana-mainnet-beta'], - }), + connector: connectorField(CLMM_CONNECTORS, 'CLMM connector'), + chainNetwork: chainNetworkField(), walletAddress: Type.String({ description: 'Wallet address', default: defaultWallet, @@ -112,11 +81,7 @@ export const closePositionRoute: FastifyPluginAsync = async (fastify) => { throw httpErrors.badRequest(`Unsupported connector: ${connector}`); } } catch (e: any) { - logger.error('Failed to close position:', e); - if (e.statusCode) { - throw e; - } - throw httpErrors.internalServerError('Failed to close position'); + rethrowRouteError(e, 'Failed to close position'); } }, ); diff --git a/src/trading/trading-clmm-routes/collect-fees.ts b/src/trading/trading-clmm-routes/collect-fees.ts index 8d0806e37f..5c08de9e52 100644 --- a/src/trading/trading-clmm-routes/collect-fees.ts +++ b/src/trading/trading-clmm-routes/collect-fees.ts @@ -1,8 +1,6 @@ import { Static, Type } from '@sinclair/typebox'; import { FastifyPluginAsync } from 'fastify'; -import { getEthereumChainConfig } from '../../chains/ethereum/ethereum.config'; -import { getSolanaChainConfig } from '../../chains/solana/solana.config'; import { collectFees as meteoraCollectFees } from '../../connectors/meteora/clmm-routes/collectFees'; import { collectFees as orcaCollectFees } from '../../connectors/orca/clmm-routes/collectFees'; import { collectFees as pancakeswapCollectFees } from '../../connectors/pancakeswap/clmm-routes/collectFees'; @@ -11,48 +9,19 @@ import { collectFees as raydiumCollectFees } from '../../connectors/raydium/clmm import { collectFees as uniswapCollectFees } from '../../connectors/uniswap/clmm-routes/collectFees'; import { CollectFeesResponseType, CollectFeesResponse } from '../../schemas/clmm-schema'; import { httpErrors } from '../../services/error-handler'; -import { logger } from '../../services/logger'; - -// Get default wallet from Solana config, fallback to Ethereum if Solana doesn't exist -let defaultWallet: string; -try { - const solanaChainConfig = getSolanaChainConfig(); - defaultWallet = solanaChainConfig.defaultWallet; -} catch { - const ethereumChainConfig = getEthereumChainConfig(); - defaultWallet = ethereumChainConfig.defaultWallet; -} - -/** - * Parse chain-network parameter into chain and network - */ -function parseChainNetwork(chainNetwork: string): { chain: string; network: string } { - const parts = chainNetwork.split('-'); - - if (parts.length < 2) { - throw new Error( - `Invalid chain-network format: ${chainNetwork}. Expected format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)`, - ); - } - - const chain = parts[0]; - const network = parts.slice(1).join('-'); - - return { chain, network }; -} +import { + chainNetworkField, + CLMM_CONNECTORS, + connectorField, + defaultWallet, + parseChainNetwork, + rethrowRouteError, +} from '../common'; // Unified schema with connector field const UnifiedCollectFeesRequest = Type.Object({ - connector: Type.String({ - description: 'Connector name (uniswap, pancakeswap, raydium, meteora, pancakeswap-sol, orca)', - default: 'meteora', - examples: ['meteora'], - }), - chainNetwork: Type.String({ - description: 'Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', - default: 'solana-mainnet-beta', - examples: ['solana-mainnet-beta'], - }), + connector: connectorField(CLMM_CONNECTORS, 'CLMM connector'), + chainNetwork: chainNetworkField(), walletAddress: Type.String({ description: 'Wallet address', default: defaultWallet, @@ -112,11 +81,7 @@ export const collectFeesRoute: FastifyPluginAsync = async (fastify) => { throw httpErrors.badRequest(`Unsupported connector: ${connector}`); } } catch (e: any) { - logger.error('Failed to collect fees:', e); - if (e.statusCode) { - throw e; - } - throw httpErrors.internalServerError('Failed to collect fees'); + rethrowRouteError(e, 'Failed to collect fees'); } }, ); diff --git a/src/trading/trading-clmm-routes/create-pool.ts b/src/trading/trading-clmm-routes/create-pool.ts index 9aef970949..de5d829321 100644 --- a/src/trading/trading-clmm-routes/create-pool.ts +++ b/src/trading/trading-clmm-routes/create-pool.ts @@ -1,74 +1,41 @@ import { Static, Type } from '@sinclair/typebox'; import { FastifyPluginAsync } from 'fastify'; -import { getEthereumChainConfig } from '../../chains/ethereum/ethereum.config'; -import { getSolanaChainConfig } from '../../chains/solana/solana.config'; import { createPool as meteoraCreatePool } from '../../connectors/meteora/clmm-routes/createPool'; import { createPool as orcaCreatePool } from '../../connectors/orca/clmm-routes/createPool'; import { createPool as pancakeswapCreatePool } from '../../connectors/pancakeswap/clmm-routes/createPool'; import { createPool as pancakeswapSolCreatePool } from '../../connectors/pancakeswap-sol/clmm-routes/createPool'; import { createPool as raydiumCreatePool } from '../../connectors/raydium/clmm-routes/createPool'; import { createPool as uniswapCreatePool } from '../../connectors/uniswap/clmm-routes/createPool'; -import { CreatePoolResponse, CreatePoolResponseType } from '../../schemas/amm-schema'; +import { + CreatePoolResponse, + CreatePoolResponseType, + CreatePoolRequest as ClmmCreatePoolRequest, +} from '../../schemas/clmm-schema'; import { httpErrors } from '../../services/error-handler'; -import { logger } from '../../services/logger'; - -// Get default wallet from Solana config, fallback to Ethereum if Solana doesn't exist -let defaultWallet: string; -try { - defaultWallet = getSolanaChainConfig().defaultWallet; -} catch { - defaultWallet = getEthereumChainConfig().defaultWallet; -} - -function parseChainNetwork(chainNetwork: string): { chain: string; network: string } { - const parts = chainNetwork.split('-'); - if (parts.length < 2) { - throw new Error( - `Invalid chain-network format: ${chainNetwork}. Expected format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)`, - ); - } - return { chain: parts[0], network: parts.slice(1).join('-') }; -} +import { + chainNetworkField, + CLMM_CONNECTORS, + connectorField, + defaultWallet, + parseChainNetwork, + rethrowRouteError, +} from '../common'; // Unified CLMM create-pool. Creates + initializes a pool at an initial price (no position is // seeded — concentrated-liquidity positions need a range, opened separately via open-position). // Per-connector extras are optional and consumed only by their owning connector. -const UnifiedClmmCreatePoolRequest = Type.Object({ - connector: Type.String({ - description: 'CLMM connector name (meteora, raydium, uniswap, orca, pancakeswap, pancakeswap-sol)', - default: 'meteora', - examples: ['meteora'], - }), - chainNetwork: Type.String({ - description: 'Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', - default: 'solana-mainnet-beta', - examples: ['solana-mainnet-beta'], +// Composed from the canonical ClmmCreatePoolRequest (schemas/clmm-schema.ts): +// the unified route swaps per-connector `network` for connector + chainNetwork +// and defaults the wallet. +const UnifiedClmmCreatePoolRequest = Type.Composite([ + Type.Object({ + connector: connectorField(CLMM_CONNECTORS, 'CLMM connector'), + chainNetwork: chainNetworkField(), + walletAddress: Type.String({ description: 'Wallet address (pool creator + payer)', default: defaultWallet }), }), - walletAddress: Type.String({ description: 'Wallet address (pool creator + payer)', default: defaultWallet }), - baseToken: Type.String({ description: 'Base token symbol or address' }), - quoteToken: Type.String({ description: 'Quote token symbol or address' }), - initialPrice: Type.Optional( - Type.Number({ - 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.', - }), - ), - // Connector-specific extras (optional; ignored by connectors that do not use them): - binStep: Type.Optional(Type.Number({ description: 'Meteora DLMM bin step (bps)' })), - feeBps: Type.Optional(Type.Number({ description: 'Meteora DLMM base fee (bps)' })), - ammConfigIndex: Type.Optional(Type.Number({ description: 'Raydium CLMM AMM config index (fee tier)' })), - fee: Type.Optional( - Type.Number({ - description: 'V3 fee tier — Uniswap (100 | 500 | 3000 | 10000) or PancakeSwap (100 | 500 | 2500 | 10000)', - }), - ), - tickSpacing: Type.Optional(Type.Number({ description: 'Orca Whirlpool tick spacing (fee tier)' })), - ammConfig: Type.Optional(Type.String({ description: 'pancakeswap-sol CLMM amm_config account address (required)' })), - gasPrice: Type.Optional(Type.Number({ description: 'EVM gas price in gwei (uniswap/pancakeswap)' })), - maxGas: Type.Optional(Type.Number({ description: 'EVM max gas limit (uniswap/pancakeswap)' })), -}); + Type.Omit(ClmmCreatePoolRequest, ['network', 'walletAddress'], {}), +]); export const createPoolRoute: FastifyPluginAsync = async (fastify) => { fastify.post<{ @@ -79,7 +46,8 @@ export const createPoolRoute: FastifyPluginAsync = async (fastify) => { { schema: { description: - 'Create and initialize a new CLMM pool across supported connectors (Meteora DLMM, Raydium CLMM, Uniswap V3)', + 'Create and initialize a new CLMM pool across supported connectors (Meteora DLMM, Raydium CLMM, ' + + 'PancakeSwap Solana CLMM, Orca Whirlpool, Uniswap V3, PancakeSwap V3)', tags: ['/trading/clmm'], body: UnifiedClmmCreatePoolRequest, response: { 200: CreatePoolResponse }, @@ -97,15 +65,20 @@ export const createPoolRoute: FastifyPluginAsync = async (fastify) => { binStep, feeBps, ammConfigIndex, - fee, - tickSpacing, - ammConfig, - gasPrice, - maxGas, } = request.body; const { network } = parseChainNetwork(chainNetwork); + // EVM V3 fee tiers are denominated in hundredths of a bip; feeBps is the + // route's one fee vocabulary, so map it (1 bps -> 100). + if ((connector === 'uniswap' || connector === 'pancakeswap') && feeBps === undefined) { + throw httpErrors.badRequest( + `feeBps is required for ${connector}: the V3 fee tier in basis points ` + + '(1, 5, 30 or 100; pancakeswap also 25)', + ); + } + const evmFeeTier = feeBps === undefined ? undefined : feeBps * 100; + switch (connector) { case 'meteora': return await meteoraCreatePool( @@ -120,29 +93,13 @@ export const createPoolRoute: FastifyPluginAsync = async (fastify) => { case 'raydium': return await raydiumCreatePool(network, walletAddress, baseToken, quoteToken, initialPrice, ammConfigIndex); case 'uniswap': - return await uniswapCreatePool( - network, - walletAddress, - baseToken, - quoteToken, - initialPrice, - fee, - gasPrice, - maxGas, - ); + return await uniswapCreatePool(network, walletAddress, baseToken, quoteToken, initialPrice, evmFeeTier); case 'orca': - return await orcaCreatePool(network, walletAddress, baseToken, quoteToken, initialPrice, tickSpacing); + // Orca's fee tier IS its tick spacing — binStep is the route's one + // granularity vocabulary. + return await orcaCreatePool(network, walletAddress, baseToken, quoteToken, initialPrice, binStep); case 'pancakeswap': - return await pancakeswapCreatePool( - network, - walletAddress, - baseToken, - quoteToken, - initialPrice, - fee, - gasPrice, - maxGas, - ); + return await pancakeswapCreatePool(network, walletAddress, baseToken, quoteToken, initialPrice, evmFeeTier); case 'pancakeswap-sol': return await pancakeswapSolCreatePool( network, @@ -150,15 +107,13 @@ export const createPoolRoute: FastifyPluginAsync = async (fastify) => { baseToken, quoteToken, initialPrice, - ammConfig, + ammConfigIndex, ); default: throw httpErrors.badRequest(`Unsupported CLMM connector: ${connector}`); } } catch (e: any) { - logger.error('Failed to create CLMM pool:', e); - if (e.statusCode) throw e; - throw httpErrors.internalServerError('Failed to create pool'); + rethrowRouteError(e, 'Failed to create CLMM pool'); } }, ); diff --git a/src/trading/trading-clmm-routes/open.ts b/src/trading/trading-clmm-routes/open.ts index a2739b0524..3c84302ea3 100644 --- a/src/trading/trading-clmm-routes/open.ts +++ b/src/trading/trading-clmm-routes/open.ts @@ -1,21 +1,23 @@ import { Static, Type } from '@sinclair/typebox'; import { FastifyPluginAsync } from 'fastify'; +import { openPosition as meteoraOpenPosition } from '../../connectors/meteora/clmm-routes/openPosition'; +import { openPosition as orcaOpenPosition } from '../../connectors/orca/clmm-routes/openPosition'; +import { openPosition as pancakeswapOpenPosition } from '../../connectors/pancakeswap/clmm-routes/openPosition'; +import { openPosition as pancakeswapSolOpenPosition } from '../../connectors/pancakeswap-sol/clmm-routes/openPosition'; +import { openPosition as raydiumOpenPosition } from '../../connectors/raydium/clmm-routes/openPosition'; +import { openPosition as uniswapOpenPosition } from '../../connectors/uniswap/clmm-routes/openPosition'; +import { OpenPositionResponseType, OpenPositionResponse } from '../../schemas/clmm-schema'; import { httpErrors } from '../../services/error-handler'; -import { Ethereum } from '../../chains/ethereum/ethereum'; -import { getEthereumChainConfig } from '../../chains/ethereum/ethereum.config'; -import { Solana } from '../../chains/solana/solana'; -import { getSolanaChainConfig } from '../../chains/solana/solana.config'; - -// Get default wallet from Solana config, fallback to Ethereum if Solana doesn't exist -let defaultWallet: string; -try { - const solanaChainConfig = getSolanaChainConfig(); - defaultWallet = solanaChainConfig.defaultWallet; -} catch { - const ethereumChainConfig = getEthereumChainConfig(); - defaultWallet = ethereumChainConfig.defaultWallet; -} +import { + chainNetworkField, + CLMM_CONNECTORS, + connectorField, + defaultWallet, + parseChainNetwork, + rethrowRouteError, + slippagePctField, +} from '../common'; // Constants for examples (using Meteora CLMM values) const BASE_TOKEN_AMOUNT = 0.01; @@ -24,36 +26,10 @@ const LOWER_PRICE_BOUND = 150; const UPPER_PRICE_BOUND = 250; const CLMM_POOL_ADDRESS_EXAMPLE = '2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3'; -/** - * Parse chain-network parameter into chain and network - */ -function parseChainNetwork(chainNetwork: string): { chain: string; network: string } { - const parts = chainNetwork.split('-'); - - if (parts.length < 2) { - throw new Error( - `Invalid chain-network format: ${chainNetwork}. Expected format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)`, - ); - } - - const chain = parts[0]; - const network = parts.slice(1).join('-'); - - return { chain, network }; -} - // Unified schema with connector field const UnifiedOpenPositionRequest = Type.Object({ - connector: Type.String({ - description: 'Connector name (uniswap, pancakeswap, raydium, meteora, pancakeswap-sol, orca)', - default: 'meteora', - examples: ['meteora'], - }), - chainNetwork: Type.String({ - description: 'Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', - default: 'solana-mainnet-beta', - examples: ['solana-mainnet-beta'], - }), + connector: connectorField(CLMM_CONNECTORS, 'CLMM connector'), + chainNetwork: chainNetworkField(), walletAddress: Type.String({ description: 'Wallet address', default: defaultWallet, @@ -82,15 +58,7 @@ const UnifiedOpenPositionRequest = Type.Object({ examples: [QUOTE_TOKEN_AMOUNT], }), ), - slippagePct: Type.Optional( - Type.Number({ - minimum: 0, - maximum: 100, - description: 'Maximum acceptable slippage percentage', - default: 1, - examples: [1], - }), - ), + slippagePct: slippagePctField(), // Meteora-specific parameter (optional, ignored by other connectors) strategyType: Type.Optional( Type.Number({ @@ -100,16 +68,6 @@ const UnifiedOpenPositionRequest = Type.Object({ ), }); -// Import connector functions -import { openPosition as meteoraOpenPosition } from '../../connectors/meteora/clmm-routes/openPosition'; -import { openPosition as orcaOpenPosition } from '../../connectors/orca/clmm-routes/openPosition'; -import { openPosition as pancakeswapOpenPosition } from '../../connectors/pancakeswap/clmm-routes/openPosition'; -import { openPosition as pancakeswapSolOpenPosition } from '../../connectors/pancakeswap-sol/clmm-routes/openPosition'; -import { openPosition as raydiumOpenPosition } from '../../connectors/raydium/clmm-routes/openPosition'; -import { openPosition as uniswapOpenPosition } from '../../connectors/uniswap/clmm-routes/openPosition'; -import { OpenPositionResponseType, OpenPositionResponse } from '../../schemas/clmm-schema'; -import { logger } from '../../services/logger'; - export const openPositionRoute: FastifyPluginAsync = async (fastify) => { fastify.post<{ Body: Static; @@ -144,6 +102,14 @@ export const openPositionRoute: FastifyPluginAsync = async (fastify) => { // Parse chain and network from chainNetwork parameter const { network } = parseChainNetwork(chainNetwork); + // Same contract as add.ts: single-sided opens are valid, but at least one + // side must be positive — reject here rather than deep in connector code. + const baseAmount = baseTokenAmount ?? 0; + const quoteAmount = quoteTokenAmount ?? 0; + if (baseAmount <= 0 && quoteAmount <= 0) { + throw httpErrors.badRequest('At least one of baseTokenAmount or quoteTokenAmount must be greater than 0'); + } + // Route to appropriate connector switch (connector) { case 'uniswap': @@ -223,11 +189,7 @@ export const openPositionRoute: FastifyPluginAsync = async (fastify) => { throw httpErrors.badRequest(`Unsupported connector: ${connector}`); } } catch (e: any) { - logger.error('Failed to open position:', e); - if (e.statusCode) { - throw e; - } - throw httpErrors.internalServerError('Failed to open position'); + rethrowRouteError(e, 'Failed to open position'); } }, ); diff --git a/src/trading/trading-clmm-routes/remove.ts b/src/trading/trading-clmm-routes/remove.ts index f76b555603..2f2520cef7 100644 --- a/src/trading/trading-clmm-routes/remove.ts +++ b/src/trading/trading-clmm-routes/remove.ts @@ -1,8 +1,6 @@ import { Static, Type } from '@sinclair/typebox'; import { FastifyPluginAsync } from 'fastify'; -import { getEthereumChainConfig } from '../../chains/ethereum/ethereum.config'; -import { getSolanaChainConfig } from '../../chains/solana/solana.config'; import { removeLiquidity as meteoraRemoveLiquidity } from '../../connectors/meteora/clmm-routes/removeLiquidity'; import { removeLiquidity as orcaRemoveLiquidity } from '../../connectors/orca/clmm-routes/removeLiquidity'; import { removeLiquidity as pancakeswapRemoveLiquidity } from '../../connectors/pancakeswap/clmm-routes/removeLiquidity'; @@ -11,48 +9,20 @@ import { removeLiquidity as raydiumRemoveLiquidity } from '../../connectors/rayd import { removeLiquidity as uniswapRemoveLiquidity } from '../../connectors/uniswap/clmm-routes/removeLiquidity'; import { RemoveLiquidityResponseType, RemoveLiquidityResponse } from '../../schemas/clmm-schema'; import { httpErrors } from '../../services/error-handler'; -import { logger } from '../../services/logger'; - -// Get default wallet from Solana config, fallback to Ethereum if Solana doesn't exist -let defaultWallet: string; -try { - const solanaChainConfig = getSolanaChainConfig(); - defaultWallet = solanaChainConfig.defaultWallet; -} catch { - const ethereumChainConfig = getEthereumChainConfig(); - defaultWallet = ethereumChainConfig.defaultWallet; -} - -/** - * Parse chain-network parameter into chain and network - */ -function parseChainNetwork(chainNetwork: string): { chain: string; network: string } { - const parts = chainNetwork.split('-'); - - if (parts.length < 2) { - throw new Error( - `Invalid chain-network format: ${chainNetwork}. Expected format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)`, - ); - } - - const chain = parts[0]; - const network = parts.slice(1).join('-'); - - return { chain, network }; -} +import { + chainNetworkField, + CLMM_CONNECTORS, + connectorField, + defaultWallet, + parseChainNetwork, + rethrowRouteError, + slippagePctField, +} from '../common'; // Unified schema with connector field const UnifiedRemoveLiquidityRequest = Type.Object({ - connector: Type.String({ - description: 'Connector name (uniswap, pancakeswap, raydium, meteora, pancakeswap-sol, orca)', - default: 'meteora', - examples: ['meteora'], - }), - chainNetwork: Type.String({ - description: 'Chain and network in format: chain-network (e.g., solana-mainnet-beta, ethereum-mainnet)', - default: 'solana-mainnet-beta', - examples: ['solana-mainnet-beta'], - }), + connector: connectorField(CLMM_CONNECTORS, 'CLMM connector'), + chainNetwork: chainNetworkField(), walletAddress: Type.String({ description: 'Wallet address', default: defaultWallet, @@ -68,6 +38,11 @@ const UnifiedRemoveLiquidityRequest = Type.Object({ default: 100, examples: [100], }), + // Orca-specific parameter (optional, ignored by other connectors, which manage + // slippage internally). + slippagePct: slippagePctField( + "Maximum acceptable slippage percentage. Only applies to the Orca connector; defaults to Orca's configured slippagePct.", + ), }); // Import connector functions @@ -90,7 +65,8 @@ export const removeLiquidityRoute: FastifyPluginAsync = async (fastify) => { }, async (request) => { try { - const { connector, chainNetwork, walletAddress, positionAddress, percentageToRemove } = request.body; + const { connector, chainNetwork, walletAddress, positionAddress, percentageToRemove, slippagePct } = + request.body; // Parse chain and network from chainNetwork parameter const { network } = parseChainNetwork(chainNetwork); @@ -113,17 +89,13 @@ export const removeLiquidityRoute: FastifyPluginAsync = async (fastify) => { return await pancakeswapSolRemoveLiquidity(network, walletAddress, positionAddress, percentageToRemove); case 'orca': - return await orcaRemoveLiquidity(network, walletAddress, positionAddress, percentageToRemove, 1); + return await orcaRemoveLiquidity(network, walletAddress, positionAddress, percentageToRemove, slippagePct); default: throw httpErrors.badRequest(`Unsupported connector: ${connector}`); } } catch (e: any) { - logger.error('Failed to remove liquidity:', e); - if (e.statusCode) { - throw e; - } - throw httpErrors.internalServerError('Failed to remove liquidity'); + rethrowRouteError(e, 'Failed to remove liquidity'); } }, ); diff --git a/src/trading/trading.routes.ts b/src/trading/trading.routes.ts index b892c6a5f3..fc0ba5a6c4 100644 --- a/src/trading/trading.routes.ts +++ b/src/trading/trading.routes.ts @@ -12,8 +12,6 @@ import { poolInfoRoute as ammPoolInfoRoute, positionInfoRoute as ammPositionInfoRoute, positionsOwnedRoute as ammPositionsOwnedRoute, - quoteSwapRoute as ammQuoteSwapRoute, - executeSwapRoute as ammExecuteSwapRoute, quoteLiquidityRoute as ammQuoteLiquidityRoute, addLiquidityRoute as ammAddLiquidityRoute, removeLiquidityRoute as ammRemoveLiquidityRoute, @@ -60,17 +58,10 @@ export const tradingAmmRoutes: FastifyPluginAsync = async (fastify) => { fastify.register(ammPoolInfoRoute); fastify.register(ammPositionInfoRoute); fastify.register(ammPositionsOwnedRoute); - fastify.register(ammQuoteSwapRoute); fastify.register(ammQuoteLiquidityRoute); // Register AMM transaction routes (unified cross-connector) - fastify.register(ammExecuteSwapRoute); fastify.register(ammAddLiquidityRoute); fastify.register(ammRemoveLiquidityRoute); fastify.register(createPoolRoute); }; - -// Legacy export for backward compatibility -export const tradingRoutes = tradingSwapRoutes; - -export default tradingRoutes; diff --git a/test/chains/ethereum/routes/poll.test.ts b/test/chains/ethereum/routes/poll.test.ts new file mode 100644 index 0000000000..fb0b89b32a --- /dev/null +++ b/test/chains/ethereum/routes/poll.test.ts @@ -0,0 +1,83 @@ +// Import shared mocks before importing app +import '../../../mocks/app-mocks'; + +import { Ethereum } from '../../../../src/chains/ethereum/ethereum'; +import { pollEthereumTransaction } from '../../../../src/chains/ethereum/routes/poll'; +import { TransactionStatusCode } from '../../../../src/schemas/chain-schema'; + +// Mock the Ethereum class +jest.mock('../../../../src/chains/ethereum/ethereum', () => ({ + Ethereum: { + getInstance: jest.fn(), + }, +})); + +const mockEthereum = Ethereum as jest.Mocked; + +const TX_HASH = '0x' + 'ab'.repeat(32); + +describe('pollEthereumTransaction', () => { + const mockEthereumInstance = { + getCurrentBlockNumber: jest.fn(), + getTransaction: jest.fn(), + getTransactionReceipt: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockEthereum.getInstance.mockResolvedValue(mockEthereumInstance as any); + mockEthereumInstance.getCurrentBlockNumber.mockResolvedValue(21000000); + }); + + it('returns NOT_FOUND when the node does not know the transaction', async () => { + mockEthereumInstance.getTransaction.mockResolvedValue(null); + + const result = await pollEthereumTransaction(null as any, 'mainnet', TX_HASH); + + expect(result.txStatus).toBe(TransactionStatusCode.NOT_FOUND); + expect(mockEthereumInstance.getTransactionReceipt).not.toHaveBeenCalled(); + }); + + it('returns PENDING while the transaction sits in the mempool', async () => { + mockEthereumInstance.getTransaction.mockResolvedValue({ + hash: TX_HASH, + gasLimit: { toString: () => '21000' }, + value: { toString: () => '0' }, + }); + mockEthereumInstance.getTransactionReceipt.mockResolvedValue(null); + + const result = await pollEthereumTransaction(null as any, 'mainnet', TX_HASH); + + expect(result.txStatus).toBe(TransactionStatusCode.PENDING); + expect(result.txBlock).toBe(-1); + }); + + it('returns CONFIRMED for a mined transaction with receipt status 1', async () => { + mockEthereumInstance.getTransaction.mockResolvedValue({ + hash: TX_HASH, + gasLimit: { toString: () => '21000' }, + value: { toString: () => '0' }, + }); + mockEthereumInstance.getTransactionReceipt.mockResolvedValue({ status: 1, blockNumber: 20999999, logs: [] }); + + const result = await pollEthereumTransaction(null as any, 'mainnet', TX_HASH); + + expect(result.txStatus).toBe(TransactionStatusCode.CONFIRMED); + expect(result.txBlock).toBe(20999999); + }); + + it('returns FAILED for a reverted transaction (receipt status 0)', async () => { + // Regression: receipt.status 0 is a number, so the old + // `typeof status === 'number' ? 1 : -1` mapping reported reverts as confirmed. + mockEthereumInstance.getTransaction.mockResolvedValue({ + hash: TX_HASH, + gasLimit: { toString: () => '21000' }, + value: { toString: () => '0' }, + }); + mockEthereumInstance.getTransactionReceipt.mockResolvedValue({ status: 0, blockNumber: 20999999, logs: [] }); + + const result = await pollEthereumTransaction(null as any, 'mainnet', TX_HASH); + + expect(result.txStatus).toBe(TransactionStatusCode.FAILED); + }); +}); diff --git a/test/chains/ethereum/transaction-confirmation.test.ts b/test/chains/ethereum/transaction-confirmation.test.ts new file mode 100644 index 0000000000..ba5df56a67 --- /dev/null +++ b/test/chains/ethereum/transaction-confirmation.test.ts @@ -0,0 +1,83 @@ +import { BigNumber } from 'ethers'; + +import { Ethereum } from '../../../src/chains/ethereum/ethereum'; + +// Unit tests for Ethereum.prototype.handleTransactionConfirmation — the single +// confirmation gate every EVM route that sends a transaction goes through. +// +// The contract it has to hold: +// - no receipt (still pending) -> { confirmed: false } carrying the tx hash, never a throw, +// so a caller can reconcile a transaction that lands after the poll window closes. +// - reverted receipt (status 0) -> throws 400 TRANSACTION_FAILED. `receipt.status === 0` +// must never reach a response body, where 0 reads as TransactionStatus.PENDING. +// - confirmed receipt (status 1) -> { confirmed: true } with the receipt and the gas fee +// already converted to native units. +// +// The class constructor is private and pulls in network state, so the method is invoked +// through Ethereum.prototype..call() against a stub carrying only what it reads. + +type Stub = { handleTransactionExecution: jest.Mock }; + +const callHelper = (stub: Stub, tx: any) => (Ethereum.prototype as any).handleTransactionConfirmation.call(stub, tx); + +describe('Ethereum.handleTransactionConfirmation', () => { + it('reports a still-pending transaction as pending and preserves the tx hash', async () => { + const stub: Stub = { handleTransactionExecution: jest.fn().mockResolvedValue(null) }; + + const outcome = await callHelper(stub, { hash: '0xpendinghash' }); + + expect(outcome).toEqual({ confirmed: false, signature: '0xpendinghash' }); + }); + + it('throws 400 TRANSACTION_FAILED for a reverted transaction instead of reporting PENDING', async () => { + const stub: Stub = { + handleTransactionExecution: jest.fn().mockResolvedValue({ + status: 0, + transactionHash: '0xrevertedhash', + gasUsed: BigNumber.from(21_000), + effectiveGasPrice: BigNumber.from('1000000000'), + }), + }; + + const error: any = await callHelper(stub, { hash: '0xrevertedhash' }).catch((e: any) => e); + + expect(error.statusCode).toBe(400); + expect(error.code).toBe('TRANSACTION_FAILED'); + expect(error.message).toContain('0xrevertedhash'); + expect(error.message).toMatch(/reverted on-chain/); + }); + + it('returns the receipt and the gas fee in native units for a confirmed transaction', async () => { + const receipt = { + status: 1, + transactionHash: '0xconfirmedhash', + logs: [], + // 21,000 gas at 1 gwei = 0.000021 ETH + gasUsed: BigNumber.from(21_000), + effectiveGasPrice: BigNumber.from('1000000000'), + }; + const stub: Stub = { handleTransactionExecution: jest.fn().mockResolvedValue(receipt) }; + + const outcome = await callHelper(stub, { hash: '0xconfirmedhash' }); + + expect(outcome).toEqual({ + confirmed: true, + signature: '0xconfirmedhash', + receipt, + fee: 0.000021, + }); + }); + + it('treats a receipt with no status as pending rather than guessing', async () => { + const stub: Stub = { + handleTransactionExecution: jest.fn().mockResolvedValue({ + status: undefined, + transactionHash: '0xnostatushash', + }), + }; + + const outcome = await callHelper(stub, { hash: '0xnostatushash' }); + + expect(outcome).toEqual({ confirmed: false, signature: '0xnostatushash' }); + }); +}); diff --git a/test/chains/solana/confirmation-helpers.test.ts b/test/chains/solana/confirmation-helpers.test.ts new file mode 100644 index 0000000000..a868dd67e5 --- /dev/null +++ b/test/chains/solana/confirmation-helpers.test.ts @@ -0,0 +1,134 @@ +/** + * Unit tests for the shared route-level confirmation helpers: + * Solana.getConfirmedTransactionData and Solana.handleConfirmation. + * + * Exercised via Function.prototype.call against a hand-built `this` (like the + * sendAndConfirmTransactionForWallet tests) so the branching can be asserted without + * standing up the Solana singleton / RPC. + * + * These pin the G4 contract: existence of txData is never equated with confirmation — + * a landed-but-failed transaction throws TRANSACTION_FAILED, and a just-confirmed + * transaction whose data lags RPC visibility is found via the retrying fetch instead of + * being misreported as PENDING. + */ + +import { Solana } from '../../../src/chains/solana/solana'; + +const buildLandedWithErrorException = (Solana.prototype as any).buildLandedWithErrorException; + +const getConfirmedTransactionData = (Solana.prototype as any).getConfirmedTransactionData as ( + this: unknown, + signature: string, +) => Promise; + +const handleConfirmation = (Solana.prototype as any).handleConfirmation as ( + this: unknown, + signature: string, + txData: any, + tokenIn: string, + tokenOut: string, + walletAddress: string, + side?: 'BUY' | 'SELL', + slippagePct?: number, +) => Promise<{ signature: string; status: number; data?: any }>; + +const WALLET = 'AabEVCB1sWgCPxbn6hFYM4Ukj7UubpBRbbYqRnqRXnZD'; +const TOKEN_IN = 'So11111111111111111111111111111111111111112'; +const TOKEN_OUT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'; + +const failedTxData = { + meta: { err: { InstructionError: [1, { Custom: 6001 }] }, logMessages: [] }, +}; + +describe('Solana.getConfirmedTransactionData', () => { + it('uses the retrying fetch and returns the transaction data when it succeeded', async () => { + const fetchWithRetry = jest.fn(async () => ({ meta: { err: null, fee: 5000 } })); + const fakeThis = { _fetchTransactionWithRetry: fetchWithRetry, buildLandedWithErrorException }; + + const txData = await getConfirmedTransactionData.call(fakeThis, 'sig'); + expect(txData).toEqual({ meta: { err: null, fee: 5000 } }); + expect(fetchWithRetry).toHaveBeenCalledWith('sig'); + }); + + it('throws TRANSACTION_FAILED when the transaction landed on-chain with an error', async () => { + const fakeThis = { + _fetchTransactionWithRetry: jest.fn(async () => failedTxData), + buildLandedWithErrorException, + }; + + const error = await getConfirmedTransactionData.call(fakeThis, 'landed-sig').then( + () => { + throw new Error('expected getConfirmedTransactionData to throw'); + }, + (e: any) => e, + ); + expect(error.message).toMatch(/Transaction landed-sig landed on-chain but failed/); + expect(error.code).toBe('TRANSACTION_FAILED'); + expect(error.statusCode).toBe(400); + }); + + it('returns null when the transaction is genuinely not visible yet', async () => { + const fakeThis = { _fetchTransactionWithRetry: jest.fn(async () => null), buildLandedWithErrorException }; + await expect(getConfirmedTransactionData.call(fakeThis, 'gone-sig')).resolves.toBeNull(); + }); +}); + +describe('Solana.handleConfirmation', () => { + const confirmedThis = () => ({ + buildLandedWithErrorException, + _fetchTransactionWithRetry: jest.fn(async () => null), + extractBalanceChangesAndFee: jest.fn(async () => ({ balanceChanges: [-0.1, 14.85], fee: 0.000005 })), + }); + + it('reports CONFIRMED with the applied slippagePct echoed in data', async () => { + const fakeThis = confirmedThis(); + const result = await handleConfirmation.call( + fakeThis, + 'sig', + { meta: { err: null } }, + TOKEN_IN, + TOKEN_OUT, + WALLET, + 'SELL', + 0.75, + ); + + expect(result.status).toBe(1); + expect(result.data).toMatchObject({ + tokenIn: TOKEN_IN, + tokenOut: TOKEN_OUT, + amountIn: 0.1, + amountOut: 14.85, + slippagePct: 0.75, + }); + // Data was supplied, so no re-fetch happened. + expect(fakeThis._fetchTransactionWithRetry).not.toHaveBeenCalled(); + }); + + it('throws TRANSACTION_FAILED for a landed-but-failed transaction instead of misreporting it', async () => { + const fakeThis = confirmedThis(); + const error = await handleConfirmation.call(fakeThis, 'landed-sig', failedTxData, TOKEN_IN, TOKEN_OUT, WALLET).then( + () => { + throw new Error('expected handleConfirmation to throw'); + }, + (e: any) => e, + ); + expect(error.code).toBe('TRANSACTION_FAILED'); + expect(fakeThis.extractBalanceChangesAndFee).not.toHaveBeenCalled(); + }); + + it('re-fetches with retry when the caller has no txData, so a lagging RPC does not yield a false PENDING', async () => { + const fakeThis = confirmedThis(); + fakeThis._fetchTransactionWithRetry = jest.fn(async () => ({ meta: { err: null } })); + + const result = await handleConfirmation.call(fakeThis, 'sig', null, TOKEN_IN, TOKEN_OUT, WALLET); + expect(fakeThis._fetchTransactionWithRetry).toHaveBeenCalledWith('sig'); + expect(result.status).toBe(1); + }); + + it('returns the pending shape only when the transaction is genuinely not found', async () => { + const fakeThis = confirmedThis(); + const result = await handleConfirmation.call(fakeThis, 'pending-sig', null, TOKEN_IN, TOKEN_OUT, WALLET); + expect(result).toEqual({ signature: 'pending-sig', status: 0, data: undefined }); + }); +}); diff --git a/test/chains/solana/routes/poll.test.ts b/test/chains/solana/routes/poll.test.ts new file mode 100644 index 0000000000..f5bd3b422e --- /dev/null +++ b/test/chains/solana/routes/poll.test.ts @@ -0,0 +1,157 @@ +// Import shared mocks before importing app +import '../../../mocks/app-mocks'; + +import { pollSolanaTransaction } from '../../../../src/chains/solana/routes/poll'; +import { Solana } from '../../../../src/chains/solana/solana'; +import { TransactionStatusCode } from '../../../../src/schemas/chain-schema'; + +// Mock the Solana class +jest.mock('../../../../src/chains/solana/solana', () => ({ + ...jest.requireActual('../../../../src/chains/solana/solana'), + Solana: { + getInstance: jest.fn(), + }, +})); + +const mockSolana = Solana as jest.Mocked; + +// 88-char base58-ish signature that passes the route's format validation +const VALID_SIGNATURE = '5'.repeat(88); + +describe('pollSolanaTransaction', () => { + const mockSolanaInstance = { + getCurrentBlockNumber: jest.fn(), + getTransaction: jest.fn(), + getTransactionStatusCode: jest.fn(), + getSignatureStatus: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockSolana.getInstance.mockResolvedValue(mockSolanaInstance as any); + mockSolanaInstance.getCurrentBlockNumber.mockResolvedValue(365795000); + }); + + it('returns CONFIRMED with fee when the transaction landed without error', async () => { + mockSolanaInstance.getTransaction.mockResolvedValue({ + slot: 365794000, + meta: { fee: 5000, err: null }, + }); + mockSolanaInstance.getTransactionStatusCode.mockResolvedValue(TransactionStatusCode.CONFIRMED); + + const result = await pollSolanaTransaction(null as any, 'mainnet-beta', VALID_SIGNATURE); + + expect(result.txStatus).toBe(TransactionStatusCode.CONFIRMED); + expect(result.txBlock).toBe(365794000); + expect(result.fee).toBe(5000 / 1e9); + expect(result.error).toBeNull(); + }); + + it('attributes a failed transaction to the program named in the logs', async () => { + // meta.err carries the code but no program; without the logs the parser cannot + // reach the program's error table and every custom code reports UNKNOWN. + mockSolanaInstance.getTransaction.mockResolvedValue({ + slot: 365794000, + meta: { + fee: 5000, + err: { InstructionError: [0, { Custom: 6018 }] }, + logMessages: [ + 'Program ComputeBudget111111111111111111111111111111 invoke [1]', + 'Program ComputeBudget111111111111111111111111111111 success', + 'Program whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc invoke [1]', + 'Program whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc failed: custom program error: 0x1782', + ], + }, + }); + mockSolanaInstance.getTransactionStatusCode.mockResolvedValue(TransactionStatusCode.FAILED); + + const result = await pollSolanaTransaction(null as any, 'mainnet-beta', VALID_SIGNATURE); + + expect(result.txStatus).toBe(TransactionStatusCode.FAILED); + expect(result.error).toContain('SLIPPAGE_EXCEEDED'); + expect(result.error).not.toContain('UNKNOWN'); + }); + + describe('null txData (not visible at confirmed commitment)', () => { + beforeEach(() => { + mockSolanaInstance.getTransaction.mockResolvedValue(null); + }); + + it('returns UNCONFIRMED when the cluster has seen the signature', async () => { + mockSolanaInstance.getSignatureStatus.mockResolvedValue(TransactionStatusCode.PENDING); + + const result = await pollSolanaTransaction(null as any, 'mainnet-beta', VALID_SIGNATURE); + + expect(mockSolanaInstance.getSignatureStatus).toHaveBeenCalledWith(VALID_SIGNATURE); + expect(result.txStatus).toBe(TransactionStatusCode.PENDING); + expect(result.error).toBeNull(); + }); + + it('returns NOT_FOUND when the signature is unknown to the cluster', async () => { + mockSolanaInstance.getSignatureStatus.mockResolvedValue(TransactionStatusCode.NOT_FOUND); + + const result = await pollSolanaTransaction(null as any, 'mainnet-beta', VALID_SIGNATURE); + + expect(result.txStatus).toBe(TransactionStatusCode.NOT_FOUND); + expect(result.error).toBeNull(); + }); + + it('returns FAILED when the signature status carries an error', async () => { + mockSolanaInstance.getSignatureStatus.mockResolvedValue(TransactionStatusCode.FAILED); + + const result = await pollSolanaTransaction(null as any, 'mainnet-beta', VALID_SIGNATURE); + + expect(result.txStatus).toBe(TransactionStatusCode.FAILED); + }); + }); + + it('returns NOT_FOUND for a malformed signature, which can never resolve', async () => { + const result = await pollSolanaTransaction(null as any, 'mainnet-beta', 'not-a-signature'); + + expect(result.txStatus).toBe(TransactionStatusCode.NOT_FOUND); + expect(result.error).toContain('INVALID_INPUT'); + expect(mockSolanaInstance.getTransaction).not.toHaveBeenCalled(); + }); + + it('returns UNCONFIRMED (not NOT_FOUND) on a transient RPC error so callers keep polling', async () => { + mockSolanaInstance.getTransaction.mockRejectedValue(new Error('RPC unavailable')); + + const result = await pollSolanaTransaction(null as any, 'mainnet-beta', VALID_SIGNATURE); + + expect(result.txStatus).toBe(TransactionStatusCode.PENDING); + expect(result.error).toContain('RPC unavailable'); + }); +}); + +describe('Solana.getSignatureStatus', () => { + const { Solana: RealSolana } = jest.requireActual('../../../../src/chains/solana/solana'); + + const makeInstance = (statusValue: any) => { + const instance = Object.create(RealSolana.prototype); + instance.connection = { + getSignatureStatuses: jest.fn().mockResolvedValue({ value: [statusValue] }), + }; + return instance; + }; + + it('maps a null status to NOT_FOUND', async () => { + const instance = makeInstance(null); + await expect(instance.getSignatureStatus(VALID_SIGNATURE)).resolves.toBe(TransactionStatusCode.NOT_FOUND); + expect(instance.connection.getSignatureStatuses).toHaveBeenCalledWith([VALID_SIGNATURE], { + searchTransactionHistory: true, + }); + }); + + it('maps a status with err to FAILED', async () => { + const instance = makeInstance({ + err: { InstructionError: [2, { Custom: 6018 }] }, + confirmationStatus: 'confirmed', + }); + await expect(instance.getSignatureStatus(VALID_SIGNATURE)).resolves.toBe(TransactionStatusCode.FAILED); + }); + + it('maps a processed (seen but unconfirmed) status to UNCONFIRMED', async () => { + const instance = makeInstance({ err: null, confirmationStatus: 'processed' }); + await expect(instance.getSignatureStatus(VALID_SIGNATURE)).resolves.toBe(TransactionStatusCode.PENDING); + }); +}); diff --git a/test/chains/solana/sendAndConfirmTransactionForWallet.test.ts b/test/chains/solana/sendAndConfirmTransactionForWallet.test.ts index c8a37ead5a..e4dec19616 100644 --- a/test/chains/solana/sendAndConfirmTransactionForWallet.test.ts +++ b/test/chains/solana/sendAndConfirmTransactionForWallet.test.ts @@ -45,6 +45,39 @@ function versionedTx(): VersionedTransaction { return new VersionedTransaction(message); } +describe('Solana.sendAndConfirmTransaction compute simulation', () => { + const sendAndConfirm = (Solana.prototype as any).sendAndConfirmTransaction as ( + this: unknown, + tx: Transaction, + ) => Promise<{ signature: string; fee: number }>; + + it('does not broadcast when the compute-estimation simulation returned an error', async () => { + const _sendAndConfirmRawTransaction = jest.fn(); + const fakeThis = { + config: { defaultComputeUnits: 200000 }, + estimateGasPrice: jest.fn(async () => 0.1), + connection: { + simulateTransaction: jest.fn(async () => ({ + value: { + err: { InstructionError: [0, { Custom: 6018 }] }, + unitsConsumed: 10385, + logs: [ + 'Program whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc invoke [1]', + 'Program whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc failed: custom program error: 0x1782', + ], + }, + })), + }, + _sendAndConfirmRawTransaction, + }; + + await expect(sendAndConfirm.call(fakeThis, legacyTx())).rejects.toMatchObject({ + code: 'SLIPPAGE_EXCEEDED', + }); + expect(_sendAndConfirmRawTransaction).not.toHaveBeenCalled(); + }); +}); + describe('Solana.sendAndConfirmTransactionForWallet', () => { it('drops extra signers that carry the wallet pubkey (SDK dummy owner signers)', async () => { // Raydium's TxBuilder appends `owner.signer` to the signers it returns. For non-local @@ -179,6 +212,7 @@ describe('Solana.throwIfLandedWithError / confirmationTimeoutError', () => { const throwIfLandedWithError = (Solana.prototype as any).throwIfLandedWithError as ( this: unknown, signature: string, + txData?: any, ) => Promise; const confirmationTimeoutError = (Solana.prototype as any).confirmationTimeoutError as ( this: unknown, @@ -187,6 +221,7 @@ describe('Solana.throwIfLandedWithError / confirmationTimeoutError', () => { it('surfaces the on-chain program error when a tx lands but fails (not a timeout)', async () => { const fakeThis = { + buildLandedWithErrorException: (Solana.prototype as any).buildLandedWithErrorException, connection: { getTransaction: jest.fn(async () => ({ meta: { @@ -200,7 +235,35 @@ describe('Solana.throwIfLandedWithError / confirmationTimeoutError', () => { }, }; - await expect(throwIfLandedWithError.call(fakeThis, 'landed-sig')).rejects.toThrow(/landed on-chain but failed/); + const error = await throwIfLandedWithError.call(fakeThis, 'landed-sig').then( + () => { + throw new Error('expected throwIfLandedWithError to throw'); + }, + (e: any) => e, + ); + // A landed-but-failed tx paid fees on-chain — it is TRANSACTION_FAILED (4xx, + // non-retryable), not a simulation failure, and the message keeps the signature. + expect(error.message).toMatch(/Transaction landed-sig landed on-chain but failed/); + expect(error.code).toBe('TRANSACTION_FAILED'); + expect(error.statusCode).toBe(400); + }); + + it('uses caller-provided txData without a re-fetch and throws the shared landed-but-failed error', async () => { + const getTransaction = jest.fn(); + const fakeThis = { + buildLandedWithErrorException: (Solana.prototype as any).buildLandedWithErrorException, + connection: { getTransaction }, + }; + + const failedTxData = { meta: { err: { InstructionError: [0, 'Custom'] }, logMessages: [] } }; + const error = await throwIfLandedWithError.call(fakeThis as any, 'route-sig', failedTxData).then( + () => { + throw new Error('expected throwIfLandedWithError to throw'); + }, + (e: any) => e, + ); + expect(error.code).toBe('TRANSACTION_FAILED'); + expect(getTransaction).not.toHaveBeenCalled(); }); it('returns silently when the transaction succeeded or is missing', async () => { diff --git a/test/chains/solana/solana-error-parser.test.ts b/test/chains/solana/solana-error-parser.test.ts index 7e4ae624d8..cdd2c765ea 100644 --- a/test/chains/solana/solana-error-parser.test.ts +++ b/test/chains/solana/solana-error-parser.test.ts @@ -110,6 +110,37 @@ describe('Solana Error Parser', () => { }); describe('Orca Whirlpool errors', () => { + it('should parse Orca token minimum error 6018 as slippage, not math overflow', () => { + const errorMessage = `Program whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc failed: custom program error: 0x1782`; + const result = parseSolanaError(errorMessage); + + expect(result.type).toBe('SLIPPAGE_EXCEEDED'); + expect(result.program).toBe('Orca Whirlpool'); + expect(result.errorCode).toBe(6018); + expect(result.message).toContain('minimum token amount'); + }); + + it('attributes a custom error to the FAILING program, not the first invoked one', () => { + // Simulation-shaped message: full logs open with prelude programs + // (ComputeBudget), and the Whirlpool failure comes later. The parser must + // consult Orca's table (6018 = TokenMinSubceeded → slippage), not fall + // through to the generic map's MATH_OVERFLOW. + const errorMessage = [ + 'Transaction simulation failed: ', + 'Error: {"InstructionError":[3,{"Custom":6018}]}', + 'Program Logs: Program ComputeBudget111111111111111111111111111111 invoke [1]', + 'Program ComputeBudget111111111111111111111111111111 success', + 'Program whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc invoke [1]', + 'Program log: Error: TokenMinSubceeded', + 'Program whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc failed: custom program error: 0x1782', + ].join('\n'); + const result = parseSolanaError(errorMessage); + + expect(result.type).toBe('SLIPPAGE_EXCEEDED'); + expect(result.program).toBe('Orca Whirlpool'); + expect(result.errorCode).toBe(6018); + }); + it('should parse Orca slippage error via program ID string match', () => { const errorMessage = `Program whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc failed: custom program error: 0x178d`; const result = parseSolanaError(errorMessage); diff --git a/test/connectors/dflow/router-routes/executeQuote.test.ts b/test/connectors/dflow/router-routes/executeQuote.test.ts index b9e41f94a1..459563810a 100644 --- a/test/connectors/dflow/router-routes/executeQuote.test.ts +++ b/test/connectors/dflow/router-routes/executeQuote.test.ts @@ -53,6 +53,7 @@ describe('POST /execute-quote (dflow)', () => { const mockSolanaInstance = { sendAndConfirmTransactionForWallet, connection: { getTransaction: jest.fn(async () => ({ meta: {} })) }, + getConfirmedTransactionData: jest.fn(async () => ({ meta: {} })), handleConfirmation: jest.fn(async () => confirmedResult), }; (Solana.getInstance as jest.Mock).mockResolvedValue(mockSolanaInstance); diff --git a/test/connectors/jupiter/schemas.test.ts b/test/connectors/jupiter/schemas.test.ts index 396d9d24e4..b70b060fe4 100644 --- a/test/connectors/jupiter/schemas.test.ts +++ b/test/connectors/jupiter/schemas.test.ts @@ -121,24 +121,27 @@ describe('Jupiter Schema Tests', () => { }); describe('Jupiter-specific Fields', () => { - it('JupiterQuoteSwapRequest should include Jupiter-specific fields', () => { + // Routing policy (restrictIntermediateTokens/onlyDirectRoutes) and priority fees + // (priorityLevel/maxLamports) are connector-config settings, not request params. + // The one request-level knob shared by every Solana router is approximateIfNoExactOut. + it('JupiterQuoteSwapRequest exposes only the standard router params', () => { const props = Object.keys(Jupiter.JupiterQuoteSwapRequest.properties); - expect(props).toContain('restrictIntermediateTokens'); - expect(props).toContain('onlyDirectRoutes'); + expect(props).toContain('approximateIfNoExactOut'); + expect(props).not.toContain('restrictIntermediateTokens'); + expect(props).not.toContain('onlyDirectRoutes'); }); - it('JupiterExecuteQuoteRequest should include Jupiter-specific fields', () => { + it('JupiterExecuteQuoteRequest exposes no priority-fee params', () => { const props = Object.keys(Jupiter.JupiterExecuteQuoteRequest.properties); - expect(props).toContain('priorityLevel'); - expect(props).toContain('maxLamports'); + expect(props).not.toContain('priorityLevel'); + expect(props).not.toContain('maxLamports'); }); - it('JupiterExecuteSwapRequest should include Jupiter-specific fields', () => { + it('JupiterExecuteSwapRequest exposes only the standard router params', () => { const props = Object.keys(Jupiter.JupiterExecuteSwapRequest.properties); - expect(props).toContain('restrictIntermediateTokens'); - expect(props).toContain('onlyDirectRoutes'); - expect(props).toContain('priorityLevel'); - expect(props).toContain('maxLamports'); + expect(props).toContain('approximateIfNoExactOut'); + expect(props).not.toContain('restrictIntermediateTokens'); + expect(props).not.toContain('priorityLevel'); }); it('JupiterQuoteSwapResponse should include Jupiter-specific fields', () => { diff --git a/test/connectors/jupiter/swap.test.js b/test/connectors/jupiter/swap.test.js index e6fe869abe..602fbb852f 100644 --- a/test/connectors/jupiter/swap.test.js +++ b/test/connectors/jupiter/swap.test.js @@ -320,7 +320,7 @@ describe('Jupiter Swap Tests (Solana Mainnet)', () => { ); }); - test('returns successful swap execution with fee parameters', async () => { + test('returns successful swap execution with slippage and BUY-approximation parameters', async () => { // Mock response with status-based format const executeResponse = { signature: '3YHqPTNGFvRjLb6HkBQq8qwsRZ8XNjEjvuehVeNDdz3TxxKnvYBfgMsYCQKNHMpDYzKcUfKdCwzBvkPvDz5aLfYd', @@ -330,7 +330,7 @@ describe('Jupiter Swap Tests (Solana Mainnet)', () => { tokenOut: QUOTE_TOKEN, amountIn: 1.0, amountOut: 16.391234, - fee: 0.002, // Higher fee due to priority + fee: 0.000005, baseTokenBalanceChange: -1.0, quoteTokenBalanceChange: 16.391234, }, @@ -342,7 +342,8 @@ describe('Jupiter Swap Tests (Solana Mainnet)', () => { data: executeResponse, }); - // Make the request with fee parameters + // The request surface is slippagePct + approximateIfNoExactOut only — + // priority fees and routing policy live in connector/network config. const response = await axios.post(`http://localhost:15888/connectors/${CONNECTOR}/execute-swap`, { network: NETWORK, baseToken: BASE_TOKEN, @@ -350,24 +351,29 @@ describe('Jupiter Swap Tests (Solana Mainnet)', () => { side: 'SELL', amount: 1.0, walletAddress: TEST_WALLET, - priorityLevel: 'veryHigh', - maxLamports: 1000000, + slippagePct: 0.5, + approximateIfNoExactOut: false, }); // Validate the response expect(response.status).toBe(200); expect(validateSwapExecution(response.data)).toBe(true); expect(response.data.status).toBe(1); // CONFIRMED - expect(response.data.data.fee).toBe(0.002); // Higher fee + expect(response.data.data.fee).toBe(0.000005); - // Verify axios was called with fee parameters + // Verify axios was called with the supported parameters expect(axios.post).toHaveBeenCalledWith( `http://localhost:15888/connectors/${CONNECTOR}/execute-swap`, expect.objectContaining({ - priorityLevel: 'veryHigh', - maxLamports: 1000000, + slippagePct: 0.5, + approximateIfNoExactOut: false, }), ); + // Assert each removed field's absence separately: a combined + // not.objectContaining({a, b}) passes when EITHER key is missing. + const executeBody = axios.post.mock.calls[axios.post.mock.calls.length - 1][1]; + expect(executeBody.priorityLevel).toBeUndefined(); + expect(executeBody.maxLamports).toBeUndefined(); }); test('returns pending swap execution', async () => { diff --git a/test/connectors/meteora/clmm-routes/execute-swap.test.ts b/test/connectors/meteora/clmm-routes/execute-swap.test.ts index 18d2eabf52..c19c4b5a97 100644 --- a/test/connectors/meteora/clmm-routes/execute-swap.test.ts +++ b/test/connectors/meteora/clmm-routes/execute-swap.test.ts @@ -121,6 +121,10 @@ describe('POST /execute-swap', () => { blockTime: Date.now() / 1000, }), }, + getConfirmedTransactionData: jest.fn().mockResolvedValue({ + meta: { fee: 5000 }, + blockTime: Date.now() / 1000, + }), simulateWithErrorHandling: jest.fn().mockResolvedValue(undefined), extractBalanceChangesAndFee: jest.fn().mockResolvedValue({ balanceChanges: [-0.1, 14.85], @@ -168,6 +172,58 @@ describe('POST /execute-swap', () => { expect(body.data).toHaveProperty('quoteTokenBalanceChange', 14.85); expect(body.data).toHaveProperty('tokenIn', mockSOL.address); expect(body.data).toHaveProperty('tokenOut', mockUSDC.address); + // The applied slippage is echoed on the execute response. + expect(body.data).toHaveProperty('slippagePct', 1); + }); + + it('fails loudly (400 TRANSACTION_FAILED) when the transaction landed on-chain but failed', async () => { + const { transactionFailed } = jest.requireActual('../../../../src/services/error-handler'); + const extractBalanceChangesAndFee = jest.fn(); + const mockSolanaInstance = { + getWallet: jest.fn().mockResolvedValue(mockWallet), + getToken: jest.fn((t: string) => { + if (t === 'SOL' || t === mockSOL.address) return Promise.resolve(mockSOL); + if (t === 'USDC' || t === mockUSDC.address) return Promise.resolve(mockUSDC); + return Promise.resolve(null); + }), + sendAndConfirmTransactionForWallet: jest.fn().mockResolvedValue({ + signature: mockTransaction.signature, + fee: 0.000005, + }), + // The route-level re-fetch surfaces a landed-but-failed transaction as a throw — + // it must never be reported as CONFIRMED (data exists) or PENDING. + getConfirmedTransactionData: jest + .fn() + .mockRejectedValue( + transactionFailed(`Transaction ${mockTransaction.signature} landed on-chain but failed: custom error 0x1771`), + ), + extractBalanceChangesAndFee, + }; + (Solana.getInstance as jest.Mock).mockResolvedValue(mockSolanaInstance); + + (Meteora.getInstance as jest.Mock).mockResolvedValue({ + getDlmmPool: jest.fn().mockResolvedValue(mockDlmmPool), + }); + + const response = await server.inject({ + method: 'POST', + url: '/execute-swap', + payload: { + network: 'mainnet-beta', + walletAddress: '11111111111111111111111111111111', + poolAddress: mockPoolAddress, + baseToken: 'SOL', + quoteToken: 'USDC', + amount: 0.1, + side: 'SELL', + slippagePct: 1, + }, + }); + + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.body).message).toMatch(/landed on-chain but failed/); + // The route must not have tried to build a CONFIRMED response. + expect(extractBalanceChangesAndFee).not.toHaveBeenCalled(); }); it('should execute a CLMM swap for BUY side', async () => { @@ -194,6 +250,10 @@ describe('POST /execute-swap', () => { blockTime: Date.now() / 1000, }), }, + getConfirmedTransactionData: jest.fn().mockResolvedValue({ + meta: { fee: 5000 }, + blockTime: Date.now() / 1000, + }), simulateWithErrorHandling: jest.fn().mockResolvedValue(undefined), extractBalanceChangesAndFee: jest.fn().mockResolvedValue({ balanceChanges: [-15, 0.1], // For BUY: first is USDC (negative), second is SOL (positive) diff --git a/test/connectors/okx/router-routes/executeQuote.test.ts b/test/connectors/okx/router-routes/executeQuote.test.ts index 4ee20ef1e6..7ac279283d 100644 --- a/test/connectors/okx/router-routes/executeQuote.test.ts +++ b/test/connectors/okx/router-routes/executeQuote.test.ts @@ -29,6 +29,7 @@ const confirmedResult = { fee: 0.000005, baseTokenBalanceChange: -0.1, quoteTokenBalanceChange: 15, + slippagePct: 0.5, }, }; @@ -53,6 +54,7 @@ describe('POST /execute-quote (okx)', () => { const mockSolanaInstance = { sendAndConfirmTransactionForWallet, connection: { getTransaction: jest.fn(async () => ({ meta: {} })) }, + getConfirmedTransactionData: jest.fn(async () => ({ meta: {} })), handleConfirmation: jest.fn(async () => confirmedResult), }; (Solana.getInstance as jest.Mock).mockResolvedValue(mockSolanaInstance); @@ -79,7 +81,10 @@ describe('POST /execute-quote (okx)', () => { }); expect(response.statusCode).toBe(200); - expect(JSON.parse(response.body)).toMatchObject({ signature: 'okx-sig', status: 1 }); + const body = JSON.parse(response.body); + expect(body).toMatchObject({ signature: 'okx-sig', status: 1 }); + // The applied slippage survives the SwapExecuteResponse serializer. + expect(body.data.slippagePct).toBe(0.5); // The route is re-fetched with the executing wallet and the cached parameters. expect(getSwapTransaction).toHaveBeenCalledWith( WALLET, @@ -90,6 +95,18 @@ describe('POST /execute-quote (okx)', () => { 0.5, ); expect(sendAndConfirmTransactionForWallet).toHaveBeenCalledWith(unsignedTx, WALLET); + // The confirmation helper receives the retry-fetched txData and the applied slippage + // so it can decide the status (never `txData !== null`) and echo slippagePct. + expect(mockSolanaInstance.getConfirmedTransactionData).toHaveBeenCalledWith('okx-sig'); + expect(mockSolanaInstance.handleConfirmation).toHaveBeenCalledWith( + 'okx-sig', + { meta: {} }, + mockSOL.address, + mockUSDC.address, + WALLET, + undefined, + 0.5, + ); expect(quoteCache.get('okx-quote-1')).toBeNull(); }); diff --git a/test/connectors/orca/clmm-routes/closePosition.test.ts b/test/connectors/orca/clmm-routes/closePosition.test.ts index 994e334d93..b075d61a0d 100644 --- a/test/connectors/orca/clmm-routes/closePosition.test.ts +++ b/test/connectors/orca/clmm-routes/closePosition.test.ts @@ -38,6 +38,7 @@ describe('closePosition', () => { (Solana.getInstance as jest.Mock).mockResolvedValue({ sendAndConfirmTransactionForWallet: sendForWallet, connection: { getTransaction: jest.fn().mockResolvedValue(null) }, + getConfirmedTransactionData: jest.fn().mockResolvedValue(null), }); const deployment = { programId: PROGRAM, configAddress: POOL }; (Orca.getInstance as jest.Mock).mockResolvedValue({ diff --git a/test/connectors/orca/clmm-routes/create-pool.test.ts b/test/connectors/orca/clmm-routes/create-pool.test.ts index f027a4383b..fe16dddf36 100644 --- a/test/connectors/orca/clmm-routes/create-pool.test.ts +++ b/test/connectors/orca/clmm-routes/create-pool.test.ts @@ -97,6 +97,7 @@ describe('POST /create-pool (Orca CLMM)', () => { getTransaction: jest.fn().mockResolvedValue({ meta: { fee: 5000 } }), }, sendAndConfirmTransactionForWallet: sendAndConfirm, + getConfirmedTransactionData: jest.fn().mockResolvedValue({ meta: { fee: 5000 } }), }); (Orca.getInstance as jest.Mock).mockResolvedValue({ solanaKitRpc: {}, diff --git a/test/connectors/orca/clmm-routes/openPosition.test.ts b/test/connectors/orca/clmm-routes/openPosition.test.ts index 7e6a147a95..183e3f0355 100644 --- a/test/connectors/orca/clmm-routes/openPosition.test.ts +++ b/test/connectors/orca/clmm-routes/openPosition.test.ts @@ -60,6 +60,7 @@ describe('openPosition', () => { (Solana.getInstance as jest.Mock).mockResolvedValue({ sendAndConfirmTransactionForWallet: sendForWallet, connection: { getTransaction: jest.fn().mockResolvedValue(null) }, + getConfirmedTransactionData: jest.fn().mockResolvedValue(null), }); (Orca.getInstance as jest.Mock).mockResolvedValue({ solanaKitRpc: { getEpochInfo: jest.fn(() => ({ send: jest.fn().mockResolvedValue({ epoch: 1n }) })) }, diff --git a/test/connectors/orca/clmm-routes/removeLiquidity.test.ts b/test/connectors/orca/clmm-routes/removeLiquidity.test.ts index ffe976fdb2..b470d85535 100644 --- a/test/connectors/orca/clmm-routes/removeLiquidity.test.ts +++ b/test/connectors/orca/clmm-routes/removeLiquidity.test.ts @@ -53,7 +53,7 @@ describe('POST /remove-liquidity', () => { network: 'mainnet-beta', walletAddress: mockWalletAddress, positionAddress: mockPositionAddress, - percentage: 50, + percentageToRemove: 50, }, }); @@ -79,7 +79,7 @@ describe('POST /remove-liquidity', () => { network: 'mainnet-beta', walletAddress: mockWalletAddress, positionAddress: mockPositionAddress, - percentage: 100, + percentageToRemove: 100, }, }); @@ -92,7 +92,7 @@ describe('POST /remove-liquidity', () => { url: '/remove-liquidity', payload: { positionAddress: mockPositionAddress, - percentage: 25, + percentageToRemove: 25, }, }); @@ -108,14 +108,14 @@ describe('POST /remove-liquidity', () => { payload: { network: 'mainnet-beta', walletAddress: mockWalletAddress, - percentage: 50, + percentageToRemove: 50, }, }); expect(response.statusCode).toBe(400); }); - it('should return error when percentage is missing', async () => { + it('should return error when percentageToRemove is missing', async () => { const response = await app.inject({ method: 'POST', url: '/remove-liquidity', @@ -129,7 +129,7 @@ describe('POST /remove-liquidity', () => { expect(response.statusCode).toBeGreaterThanOrEqual(400); }); - it('should handle invalid percentage values', async () => { + it('should handle invalid percentageToRemove values', async () => { const response = await app.inject({ method: 'POST', url: '/remove-liquidity', @@ -137,7 +137,7 @@ describe('POST /remove-liquidity', () => { network: 'mainnet-beta', walletAddress: mockWalletAddress, positionAddress: mockPositionAddress, - percentage: 150, + percentageToRemove: 150, }, }); @@ -159,7 +159,7 @@ describe('POST /remove-liquidity', () => { network: 'mainnet-beta', walletAddress: mockWalletAddress, positionAddress: mockPositionAddress, - percentage: 50, + percentageToRemove: 50, }, }); @@ -176,7 +176,7 @@ describe('POST /remove-liquidity', () => { network: 'mainnet-beta', walletAddress: mockWalletAddress, positionAddress: mockPositionAddress, - percentage: 50, + percentageToRemove: 50, }, }); diff --git a/test/connectors/orca/orca.test.ts b/test/connectors/orca/orca.test.ts index 964e01cfa5..bc0ae36a34 100644 --- a/test/connectors/orca/orca.test.ts +++ b/test/connectors/orca/orca.test.ts @@ -17,6 +17,7 @@ jest.mock('@orca-so/whirlpools', () => ({ jest.mock('@orca-so/whirlpools-client', () => ({ fetchWhirlpool: jest.fn(), fetchPosition: jest.fn(), + fetchMaybePosition: jest.fn(), })); jest.mock('@solana/kit', () => ({ address: jest.fn((value: string) => value), @@ -27,7 +28,7 @@ jest.mock('@solana/kit', () => ({ })); import { fetchPositionsForOwner, setNativeMintWrappingStrategy } from '@orca-so/whirlpools'; -import { fetchPosition, fetchWhirlpool } from '@orca-so/whirlpools-client'; +import { fetchMaybePosition, fetchPosition, fetchWhirlpool } from '@orca-so/whirlpools-client'; import { Solana } from '../../../src/chains/solana/solana'; import { Orca } from '../../../src/connectors/orca/orca'; @@ -148,6 +149,7 @@ describe('Orca', () => { it('validates and reads a specific position without a wallet-bound SDK client', async () => { const info = { address: Keypair.generate().publicKey.toBase58() }; + (fetchMaybePosition as jest.Mock).mockResolvedValue({ exists: true }); (getPositionDetails as jest.Mock).mockResolvedValue(info); const orca = await Orca.getInstance('mainnet-beta'); await expect(orca.getPositionInfo(info.address, wallet.publicKey.toBase58())).resolves.toEqual(info); @@ -156,6 +158,27 @@ describe('Orca', () => { ); }); + it('returns null from getPositionInfo only when the account definitively does not exist', async () => { + (fetchMaybePosition as jest.Mock).mockResolvedValue({ exists: false }); + const orca = await Orca.getInstance('mainnet-beta'); + const positionAddress = Keypair.generate().publicKey.toBase58(); + await expect(orca.getPositionInfo(positionAddress, wallet.publicKey.toBase58())).resolves.toBeNull(); + expect(getPositionDetails).not.toHaveBeenCalled(); + }); + + it('propagates transient errors from getPositionInfo instead of reporting the position closed', async () => { + // Callers treat null as "position closed"; a swallowed RPC error here would + // let an LP executor abandon a live, funded position while reporting success. + (fetchMaybePosition as jest.Mock).mockRejectedValue(new Error('429 Too Many Requests')); + const orca = await Orca.getInstance('mainnet-beta'); + const positionAddress = Keypair.generate().publicKey.toBase58(); + await expect(orca.getPositionInfo(positionAddress, wallet.publicKey.toBase58())).rejects.toThrow('429'); + + (fetchMaybePosition as jest.Mock).mockResolvedValue({ exists: true }); + (getPositionDetails as jest.Mock).mockRejectedValue(new Error('RPC node behind')); + await expect(orca.getPositionInfo(positionAddress, wallet.publicKey.toBase58())).rejects.toThrow('RPC node behind'); + }); + it('keeps connector configuration public', async () => { const orca = await Orca.getInstance('mainnet-beta'); expect(orca.config).toBe(OrcaConfig.config); diff --git a/test/connectors/pancakeswap-sol/clmm-routes/collectFees.test.ts b/test/connectors/pancakeswap-sol/clmm-routes/collectFees.test.ts new file mode 100644 index 0000000000..518e98aeb2 --- /dev/null +++ b/test/connectors/pancakeswap-sol/clmm-routes/collectFees.test.ts @@ -0,0 +1,143 @@ +import BN from 'bn.js'; + +import { Solana } from '../../../../src/chains/solana/solana'; +import { PancakeswapSol } from '../../../../src/connectors/pancakeswap-sol/pancakeswap-sol'; +import { buildRemoveLiquidityTransaction } from '../../../../src/connectors/pancakeswap-sol/pancakeswap-sol.transactions'; +import { transactionFailed } from '../../../../src/services/error-handler'; +import { fastifyWithTypeProvider } from '../../../utils/testUtils'; + +jest.mock('../../../../src/chains/solana/solana'); +jest.mock('../../../../src/connectors/pancakeswap-sol/pancakeswap-sol'); +jest.mock('../../../../src/connectors/pancakeswap-sol/pancakeswap-sol.transactions'); + +const buildApp = async () => { + const server = fastifyWithTypeProvider(); + await server.register(require('@fastify/sensible')); + const { collectFeesRoute } = await import('../../../../src/connectors/pancakeswap-sol/clmm-routes/collectFees'); + await server.register(collectFeesRoute); + return server; +}; + +const WALLET = 'AabEVCB1sWgCPxbn6hFYM4Ukj7UubpBRbbYqRnqRXnZD'; +const POSITION = 'ARwi1S4DaiTG5DX7S4M4ZsrXqpMD1MrTmbu9ue2tpmEq'; + +const mockSOL = { symbol: 'SOL', address: 'So11111111111111111111111111111111111111112', decimals: 9 }; +const mockUSDC = { symbol: 'USDC', address: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', decimals: 6 }; + +const mockPositionInfo = { + baseTokenAddress: mockSOL.address, + quoteTokenAddress: mockUSDC.address, +}; + +const mockTransaction = { sign: jest.fn() }; + +const baseSolanaMock = (overrides: Record = {}) => ({ + getToken: jest.fn((t: string) => { + if (t === mockSOL.address) return Promise.resolve(mockSOL); + if (t === mockUSDC.address) return Promise.resolve(mockUSDC); + return Promise.resolve(null); + }), + getWallet: jest.fn().mockResolvedValue({ publicKey: WALLET }), + estimateGasPrice: jest.fn().mockResolvedValue(0.001), + simulateWithErrorHandling: jest.fn().mockResolvedValue(undefined), + throwIfLandedWithError: jest.fn().mockResolvedValue(undefined), + extractBalanceChangesAndFee: jest.fn().mockResolvedValue({ balanceChanges: [0.0012, 0.34] }), + ...overrides, +}); + +describe('POST /collect-fees (pancakeswap-sol)', () => { + let server: any; + + beforeAll(async () => { + server = await buildApp(); + }); + + afterAll(async () => { + await server.close(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + (PancakeswapSol.getInstance as jest.Mock).mockResolvedValue({ + getPositionInfo: jest.fn().mockResolvedValue(mockPositionInfo), + }); + (buildRemoveLiquidityTransaction as jest.Mock).mockResolvedValue(mockTransaction); + }); + + it('collects fees via a zero-liquidity decrease — the position liquidity is NOT touched', async () => { + const mockSolana = baseSolanaMock({ + sendAndConfirmRawTransaction: jest + .fn() + .mockResolvedValue({ confirmed: true, signature: 'collect-sig', txData: { meta: { fee: 5000 } } }), + }); + (Solana.getInstance as jest.Mock).mockResolvedValue(mockSolana); + + const response = await server.inject({ + method: 'POST', + url: '/collect-fees', + body: { network: 'mainnet-beta', walletAddress: WALLET, positionAddress: POSITION }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body).toMatchObject({ + signature: 'collect-sig', + status: 1, + data: { + fee: 5000 / 1e9, + baseFeeAmountCollected: 0.0012, + quoteFeeAmountCollected: 0.34, + }, + }); + + // The decrease_liquidity_v2 call must carry liquidity = 0 (fees only) — the old + // implementation removed 1% of the position's liquidity and lied about the fees. + const [, , , liquidityArg, amount0Min, amount1Min] = (buildRemoveLiquidityTransaction as jest.Mock).mock.calls[0]; + expect(liquidityArg).toBeInstanceOf(BN); + expect(liquidityArg.isZero()).toBe(true); + expect(amount0Min.isZero()).toBe(true); + expect(amount1Min.isZero()).toBe(true); + }); + + it('fails loudly when the collect transaction landed on-chain but failed (no silent PENDING)', async () => { + const failedTxData = { meta: { err: { InstructionError: [0, 'Custom'] } } }; + const mockSolana = baseSolanaMock({ + sendAndConfirmRawTransaction: jest + .fn() + .mockResolvedValue({ confirmed: false, signature: 'failed-sig', txData: failedTxData }), + throwIfLandedWithError: jest + .fn() + .mockRejectedValue(transactionFailed('Transaction failed-sig landed on-chain but failed: custom error')), + }); + (Solana.getInstance as jest.Mock).mockResolvedValue(mockSolana); + + const response = await server.inject({ + method: 'POST', + url: '/collect-fees', + body: { network: 'mainnet-beta', walletAddress: WALLET, positionAddress: POSITION }, + }); + + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.body).message).toMatch(/landed on-chain but failed/); + expect(mockSolana.throwIfLandedWithError).toHaveBeenCalledWith('failed-sig', failedTxData); + }); + + it('keeps the pending shape when the transaction genuinely has not landed', async () => { + const mockSolana = baseSolanaMock({ + sendAndConfirmRawTransaction: jest + .fn() + .mockResolvedValue({ confirmed: false, signature: 'pending-sig', txData: null }), + }); + (Solana.getInstance as jest.Mock).mockResolvedValue(mockSolana); + + const response = await server.inject({ + method: 'POST', + url: '/collect-fees', + body: { network: 'mainnet-beta', walletAddress: WALLET, positionAddress: POSITION }, + }); + + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.body)).toMatchObject({ signature: 'pending-sig', status: 0 }); + expect(mockSolana.throwIfLandedWithError).toHaveBeenCalledWith('pending-sig', null); + }); +}); diff --git a/test/connectors/pancakeswap/clmm-routes/pool-info.test.ts b/test/connectors/pancakeswap/clmm-routes/pool-info.test.ts new file mode 100644 index 0000000000..3f011df491 --- /dev/null +++ b/test/connectors/pancakeswap/clmm-routes/pool-info.test.ts @@ -0,0 +1,174 @@ +import { BigNumber } from 'ethers'; + +import { Ethereum } from '../../../../src/chains/ethereum/ethereum'; +import { fastifyWithTypeProvider } from '../../../utils/testUtils'; + +jest.mock('../../../../src/chains/ethereum/ethereum'); +jest.mock('../../../../src/connectors/pancakeswap/pancakeswap'); +jest.mock('../../../../src/connectors/pancakeswap/pancakeswap.utils'); +jest.mock('../../../../src/connectors/clmm-v3-utils'); + +const buildApp = async () => { + const server = fastifyWithTypeProvider(); + await server.register(require('@fastify/sensible')); + const { poolInfoRoute } = await import('../../../../src/connectors/pancakeswap/clmm-routes/poolInfo'); + await server.register(poolInfoRoute); + return server; +}; + +// PancakeSwap V3 USDT-WBNB pool on BSC. +const POOL_ADDRESS = '0x172fcd41e0913e95784454622d1c3724f546f849'; +const USDT = { address: '0x55d398326f99059fF775485246999027B3197955', symbol: 'USDT', decimals: 18 }; +const WBNB = { address: '0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c', symbol: 'WBNB', decimals: 18 }; + +const USDT_RAW_BALANCE = BigNumber.from('4200000000000000000000000'); // 4,200,000 * 1e18 +const WBNB_RAW_BALANCE = BigNumber.from('7000000000000000000000'); // 7,000 * 1e18 + +// V3 virtual liquidity — the value the route used to report for BOTH tokens. +const POOL_LIQUIDITY = BigNumber.from('11034936417288527'); + +const mockPool = { + token0: { address: USDT.address, decimals: USDT.decimals }, + token1: { address: WBNB.address, decimals: WBNB.decimals }, + liquidity: POOL_LIQUIDITY, + sqrtRatioX96: BigNumber.from('79228162514264337593543950336'), + token0Price: { toSignificant: () => '0.00166' }, // USDT priced in WBNB + token1Price: { toSignificant: () => '602.4' }, + fee: 2500, // 0.25% in hundredths-of-bips + tickSpacing: 50, + tickCurrent: -64000, +}; + +const setupMocks = async () => { + const { Pancakeswap } = await import('../../../../src/connectors/pancakeswap/pancakeswap'); + const { getPancakeswapPoolInfo, formatTokenAmount } = await import( + '../../../../src/connectors/pancakeswap/pancakeswap.utils' + ); + + (getPancakeswapPoolInfo as jest.Mock).mockResolvedValue({ + baseTokenAddress: USDT.address, + quoteTokenAddress: WBNB.address, + poolType: 'clmm', + }); + (formatTokenAmount as jest.Mock).mockImplementation( + (amount: string, decimals: number) => Number(amount) / Math.pow(10, decimals), + ); + + (Pancakeswap.getInstance as jest.Mock).mockResolvedValue({ + getToken: jest.fn().mockImplementation((addr: string) => { + if (addr.toLowerCase() === USDT.address.toLowerCase()) return USDT; + if (addr.toLowerCase() === WBNB.address.toLowerCase()) return WBNB; + return null; + }), + getV3Pool: jest.fn().mockResolvedValue(mockPool), + }); + + const mockUsdtContract = { address: USDT.address }; + const mockWbnbContract = { address: WBNB.address }; + (Ethereum.getInstance as jest.Mock).mockResolvedValue({ + provider: { _isProvider: true }, + getContract: jest.fn().mockImplementation((tokenAddress: string) => { + if (tokenAddress.toLowerCase() === USDT.address.toLowerCase()) return mockUsdtContract; + if (tokenAddress.toLowerCase() === WBNB.address.toLowerCase()) return mockWbnbContract; + throw new Error(`unexpected contract address ${tokenAddress}`); + }), + getERC20BalanceByAddress: jest.fn().mockImplementation((contract: any, address: string, decimals: number) => { + expect(address).toBe(POOL_ADDRESS); // route must query the pool contract + if (contract.address === USDT.address) return Promise.resolve({ value: USDT_RAW_BALANCE, decimals }); + if (contract.address === WBNB.address) return Promise.resolve({ value: WBNB_RAW_BALANCE, decimals }); + return Promise.reject(new Error('unexpected token')); + }), + }); +}; + +const sampleBins = (n: number) => + Array.from({ length: n }, (_, i) => ({ + binId: -64000 + i * 50, + price: 600 + i, + baseTokenAmount: 10 + i, + quoteTokenAmount: 20 + i, + })); + +describe('GET /pool-info (PancakeSwap CLMM)', () => { + let server: any; + + beforeAll(async () => { + server = await buildApp(); + }); + + afterAll(async () => { + await server.close(); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + await setupMocks(); + }); + + it("returns the pool contract's actual ERC20 balances, not pool.liquidity", async () => { + const response = await server.inject({ + method: 'GET', + url: '/pool-info', + query: { network: 'bsc', poolAddress: POOL_ADDRESS }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + + expect(body.address).toBe(POOL_ADDRESS); + expect(body.feePct).toBeCloseTo(0.25, 6); + expect(body.binStep).toBe(50); + expect(body.activeBinId).toBe(-64000); + + // The fix: token amounts come from ERC20 balanceOf, not virtual liquidity. + expect(body.baseTokenAmount).toBeCloseTo(4200000, 0); + expect(body.quoteTokenAmount).toBeCloseTo(7000, 0); + + // Regression guard: the legacy bug reported pool.liquidity / 10^decimals for + // both sides — identical figures that mean nothing in token terms. + const buggy = Number(POOL_LIQUIDITY.toString()) / 1e18; + expect(body.baseTokenAmount).not.toBeCloseTo(buggy, 6); + expect(body.quoteTokenAmount).not.toBeCloseTo(buggy, 6); + expect(body.baseTokenAmount).not.toEqual(body.quoteTokenAmount); + }); + + it('omits bins and skips the tick reads when binCount is absent', async () => { + const { computeV3BinDistribution } = await import('../../../../src/connectors/clmm-v3-utils'); + + const response = await server.inject({ + method: 'GET', + url: '/pool-info', + query: { network: 'bsc', poolAddress: POOL_ADDRESS }, + }); + + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.body).bins).toBeUndefined(); + expect(computeV3BinDistribution).not.toHaveBeenCalled(); + }); + + it('returns bins when binCount > 0', async () => { + const { computeV3BinDistribution } = await import('../../../../src/connectors/clmm-v3-utils'); + (computeV3BinDistribution as jest.Mock).mockResolvedValueOnce(sampleBins(11)); + + const response = await server.inject({ + method: 'GET', + url: '/pool-info', + query: { network: 'bsc', poolAddress: POOL_ADDRESS, binCount: '11' }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.bins).toHaveLength(11); + expect(body.bins[0]).toEqual({ binId: -64000, price: 600, baseTokenAmount: 10, quoteTokenAmount: 20 }); + + expect(computeV3BinDistribution).toHaveBeenCalledTimes(1); + const args = (computeV3BinDistribution as jest.Mock).mock.calls[0][0]; + expect(args.binCount).toBe(11); + expect(args.tickSpacing).toBe(50); + expect(args.currentTick).toBe(-64000); + expect(args.isBaseToken0).toBe(true); + // PancakeSwap's SDK math must be supplied — the shared helper is math-agnostic. + expect(typeof args.tickMath.getSqrtRatioAtTick).toBe('function'); + expect(typeof args.sqrtPriceMath.getAmount0Delta).toBe('function'); + }); +}); diff --git a/test/connectors/raydium/amm-routes/addLiquidity.test.ts b/test/connectors/raydium/amm-routes/addLiquidity.test.ts index 3445230751..4339caf0b7 100644 --- a/test/connectors/raydium/amm-routes/addLiquidity.test.ts +++ b/test/connectors/raydium/amm-routes/addLiquidity.test.ts @@ -122,6 +122,7 @@ const buildSolanaMock = (overrides: any = {}) => ({ connection: { getTransaction: jest.fn().mockResolvedValue({ meta: { fee: 5000 } }), }, + getConfirmedTransactionData: jest.fn().mockResolvedValue({ meta: { fee: 5000 } }), extractBalanceChangesAndFee: jest.fn().mockResolvedValue({ balanceChanges: [-0.999, -149.85], }), diff --git a/test/connectors/raydium/clmm-routes/openPosition.test.ts b/test/connectors/raydium/clmm-routes/openPosition.test.ts index 55fd11f5f1..2e9d7cbef1 100644 --- a/test/connectors/raydium/clmm-routes/openPosition.test.ts +++ b/test/connectors/raydium/clmm-routes/openPosition.test.ts @@ -109,6 +109,7 @@ const buildSolanaMock = (overrides: Record = {}) => ({ connection: { getTransaction: jest.fn().mockResolvedValue(mockTxData), }, + getConfirmedTransactionData: jest.fn().mockResolvedValue(mockTxData), extractBalanceChangesAndFee: jest.fn().mockResolvedValue({ balanceChanges: [-0.002, -1, -150], }), @@ -207,10 +208,7 @@ describe('POST /open-position', () => { mockWalletAddress, mockSigners, ); - expect(mockSolanaInstance.connection.getTransaction).toHaveBeenCalledWith( - 'mock-signature', - expect.objectContaining({ commitment: 'confirmed', maxSupportedTransactionVersion: 0 }), - ); + expect(mockSolanaInstance.getConfirmedTransactionData).toHaveBeenCalledWith('mock-signature'); // Verify the response expect(body).toHaveProperty('signature', 'mock-signature'); diff --git a/test/connectors/titan/router-routes/executeQuote.test.ts b/test/connectors/titan/router-routes/executeQuote.test.ts index e66955d2eb..0ac3853f44 100644 --- a/test/connectors/titan/router-routes/executeQuote.test.ts +++ b/test/connectors/titan/router-routes/executeQuote.test.ts @@ -69,6 +69,7 @@ describe('POST /execute-quote (titan)', () => { const mockSolanaInstance = { sendAndConfirmTransactionForWallet, connection: mockConnection, + getConfirmedTransactionData: jest.fn(async () => ({ meta: {} })), handleConfirmation: jest.fn(async () => confirmedResult), }; (Solana.getInstance as jest.Mock).mockResolvedValue(mockSolanaInstance); diff --git a/test/connectors/uniswap/amm-routes/remove-liquidity-confirmation.test.ts b/test/connectors/uniswap/amm-routes/remove-liquidity-confirmation.test.ts new file mode 100644 index 0000000000..3872efa394 --- /dev/null +++ b/test/connectors/uniswap/amm-routes/remove-liquidity-confirmation.test.ts @@ -0,0 +1,140 @@ +import { BigNumber } from 'ethers'; + +import { Ethereum } from '../../../../src/chains/ethereum/ethereum'; +import { Uniswap } from '../../../../src/connectors/uniswap/uniswap'; +import { getUniswapPoolInfo } from '../../../../src/connectors/uniswap/uniswap.utils'; +import { fastifyWithTypeProvider } from '../../../utils/testUtils'; + +jest.mock('../../../../src/chains/ethereum/ethereum'); +jest.mock('../../../../src/connectors/uniswap/uniswap'); +jest.mock('@ethersproject/contracts'); +jest.mock('../../../../src/connectors/uniswap/amm-routes/positionInfo', () => ({ + ...jest.requireActual('../../../../src/connectors/uniswap/amm-routes/positionInfo'), + checkLPAllowance: jest.fn().mockResolvedValue(undefined), +})); +jest.mock('../../../../src/connectors/uniswap/uniswap.utils', () => ({ + ...jest.requireActual('../../../../src/connectors/uniswap/uniswap.utils'), + getUniswapPoolInfo: jest.fn(), +})); + +// The AMM side of the same confirmation contract (the CLMM side is pinned in +// clmm-routes/collect-fees-confirmation.test.ts). remove-liquidity is the route where the old +// behaviour was worst: `expectedBaseTokenAmount`/`expectedQuoteTokenAmount` are derived from +// reserves read BEFORE sending, so a reverted transaction used to report those withdrawals +// alongside status 0 — read downstream as "still pending" forever. + +const USDC = { address: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', symbol: 'USDC', decimals: 6 }; +const DAI = { address: '0x50c5725949a6f0c72e6c4a641f24049a917db0cb', symbol: 'DAI', decimals: 18 }; +const mockWallet = '0x0000000000000000000000000000000000000001'; +const poolAddress = '0xd0b53d9277642d899df5c87a3966a349a798f224'; +const txHash = '0x3333333333333333333333333333333333333333333333333333333333333333'; + +const { Ethereum: RealEthereum } = jest.requireActual('../../../../src/chains/ethereum/ethereum'); + +const buildApp = async () => { + const server = fastifyWithTypeProvider(); + await server.register(require('@fastify/sensible')); + const { removeLiquidityRoute } = await import('../../../../src/connectors/uniswap/amm-routes/removeLiquidity'); + await server.register(removeLiquidityRoute); + return server; +}; + +const primeMocks = (receipt: any) => { + (getUniswapPoolInfo as jest.Mock).mockResolvedValue({ + baseTokenAddress: USDC.address, + quoteTokenAddress: DAI.address, + }); + + (Uniswap.getInstance as jest.Mock).mockResolvedValue({ + getToken: jest.fn((address: string) => Promise.resolve(address === USDC.address ? USDC : DAI)), + }); + + const ethereum: any = { + provider: {}, + getWallet: jest.fn().mockResolvedValue({ address: mockWallet }), + prepareGasOptions: jest.fn().mockResolvedValue({}), + handleTransactionExecution: jest.fn().mockResolvedValue(receipt), + }; + ethereum.handleTransactionConfirmation = RealEthereum.prototype.handleTransactionConfirmation.bind(ethereum); + (Ethereum.getInstance as jest.Mock).mockResolvedValue(ethereum); + + const { Contract } = require('@ethersproject/contracts'); + (Contract as jest.Mock).mockImplementation(() => ({ + // LP pair reads: the wallet holds 10% of a pool with 100 USDC / 100 DAI of reserves, + // so removing 50% is worth 5 USDC + 5 DAI. + balanceOf: jest.fn().mockResolvedValue(BigNumber.from('1000000000000000000')), + token0: jest.fn().mockResolvedValue(USDC.address), + token1: jest.fn().mockResolvedValue(DAI.address), + totalSupply: jest.fn().mockResolvedValue(BigNumber.from('10000000000000000000')), + getReserves: jest.fn().mockResolvedValue([BigNumber.from('100000000'), BigNumber.from('100000000000000000000')]), + // Router write + removeLiquidity: jest.fn().mockResolvedValue({ hash: txHash }), + })); +}; + +const remove = (server: any) => + server.inject({ + method: 'POST', + url: '/remove-liquidity', + payload: { network: 'base', walletAddress: mockWallet, poolAddress, percentageToRemove: 50 }, + }); + +describe('POST /remove-liquidity (Uniswap V2 AMM) — transaction confirmation', () => { + let server: any; + + beforeEach(async () => { + jest.clearAllMocks(); + (Ethereum.getWalletAddressExample as jest.Mock) = jest.fn().mockResolvedValue(mockWallet); + server = await buildApp(); + }); + + afterEach(async () => { + await server.close(); + }); + + it('returns the pending shape with the tx hash and no withdrawn amounts', async () => { + primeMocks(null); + + const response = await remove(server); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.signature).toBe(txHash); + expect(body.status).toBe(0); // TransactionStatus.PENDING + expect(body.data).toBeUndefined(); + }); + + it('fails loudly (400 TRANSACTION_FAILED) on a revert rather than booking amounts as pending', async () => { + primeMocks({ + status: 0, + transactionHash: txHash, + gasUsed: BigNumber.from(21_000), + effectiveGasPrice: BigNumber.from('1000000000'), + }); + + const response = await remove(server); + + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.body).message).toContain(txHash); + expect(response.body).not.toContain('baseTokenAmountRemoved'); + }); + + it('returns the confirmed shape with the withdrawn amounts and the receipt gas fee', async () => { + primeMocks({ + status: 1, + transactionHash: txHash, + logs: [], + gasUsed: BigNumber.from(21_000), + effectiveGasPrice: BigNumber.from('1000000000'), + }); + + const response = await remove(server); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.status).toBe(1); // TransactionStatus.CONFIRMED + expect(body.data.baseTokenAmountRemoved).toBe(5); + expect(body.data.quoteTokenAmountRemoved).toBe(5); + expect(body.data.fee).toBe(0.000021); + }); +}); diff --git a/test/connectors/uniswap/clmm-routes/collect-fees-confirmation.test.ts b/test/connectors/uniswap/clmm-routes/collect-fees-confirmation.test.ts new file mode 100644 index 0000000000..86ecd4c0e4 --- /dev/null +++ b/test/connectors/uniswap/clmm-routes/collect-fees-confirmation.test.ts @@ -0,0 +1,143 @@ +import { Token } from '@uniswap/sdk-core'; +import { BigNumber } from 'ethers'; + +import { Ethereum } from '../../../../src/chains/ethereum/ethereum'; +import { Uniswap } from '../../../../src/connectors/uniswap/uniswap'; +import { fastifyWithTypeProvider } from '../../../utils/testUtils'; + +jest.mock('../../../../src/chains/ethereum/ethereum'); +jest.mock('../../../../src/connectors/uniswap/uniswap'); +jest.mock('@ethersproject/contracts'); + +// Route-level contract for an EVM CLMM liquidity route once the transaction has been sent. +// collect-fees stands in for every route on the shared confirmation gate: the amounts in its +// `data` are read BEFORE sending, so reporting them for anything but a confirmed transaction +// books tokens that never moved. +// +// Pinned here: +// - still pending -> 200 { signature, status: 0 } and NO data (used to be a TypeError on a +// null receipt, turned into a 500 that threw the tx hash away). +// - reverted -> 400 TRANSACTION_FAILED with no amounts (used to be status 0 == PENDING +// alongside the pre-send fee amounts, which a poller waits on forever). +// - confirmed -> 200 { status: 1, data } with the gas fee from the receipt. + +const WETH = new Token(8453, '0x4200000000000000000000000000000000000006', 18, 'WETH', 'Wrapped Ether'); +const USDC = new Token(8453, '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', 6, 'USDC', 'USD Coin'); +const mockWallet = '0x0000000000000000000000000000000000000001'; +const positionAddress = '1234'; +const txHash = '0x1111111111111111111111111111111111111111111111111111111111111111'; + +// The real confirmation helper, bound to the stubbed instance, so these tests exercise the +// gate itself rather than a mock of it. +const { Ethereum: RealEthereum } = jest.requireActual('../../../../src/chains/ethereum/ethereum'); + +const buildApp = async () => { + const server = fastifyWithTypeProvider(); + await server.register(require('@fastify/sensible')); + const { collectFeesRoute } = await import('../../../../src/connectors/uniswap/clmm-routes/collectFees'); + await server.register(collectFeesRoute); + return server; +}; + +/** Wire the Uniswap/Ethereum/Contract stubs so the route reaches the confirmation gate. */ +const primeMocks = (receipt: any) => { + (Uniswap.getInstance as jest.Mock).mockResolvedValue({ + checkNFTOwnership: jest.fn().mockResolvedValue(undefined), + getToken: jest.fn((address: string) => + Promise.resolve(address.toLowerCase() === WETH.address.toLowerCase() ? WETH : USDC), + ), + }); + + const ethereum: any = { + provider: {}, + getWallet: jest.fn().mockResolvedValue({ address: mockWallet }), + prepareGasOptions: jest.fn().mockResolvedValue({}), + handleTransactionExecution: jest.fn().mockResolvedValue(receipt), + }; + ethereum.handleTransactionConfirmation = RealEthereum.prototype.handleTransactionConfirmation.bind(ethereum); + (Ethereum.getInstance as jest.Mock).mockResolvedValue(ethereum); + (Ethereum.getWalletAddressExample as jest.Mock).mockResolvedValue(mockWallet); + + const { Contract } = require('@ethersproject/contracts'); + (Contract as jest.Mock).mockImplementation(() => ({ + positions: jest.fn().mockResolvedValue({ + token0: WETH.address, + token1: USDC.address, + tokensOwed0: BigNumber.from('1000000000000000'), // 0.001 WETH + tokensOwed1: BigNumber.from('2000000'), // 2 USDC + }), + multicall: jest.fn().mockResolvedValue({ hash: txHash }), + })); +}; + +const collect = (server: any) => + server.inject({ + method: 'POST', + url: '/collect-fees', + payload: { network: 'base', walletAddress: mockWallet, positionAddress }, + }); + +describe('POST /collect-fees (Uniswap V3 CLMM) — transaction confirmation', () => { + let server: any; + + beforeEach(async () => { + jest.clearAllMocks(); + (Ethereum.getWalletAddressExample as jest.Mock) = jest.fn().mockResolvedValue(mockWallet); + server = await buildApp(); + }); + + afterEach(async () => { + await server.close(); + }); + + it('returns the pending shape with the tx hash when the receipt is still missing', async () => { + primeMocks(null); + + const response = await collect(server); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.signature).toBe(txHash); + expect(body.status).toBe(0); // TransactionStatus.PENDING + // No fabricated amounts: nothing has been collected yet. + expect(body.data).toBeUndefined(); + }); + + it('fails loudly (400 TRANSACTION_FAILED) on a revert instead of reporting PENDING with amounts', async () => { + primeMocks({ + status: 0, + transactionHash: txHash, + gasUsed: BigNumber.from(21_000), + effectiveGasPrice: BigNumber.from('1000000000'), + }); + + const response = await collect(server); + + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body); + expect(body.message).toContain(txHash); + expect(body.message).toMatch(/reverted on-chain/); + // The pre-send fee amounts must not appear anywhere in the response. + expect(response.body).not.toContain('baseFeeAmountCollected'); + }); + + it('returns the confirmed shape with the receipt gas fee', async () => { + primeMocks({ + status: 1, + transactionHash: txHash, + logs: [], + gasUsed: BigNumber.from(21_000), + effectiveGasPrice: BigNumber.from('1000000000'), + }); + + const response = await collect(server); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.signature).toBe(txHash); + expect(body.status).toBe(1); // TransactionStatus.CONFIRMED + expect(body.data.fee).toBe(0.000021); + expect(body.data.baseFeeAmountCollected).toBe(0.001); + expect(body.data.quoteFeeAmountCollected).toBe(2); + }); +}); diff --git a/test/connectors/uniswap/clmm-routes/open-position-confirmation.test.ts b/test/connectors/uniswap/clmm-routes/open-position-confirmation.test.ts new file mode 100644 index 0000000000..ccea2033b3 --- /dev/null +++ b/test/connectors/uniswap/clmm-routes/open-position-confirmation.test.ts @@ -0,0 +1,171 @@ +import { Token } from '@uniswap/sdk-core'; +import { Pool, TickMath, encodeSqrtRatioX96 } from '@uniswap/v3-sdk'; +import { BigNumber } from 'ethers'; + +import { Ethereum } from '../../../../src/chains/ethereum/ethereum'; +import { Uniswap } from '../../../../src/connectors/uniswap/uniswap'; +import { getUniswapV3NftManagerAddress } from '../../../../src/connectors/uniswap/uniswap.contracts'; +import { getUniswapPoolInfo } from '../../../../src/connectors/uniswap/uniswap.utils'; +import { fastifyWithTypeProvider } from '../../../utils/testUtils'; + +jest.mock('../../../../src/chains/ethereum/ethereum'); +jest.mock('../../../../src/connectors/uniswap/uniswap'); +jest.mock('@ethersproject/contracts'); +jest.mock('../../../../src/connectors/uniswap/uniswap.utils', () => ({ + ...jest.requireActual('../../../../src/connectors/uniswap/uniswap.utils'), + getUniswapPoolInfo: jest.fn(), +})); + +// open-position is the route where a confirmed transaction can still fail to produce a usable +// result: the position's address only exists in the NFT-mint Transfer log. Pinned here: +// - still pending -> 200 { signature, status: 0 } with no data (no mint log yet). +// - confirmed, no mint log -> 500 naming the tx hash, NOT a confirmed response carrying +// positionAddress: '' that the caller could never address. +// - confirmed, mint log found -> 200 with the position ID from the log. +// - unexpected failure -> the underlying message survives the route's catch. + +const WETH = new Token(8453, '0x4200000000000000000000000000000000000006', 18, 'WETH', 'Wrapped Ether'); +const USDC = new Token(8453, '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', 6, 'USDC', 'USD Coin'); +const mockWallet = '0x0000000000000000000000000000000000000001'; +const poolAddress = '0xd0b53D9277642d899DF5C87A3966A349A798F224'; +const txHash = '0x2222222222222222222222222222222222222222222222222222222222222222'; +const TRANSFER_TOPIC = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'; +const ZERO_TOPIC = '0x0000000000000000000000000000000000000000000000000000000000000000'; + +const { Ethereum: RealEthereum } = jest.requireActual('../../../../src/chains/ethereum/ethereum'); + +/** A live WETH/USDC 0.3% pool at ~3000 USDC per WETH. */ +const buildPool = () => { + const sqrtRatioX96 = encodeSqrtRatioX96('3000000000', '1000000000000000000'); + return new Pool(WETH, USDC, 3000, sqrtRatioX96, '1000000000000', TickMath.getTickAtSqrtRatio(sqrtRatioX96)); +}; + +const buildApp = async () => { + const server = fastifyWithTypeProvider(); + await server.register(require('@fastify/sensible')); + const { openPositionRoute } = await import('../../../../src/connectors/uniswap/clmm-routes/openPosition'); + await server.register(openPositionRoute); + return server; +}; + +const primeMocks = (receipt: any, uniswapOverrides: Record = {}) => { + (getUniswapPoolInfo as jest.Mock).mockResolvedValue({ + baseTokenAddress: WETH.address, + quoteTokenAddress: USDC.address, + }); + + (Uniswap.getInstance as jest.Mock).mockResolvedValue({ + getToken: jest.fn((address: string) => + Promise.resolve(address.toLowerCase() === WETH.address.toLowerCase() ? WETH : USDC), + ), + getV3Pool: jest.fn().mockResolvedValue(buildPool()), + ...uniswapOverrides, + }); + + const ethereum: any = { + provider: {}, + getWallet: jest.fn().mockResolvedValue({ address: mockWallet }), + getContract: jest.fn().mockReturnValue({}), + getERC20Allowance: jest.fn().mockResolvedValue({ value: BigNumber.from('1000000000000000000000000') }), + prepareGasOptions: jest.fn().mockResolvedValue({}), + handleTransactionExecution: jest.fn().mockResolvedValue(receipt), + }; + ethereum.handleTransactionConfirmation = RealEthereum.prototype.handleTransactionConfirmation.bind(ethereum); + (Ethereum.getInstance as jest.Mock).mockResolvedValue(ethereum); + + const { Contract } = require('@ethersproject/contracts'); + (Contract as jest.Mock).mockImplementation(() => ({ + multicall: jest.fn().mockResolvedValue({ hash: txHash }), + })); +}; + +const open = (server: any) => + server.inject({ + method: 'POST', + url: '/open-position', + payload: { + network: 'base', + walletAddress: mockWallet, + poolAddress, + lowerPrice: 2500, + upperPrice: 3500, + baseTokenAmount: 0.1, + quoteTokenAmount: 300, + }, + }); + +describe('POST /open-position (Uniswap V3 CLMM) — transaction confirmation', () => { + let server: any; + + beforeEach(async () => { + jest.clearAllMocks(); + (Ethereum.getWalletAddressExample as jest.Mock) = jest.fn().mockResolvedValue(mockWallet); + server = await buildApp(); + }); + + afterEach(async () => { + await server.close(); + }); + + it('returns the pending shape with the tx hash when the receipt is still missing', async () => { + primeMocks(null); + + const response = await open(server); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.signature).toBe(txHash); + expect(body.status).toBe(0); // TransactionStatus.PENDING + expect(body.data).toBeUndefined(); + }); + + it('fails loudly naming the tx hash when the confirmed transaction has no NFT mint log', async () => { + primeMocks({ + status: 1, + transactionHash: txHash, + logs: [], // no Transfer-from-zero log -> the position ID is unknowable + gasUsed: BigNumber.from(21_000), + effectiveGasPrice: BigNumber.from('1000000000'), + }); + + const response = await open(server); + + expect(response.statusCode).toBe(500); + const body = JSON.parse(response.body); + expect(body.message).toContain(txHash); + // Never a confirmed response with an unusable position address. + expect(response.body).not.toContain('positionAddress'); + }); + + it('returns the position ID read from the NFT mint log on confirmation', async () => { + primeMocks({ + status: 1, + transactionHash: txHash, + logs: [ + { + address: getUniswapV3NftManagerAddress('base'), + topics: [TRANSFER_TOPIC, ZERO_TOPIC, ZERO_TOPIC, BigNumber.from(987654).toHexString()], + }, + ], + gasUsed: BigNumber.from(21_000), + effectiveGasPrice: BigNumber.from('1000000000'), + }); + + const response = await open(server); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.status).toBe(1); // TransactionStatus.CONFIRMED + expect(body.data.positionAddress).toBe('987654'); + expect(body.data.fee).toBe(0.000021); + }); + + it('keeps the underlying error message instead of a bare "Failed to open position"', async () => { + primeMocks(null, { getV3Pool: jest.fn().mockRejectedValue(new Error('pool state fetch exploded')) }); + + const response = await open(server); + + expect(response.statusCode).toBe(500); + expect(JSON.parse(response.body).message).toContain('pool state fetch exploded'); + }); +}); diff --git a/test/trading/clmm/pool-info-bin-count.test.ts b/test/trading/clmm/pool-info-bin-count.test.ts new file mode 100644 index 0000000000..cdef04a21a --- /dev/null +++ b/test/trading/clmm/pool-info-bin-count.test.ts @@ -0,0 +1,124 @@ +// binCount must reach the connector from the unified route. It was silently +// dropped: the querystring schema had no binCount and the connector calls passed +// only (fastify, network, poolAddress), so `bins` could never be returned through +// /trading/clmm/pool-info even for connectors that support it. + +const orcaGetPoolInfo = jest.fn(); +const raydiumGetPoolInfo = jest.fn(); +const uniswapGetPoolInfo = jest.fn(); +const pancakeswapGetPoolInfo = jest.fn(); +const meteoraGetPoolInfo = jest.fn(); +const pancakeswapSolGetPoolInfo = jest.fn(); + +jest.mock('../../../src/connectors/orca/clmm-routes/poolInfo', () => ({ getPoolInfo: orcaGetPoolInfo })); +jest.mock('../../../src/connectors/raydium/clmm-routes/poolInfo', () => ({ getPoolInfo: raydiumGetPoolInfo })); +jest.mock('../../../src/connectors/uniswap/clmm-routes/poolInfo', () => ({ getPoolInfo: uniswapGetPoolInfo })); +jest.mock('../../../src/connectors/pancakeswap/clmm-routes/poolInfo', () => ({ getPoolInfo: pancakeswapGetPoolInfo })); +jest.mock('../../../src/connectors/meteora/clmm-routes/poolInfo', () => ({ getPoolInfo: meteoraGetPoolInfo })); +jest.mock('../../../src/connectors/pancakeswap-sol/clmm-routes/poolInfo', () => ({ + getPoolInfo: pancakeswapSolGetPoolInfo, +})); + +import { poolsRoute } from '../../../src/trading/clmm/pools'; +import { fastifyWithTypeProvider } from '../../utils/testUtils'; + +const POOL = 'Czfq3xZZDmsdGdUyrNLtRhGc47cXcZtLG4crryfu44zE'; + +const SAMPLE_POOL_INFO = { + address: POOL, + baseTokenAddress: 'So11111111111111111111111111111111111111112', + quoteTokenAddress: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + feePct: 0.04, + price: 75.6, + baseTokenAmount: 1, + quoteTokenAmount: 2, + activeBinId: -25813, +}; + +const buildApp = async () => { + const server = fastifyWithTypeProvider(); + await server.register(require('@fastify/sensible')); + await server.register(poolsRoute, { prefix: '/trading/clmm' }); + return server; +}; + +describe('Unified CLMM pool-info binCount passthrough', () => { + let app: any; + + beforeAll(async () => { + app = await buildApp(); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + for (const m of [ + orcaGetPoolInfo, + raydiumGetPoolInfo, + uniswapGetPoolInfo, + pancakeswapGetPoolInfo, + meteoraGetPoolInfo, + pancakeswapSolGetPoolInfo, + ]) { + m.mockResolvedValue(SAMPLE_POOL_INFO); + } + }); + + const call = (connector: string, chainNetwork: string, binCount?: number) => + app.inject({ + method: 'GET', + url: '/trading/clmm/pool-info', + query: { + connector, + chainNetwork, + poolAddress: POOL, + ...(binCount === undefined ? {} : { binCount: String(binCount) }), + }, + }); + + it.each([ + ['orca', 'solana-mainnet-beta', () => orcaGetPoolInfo], + ['raydium', 'solana-mainnet-beta', () => raydiumGetPoolInfo], + ['uniswap', 'ethereum-mainnet', () => uniswapGetPoolInfo], + ['pancakeswap', 'ethereum-bsc', () => pancakeswapGetPoolInfo], + ])('forwards binCount to %s', async (connector, chainNetwork, getMock) => { + const response = await call(connector, chainNetwork, 11); + + expect(response.statusCode).toBe(200); + expect(getMock()).toHaveBeenCalledWith(expect.anything(), expect.any(String), POOL, 11); + }); + + it('defaults binCount to 0 when the caller omits it', async () => { + const response = await call('orca', 'solana-mainnet-beta'); + + expect(response.statusCode).toBe(200); + expect(orcaGetPoolInfo).toHaveBeenCalledWith(expect.anything(), expect.any(String), POOL, 0); + }); + + it('does not pass binCount to meteora, which always returns its bins', async () => { + const response = await call('meteora', 'solana-mainnet-beta', 11); + + expect(response.statusCode).toBe(200); + expect(meteoraGetPoolInfo).toHaveBeenCalledWith(expect.anything(), expect.any(String), POOL); + }); + + it('returns the bins the connector produced', async () => { + const bins = [{ binId: -25813, price: 75.6, baseTokenAmount: 1, quoteTokenAmount: 2 }]; + orcaGetPoolInfo.mockResolvedValue({ ...SAMPLE_POOL_INFO, bins }); + + const response = await call('orca', 'solana-mainnet-beta', 1); + + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.body).bins).toEqual(bins); + }); + + it('rejects a binCount above the supported maximum', async () => { + const response = await call('orca', 'solana-mainnet-beta', 500); + + expect(response.statusCode).toBe(400); + expect(orcaGetPoolInfo).not.toHaveBeenCalled(); + }); +}); diff --git a/test/trading/clmm/routes.test.ts b/test/trading/clmm/routes.test.ts index 081f7b0b5e..23618b6e00 100644 --- a/test/trading/clmm/routes.test.ts +++ b/test/trading/clmm/routes.test.ts @@ -113,7 +113,7 @@ describe('Unified Trading CLMM Routes', () => { method: 'POST', url: '/trading/clmm/open', payload: { - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', walletAddress: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', lowerPrice: 1800, upperPrice: 2200, @@ -146,7 +146,7 @@ describe('Unified Trading CLMM Routes', () => { url: '/trading/clmm/open', payload: { connector: 'uniswap', - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', lowerPrice: 1800, upperPrice: 2200, poolAddress: '0x8ad599c3A0ff1De082011EFDDc58f1908eb6e6D8', @@ -178,7 +178,7 @@ describe('Unified Trading CLMM Routes', () => { url: '/trading/clmm/remove', payload: { connector: 'uniswap', - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', walletAddress: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', percentageToRemove: 50, }, @@ -265,7 +265,7 @@ describe('Unified Trading CLMM Routes', () => { url: '/trading/clmm/open', payload: { connector: 'uniswap', - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', walletAddress: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', lowerPrice: 1800, upperPrice: 2200, @@ -301,7 +301,7 @@ describe('Unified Trading CLMM Routes', () => { url: '/trading/clmm/remove', payload: { connector: 'raydium', - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', walletAddress: 'BPgNwGDBiRuaAKuRQLpXC9rCiw5FfJDDdTunDEmtN6VF', positionAddress: 'position123', percentageToRemove: 50, @@ -317,7 +317,7 @@ describe('Unified Trading CLMM Routes', () => { url: '/trading/clmm/collect-fees', payload: { connector: 'meteora', - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', walletAddress: 'BPgNwGDBiRuaAKuRQLpXC9rCiw5FfJDDdTunDEmtN6VF', positionAddress: 'position456', }, @@ -337,7 +337,7 @@ describe('Unified Trading CLMM Routes', () => { url: '/trading/clmm/close', payload: { connector: 'pancakeswap-sol', - network: 'mainnet-beta', + chainNetwork: 'solana-mainnet-beta', walletAddress: 'BPgNwGDBiRuaAKuRQLpXC9rCiw5FfJDDdTunDEmtN6VF', positionAddress: 'position789', }, @@ -352,17 +352,21 @@ describe('Unified Trading CLMM Routes', () => { url: '/trading/clmm/open', payload: { connector: 'invalid-connector', - network: 'mainnet', + chainNetwork: 'ethereum-mainnet', walletAddress: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb', lowerPrice: 1800, upperPrice: 2200, poolAddress: '0x8ad599c3A0ff1De082011EFDDc58f1908eb6e6D8', + // An otherwise-valid body: amount validation runs before connector + // routing, and this test is about the connector rejection. + baseTokenAmount: 1, }, }); expect(response.statusCode).toBe(400); const body = JSON.parse(response.body); - expect(body.message).toContain('Unsupported connector'); + // The connector field is enum-constrained, so rejection happens at schema validation. + expect(body.message).toContain('must be equal to one of the allowed values'); }); }); }); diff --git a/test/trading/swap/pool-address-pin.test.ts b/test/trading/swap/pool-address-pin.test.ts new file mode 100644 index 0000000000..ba960d334e --- /dev/null +++ b/test/trading/swap/pool-address-pin.test.ts @@ -0,0 +1,148 @@ +import { tradingSwapRoutes } from '../../../src/trading/trading.routes'; +import { fastifyWithTypeProvider } from '../../utils/testUtils'; + +// The unified swap route resolves a pool from Gateway's configured pool list by +// token pair. A pool that is not in that list — a freshly created one, or one on +// an unlisted token — is unreachable that way, so callers can pin it by address. +// Routers choose their own route across pools and must reject the pin outright. + +const mockGetPool = jest.fn(); + +jest.mock('../../../src/services/pool-service', () => ({ + PoolService: { + getInstance: () => ({ + getPool: (...args: any[]) => mockGetPool(...args), + }), + }, +})); + +const mockMeteoraClmmQuoteSwap = jest.fn(); +jest.mock('../../../src/connectors/meteora/clmm-routes/quoteSwap', () => ({ + quoteSwap: (...args: any[]) => mockMeteoraClmmQuoteSwap(...args), +})); + +const mockJupiterRouterQuoteSwap = jest.fn(); +jest.mock('../../../src/connectors/jupiter/router-routes/quoteSwap', () => ({ + quoteSwap: (...args: any[]) => mockJupiterRouterQuoteSwap(...args), +})); + +jest.mock('../../../src/chains/solana/solana.config', () => ({ + ...jest.requireActual('../../../src/chains/solana/solana.config'), + getSolanaNetworkConfig: () => ({ swapProvider: 'jupiter/router' }), +})); + +const PINNED_POOL = '2sf5NYcY4zUPXUSmG6f66mskb24t5F8S11pC1Nz5nQT3'; + +const QUOTE = { + tokenIn: 'So11111111111111111111111111111111111111112', + tokenOut: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + amountIn: 1, + amountOut: 100, + price: 100, + minAmountOut: 99, + maxAmountIn: 1, + priceImpactPct: 0.1, +}; + +const buildApp = async () => { + const server = fastifyWithTypeProvider(); + await server.register(require('@fastify/sensible')); + await server.register(tradingSwapRoutes, { prefix: '/trading/swap' }); + return server; +}; + +const quoteUrl = (params: Record) => `/trading/swap/quote?${new URLSearchParams(params).toString()}`; + +describe('Unified swap quote — poolAddress pin', () => { + let app: any; + + beforeAll(async () => { + app = await buildApp(); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockMeteoraClmmQuoteSwap.mockResolvedValue(QUOTE); + mockJupiterRouterQuoteSwap.mockResolvedValue(QUOTE); + }); + + it('uses the pinned pool without consulting the configured pool list', async () => { + const response = await app.inject({ + method: 'GET', + url: quoteUrl({ + chainNetwork: 'solana-mainnet-beta', + connector: 'meteora/clmm', + baseToken: 'SOL', + quoteToken: 'USDC', + amount: '1', + side: 'SELL', + poolAddress: PINNED_POOL, + }), + }); + + expect(response.statusCode).toBe(200); + expect(mockGetPool).not.toHaveBeenCalled(); + expect(mockMeteoraClmmQuoteSwap).toHaveBeenCalledWith('mainnet-beta', PINNED_POOL, 'SOL', 'SELL', 1, undefined); + }); + + it('falls back to the configured pool list when no pin is given', async () => { + mockGetPool.mockResolvedValue({ address: PINNED_POOL }); + + const response = await app.inject({ + method: 'GET', + url: quoteUrl({ + chainNetwork: 'solana-mainnet-beta', + connector: 'meteora/clmm', + baseToken: 'SOL', + quoteToken: 'USDC', + amount: '1', + side: 'SELL', + }), + }); + + expect(response.statusCode).toBe(200); + expect(mockGetPool).toHaveBeenCalledWith('solana', 'mainnet-beta', 'clmm', 'SOL', 'USDC', 'meteora'); + }); + + it('tells an unresolvable pair that a pin is available', async () => { + mockGetPool.mockResolvedValue(null); + + const response = await app.inject({ + method: 'GET', + url: quoteUrl({ + chainNetwork: 'solana-mainnet-beta', + connector: 'meteora/clmm', + baseToken: 'NEWMINT', + quoteToken: 'SOL', + amount: '1', + side: 'SELL', + }), + }); + + expect(response.statusCode).toBe(404); + expect(response.json().message).toContain('poolAddress'); + }); + + it('rejects a pin on a router provider, which picks its own route', async () => { + const response = await app.inject({ + method: 'GET', + url: quoteUrl({ + chainNetwork: 'solana-mainnet-beta', + connector: 'jupiter/router', + baseToken: 'SOL', + quoteToken: 'USDC', + amount: '1', + side: 'SELL', + poolAddress: PINNED_POOL, + }), + }); + + expect(response.statusCode).toBe(400); + expect(response.json().message).toContain('poolAddress is not supported'); + expect(mockJupiterRouterQuoteSwap).not.toHaveBeenCalled(); + }); +}); diff --git a/test/trading/trading-amm-routes/create-pool.test.ts b/test/trading/trading-amm-routes/create-pool.test.ts index a100a49ba3..4de0d8ea1a 100644 --- a/test/trading/trading-amm-routes/create-pool.test.ts +++ b/test/trading/trading-amm-routes/create-pool.test.ts @@ -34,6 +34,6 @@ describe('POST /trading/amm/create-pool (unified dispatch)', () => { }); expect(response.statusCode).toBe(400); - expect(JSON.parse(response.body).message).toMatch(/Unsupported AMM connector/); + expect(JSON.parse(response.body).message).toMatch(/must be equal to one of the allowed values/); }); }); diff --git a/test/trading/trading-amm-routes/positions-owned.test.ts b/test/trading/trading-amm-routes/positions-owned.test.ts index 73504851cb..20e1d96993 100644 --- a/test/trading/trading-amm-routes/positions-owned.test.ts +++ b/test/trading/trading-amm-routes/positions-owned.test.ts @@ -41,6 +41,6 @@ describe('GET /trading/amm/positions-owned (unified dispatch)', () => { }); expect(response.statusCode).toBe(400); - expect(JSON.parse(response.body).message).toMatch(/Unsupported AMM connector/); + expect(JSON.parse(response.body).message).toMatch(/must be equal to one of the allowed values/); }); }); diff --git a/test/trading/trading-amm-routes/remove-liquidity.test.ts b/test/trading/trading-amm-routes/remove-liquidity.test.ts index 26f6791b4f..bf343b4ff5 100644 --- a/test/trading/trading-amm-routes/remove-liquidity.test.ts +++ b/test/trading/trading-amm-routes/remove-liquidity.test.ts @@ -50,6 +50,6 @@ describe('POST /trading/amm/remove-liquidity (unified dispatch)', () => { }); expect(response.statusCode).toBe(400); - expect(JSON.parse(response.body).message).toMatch(/Unsupported AMM connector/); + expect(JSON.parse(response.body).message).toMatch(/must be equal to one of the allowed values/); }); }); diff --git a/test/trading/trading-clmm-routes/create-pool.test.ts b/test/trading/trading-clmm-routes/create-pool.test.ts index 4c1da80f74..0871f879a8 100644 --- a/test/trading/trading-clmm-routes/create-pool.test.ts +++ b/test/trading/trading-clmm-routes/create-pool.test.ts @@ -33,6 +33,6 @@ describe('POST /trading/clmm/create-pool (unified dispatch)', () => { }); expect(response.statusCode).toBe(400); - expect(JSON.parse(response.body).message).toMatch(/Unsupported CLMM connector/); + expect(JSON.parse(response.body).message).toMatch(/must be equal to one of the allowed values/); }); });